feat(ack): refine intake and validation workflow
This commit is contained in:
@@ -195,15 +195,21 @@ Codex bypass、Cursor YOLO/force 和关闭 sandbox 都会 fail closed;在有
|
|||||||
审批或独立签发通道之前,不用项目文件伪装成用户授权。旧任务板没有结构化
|
审批或独立签发通道之前,不用项目文件伪装成用户授权。旧任务板没有结构化
|
||||||
`project.orchestration` 时仍可读取和手动协作,但不得自动创建 worker。
|
`project.orchestration` 时仍可读取和手动协作,但不得自动创建 worker。
|
||||||
|
|
||||||
持久化 `receiptHash` 是无密钥 checksum,不是 launcher 身份证明。由于 Orca 当前
|
持久化 `receiptHash` 是无密钥 checksum,不是 launcher 身份证明。ACK 只复用同一轮
|
||||||
不能证明旧终端的原始 argv/模型/权限,v0.10 不自动复用既有 worker;每次自动派发
|
内明确空闲、角色/profile/worktree 匹配,并且可以可信清理历史消息、取得新会话身份的
|
||||||
都重新 `plan` 并用 expected fingerprint 启动 fresh worker。
|
worker;正在执行、等待回报、状态不明或关联未完成任务的 worker 不复用。由于当前
|
||||||
|
Orca 没有可验证的历史消息清理接口,Orca 派发仍重新 `plan` 并用 expected fingerprint
|
||||||
|
启动 fresh worker。
|
||||||
fingerprint 只校验完整计划没有漂移,不是一次性令牌;成功后不得用同一 fingerprint
|
fingerprint 只校验完整计划没有漂移,不是一次性令牌;成功后不得用同一 fingerprint
|
||||||
重复启动,结果不确定时必须先 reconcile。
|
重复启动,结果不确定时必须先 reconcile。
|
||||||
若创建或关闭回执不完整,或外部 launch record 状态无法可靠持久化,launcher 会返回
|
若创建或关闭回执不完整,或外部 launch record 状态无法可靠持久化,launcher 会返回
|
||||||
`indeterminate/reconcile-required`;必须先核对 record 与 Orca live state,不能
|
`indeterminate/reconcile-required`;必须先核对 record 与 Orca live state,不能
|
||||||
直接重试。
|
直接重试。
|
||||||
|
|
||||||
|
Coordinator 最后标记整轮任务完成后,会关闭所有只关联 `verified` 任务的 worker
|
||||||
|
终端。仍关联 `blocked`、`failed_retest`、`leftover`、其它未完成任务或未解决环境事件
|
||||||
|
的终端继续保留且不设置 TTL;receipt 与测试证据不会随终端删除。
|
||||||
|
|
||||||
## 开始一个需求
|
## 开始一个需求
|
||||||
|
|
||||||
初始化完成后可以直接说:
|
初始化完成后可以直接说:
|
||||||
|
|||||||
+39
-18
@@ -135,31 +135,40 @@ description: >-
|
|||||||
worker 启动规则。项目覆盖层优先于通用示例命令。按 scope 推荐相关 `active`
|
worker 启动规则。项目覆盖层优先于通用示例命令。按 scope 推荐相关 `active`
|
||||||
知识,经确认后把固定 revision 的显式 `knowledgeRefs` 写入当前任务上下文;
|
知识,经确认后把固定 revision 的显式 `knowledgeRefs` 写入当前任务上下文;
|
||||||
不全量注入知识库。
|
不全量注入知识库。
|
||||||
`project.bugIntake.workflow` 为 `reviewed-writeback-v1` 时,按
|
`project.bugIntake.workflow` 为 `clarified-writeback-v1`(推荐)或
|
||||||
|
`reviewed-writeback-v1`(兼容旧项目)时,按
|
||||||
`references/feishu-bug-intake.md` 把飞书作为审核前的唯一协作区:先运行 check/plan,
|
`references/feishu-bug-intake.md` 把飞书作为审核前的唯一协作区:先运行 check/plan,
|
||||||
读取用户填写的 Bug;Coordinator 根据来源事实与项目上下文补全修复逻辑和可观测验收
|
读取用户填写的 Bug。新工作流中,用户只维护标题、详细描述和附件;Coordinator 根据
|
||||||
标准,只通过安全适配器写回同一飞书记录并回读确认。用户反馈后继续只在飞书修订。
|
来源事实与项目上下文整理问题说明、期望效果和可观测验收标准,不在收件箱写修复逻辑,
|
||||||
用户针对当前 `draftRevision` 明确审核通过前,不创建或刷新 `tasks.yaml` 任务、不启动
|
只通过安全适配器写回同一飞书记录并回读确认。用户反馈后继续只在飞书修订。
|
||||||
worker、不派发 Developer/Test,也不修改应用代码。审核通过后重新读取,要求 revision
|
用户针对当前 `draftRevision` 明确审核通过并亲自在飞书把状态改为 `已确认` 前,不创建
|
||||||
与批准值完全一致,才通过 `import-approved` 生成规范 `taskDraft`,原样写入最终版本、
|
或刷新 `tasks.yaml` 任务、不启动 worker、不派发 Developer/Test,也不修改应用代码。
|
||||||
|
Coordinator 不得自行写入 `已确认`。审核通过后重新读取,要求 revision 与批准值完全
|
||||||
|
一致,才通过 `import-approved` 生成规范 `taskDraft`,原样写入最终版本、
|
||||||
`source.workflow`、`source.approvedRevision` 与 `source.approvedPayloadHash`;校验器重算
|
`source.workflow`、`source.approvedRevision` 与 `source.approvedPayloadHash`;校验器重算
|
||||||
payload hash 通过后才进入三角色闭环。未声明 workflow 的旧八字段配置只按
|
payload hash 通过后,再用 `mark-imported` 把最终任务 ID 与同一 revision 写回飞书,
|
||||||
|
才进入三角色闭环。未声明 workflow 的旧八字段配置只按
|
||||||
`read-only-v1` 兼容,不得写回;
|
`read-only-v1` 兼容,不得写回;
|
||||||
标题、实际表现和预期结果不可推断;整行空白记录按批次 warning 跳过。
|
标题、详细描述和附件是来源事实,不得把 Coordinator 推断伪装成用户原文;整行空白
|
||||||
|
记录按批次 warning 跳过。
|
||||||
按每条记录的 `sourceRef` 去重:仅 `open` 任务可刷新描述;
|
按每条记录的 `sourceRef` 去重:仅 `open` 任务可刷新描述;
|
||||||
`dispatched`、`fixed_by_dev`、`retesting`、`failed_retest`、`verified`、`blocked` 和
|
`dispatched`、`fixed_by_dev`、`retesting`、`failed_retest`、`verified`、`blocked` 和
|
||||||
`leftover` 只报告来源漂移,绝不覆盖;来源消失或读取失败时绝不删除已有任务。
|
`leftover` 只报告来源漂移,绝不覆盖;来源消失或读取失败时绝不删除已有任务。
|
||||||
4. 新需求先写产品文档、任务拆分与可观测验收信号,更新 `tasks.yaml` 并校验,
|
4. 新需求先写产品文档、任务拆分与可观测验收信号,更新 `tasks.yaml` 并校验,
|
||||||
然后交给用户确认;若启用了交付,还要把本次 profile、目标、停止点和需要审批的
|
然后交给用户确认;若启用了交付,必须默认把 `defaultProfile`、目标、停止点和需要
|
||||||
步骤放入同一份计划。确认前不派发实现,也不执行交付。
|
审批的步骤放入同一份计划,不能静默省略。用户可明确取消本轮交付;确认前不派发
|
||||||
|
实现,也不执行交付。
|
||||||
5. 创建或更换 worker 时,只使用
|
5. 创建或更换 worker 时,只使用
|
||||||
`<ack-skill-dir>/scripts/launch_worker.py plan|launch` 读取
|
`<ack-skill-dir>/scripts/launch_worker.py plan|launch` 读取
|
||||||
`tasks.yaml.project.orchestration` 的 profile。不得直接执行
|
`tasks.yaml.project.orchestration` 的 profile。不得直接执行
|
||||||
`orca terminal create --command`,不得接受或拼接自由 command、额外 argv、
|
`orca terminal create --command`,不得接受或拼接自由 command、额外 argv、
|
||||||
executable、env 或 cwd。必须先审阅 `plan.launchFingerprint`,再把它作为
|
executable、env 或 cwd。必须先审阅 `plan.launchFingerprint`,再把它作为
|
||||||
`launch --expected-launch-fingerprint` 传入。v0.10 不根据持久化 receipt 自动
|
`launch --expected-launch-fingerprint` 传入。派发前先寻找同一 ACK 运行内的空闲
|
||||||
复用旧终端;每次自动派发都创建 fresh worker,receipt 只作审计与 dispatch
|
worker;只有角色、profile、worktree 和启动身份仍完全匹配,且后端能清理历史消息、
|
||||||
关联。
|
返回可核对的新会话身份时才复用。不得复用正在工作、等待回报或状态不明的 worker;
|
||||||
|
任一条件不符、清理能力不存在或无法确认清理成功时创建 fresh worker。持久化
|
||||||
|
receipt 只作审计与 dispatch 关联,不能单独授权复用。当前 Orca 终端接口不能提供
|
||||||
|
可验证的历史消息清理,因此使用 Orca 时仍走 fresh worker。
|
||||||
6. 用户已确认的任务按 ACK 闭环执行:Developer 实现与白盒验证,Test 独立黑盒
|
6. 用户已确认的任务按 ACK 闭环执行:Developer 实现与白盒验证,Test 独立黑盒
|
||||||
复测,Coordinator 读取证据终检并唯一写入 `tasks.yaml`。Developer 回报
|
复测,Coordinator 读取证据终检并唯一写入 `tasks.yaml`。Developer 回报
|
||||||
`knowledgeApplied` 和 `knowledgeCandidates`,Test 回报 `knowledgeChecks`;
|
`knowledgeApplied` 和 `knowledgeCandidates`,Test 回报 `knowledgeChecks`;
|
||||||
@@ -168,15 +177,27 @@ description: >-
|
|||||||
`<ack-skill-dir>/scripts/run_verification.py docs/ack/knowledge.yaml
|
`<ack-skill-dir>/scripts/run_verification.py docs/ack/knowledge.yaml
|
||||||
<verification-ref> --project-root <project-root>`。不要直接执行选择器返回的 path/args,
|
<verification-ref> --project-root <project-root>`。不要直接执行选择器返回的 path/args,
|
||||||
也不要给 runner 注入额外命令或参数。
|
也不要给 runner 注入额外命令或参数。
|
||||||
8. 不把 `worker_done` 或 Test 自报成功直接当作完成。每项最多三轮,仍失败则记录
|
8. 不把 `worker_done` 或 Test 自报成功直接当作完成。三轮预算只计算 Test 已对齐正确
|
||||||
`leftover` 并继续其它任务。
|
服务、数据和工具后实际执行验收所得的产品失败;环境失败不占复验轮次,不写
|
||||||
|
`failed_retest`,而写入 `dispatch.environmentIncidents`。Coordinator 先做一次有界、
|
||||||
|
安全的恢复;事件未解决、需要用户动作或会阻断本轮时,立即向用户报告原因、影响、
|
||||||
|
已尝试动作、下一恢复动作和明确的 `userAction`;即使已自动恢复,也要在最终报告汇总。
|
||||||
|
每项最多三轮有效产品复验,仍失败才记录 `leftover` 并继续其它任务。细则见
|
||||||
|
`references/optimization-method.md` §4。
|
||||||
9. 关键的安全、正确性和兼容性约束应下沉为测试、lint、CI 或正式规范;
|
9. 关键的安全、正确性和兼容性约束应下沉为测试、lint、CI 或正式规范;
|
||||||
`knowledge.yaml` 只保存触发条件、原因与证据引用,不能替代可执行控制。
|
`knowledge.yaml` 只保存触发条件、原因与证据引用,不能替代可执行控制。
|
||||||
10. 选定任务全部进入 `verified` 后,若 `delivery.enabled: true` 且用户确认的本次计划
|
10. 选定任务全部进入 `verified` 后,若 `delivery.enabled: true` 且用户确认的本次计划
|
||||||
包含交付,按 `references/delivery.md` 顺序执行 profile,并由 Coordinator 把证据
|
包含交付,按 `references/delivery.md` 顺序执行 profile,并由 Coordinator 把证据
|
||||||
写入 `tasks.yaml.deliveryRuns`。任务状态保持 `verified`;交付失败只改变 delivery
|
写入 `tasks.yaml.deliveryRuns`。任务状态保持 `verified`;交付失败只改变 delivery
|
||||||
run,不回写成任务失败。默认 profile 最多到 `review_ready`,稳定发布和生产部署
|
run,不回写成任务失败。开发或测试环境完成构建、部署和健康检查后写
|
||||||
必须在对应步骤再次取得明确批准。
|
`validation_ready`,并把访问地址、验证范围和用户下一步交给用户;不能停在
|
||||||
|
`verified` 却声称整轮 ACK 已结束。默认 profile 最多到 `validation_ready` 或
|
||||||
|
`review_ready`,稳定发布和生产部署必须在对应步骤再次取得明确批准。
|
||||||
|
11. Coordinator 最后标记整轮任务完成后,回收所有只属于 `verified` 任务的 worker
|
||||||
|
终端,并核对关闭回执;历史 receipt 和任务证据继续保留。任何还被 `open`、
|
||||||
|
`dispatched`、`fixed_by_dev`、`retesting`、`blocked`、`failed_retest`、`leftover`
|
||||||
|
或未解决环境事件引用的终端都保留,不设置 TTL,也不能因为同一终端还关联过
|
||||||
|
`verified` 任务而误关。若关闭结果不确定,记录并报告,不重复关闭或伪报已回收。
|
||||||
|
|
||||||
## 交付配置维护
|
## 交付配置维护
|
||||||
|
|
||||||
@@ -200,7 +221,7 @@ description: >-
|
|||||||
- 不把 full-access、bypass、YOLO/force、关闭 sandbox 或项目内“授权”字段当成
|
- 不把 full-access、bypass、YOLO/force、关闭 sandbox 或项目内“授权”字段当成
|
||||||
v0.10 自动 worker 的合法配置;当前一律 fail closed。
|
v0.10 自动 worker 的合法配置;当前一律 fail closed。
|
||||||
- 不把无密钥 `receiptHash` 或 Orca live metadata 当作旧终端的启动 attestation;
|
- 不把无密钥 `receiptHash` 或 Orca live metadata 当作旧终端的启动 attestation;
|
||||||
v0.10 不自动复用既有 worker。
|
没有可信空闲状态、配置匹配和历史消息清理证明时不复用既有 worker。
|
||||||
- launcher 返回 `indeterminate` 或 `reconcile required` 时,不直接重试;先按
|
- launcher 返回 `indeterminate` 或 `reconcile required` 时,不直接重试;先按
|
||||||
launch ID、外部 record 和 Orca live state 完成人工核对。
|
launch ID、外部 record 和 Orca live state 完成人工核对。
|
||||||
- 不覆盖已有 `docs/ack` 文件;除用户确认的 ACK 任务或 delivery profile 外,不擅自
|
- 不覆盖已有 `docs/ack` 文件;除用户确认的 ACK 任务或 delivery profile 外,不擅自
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
0.14.1
|
0.15.0
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# notes-web Agent 协作协议(示例,项目覆盖层)
|
# notes-web Agent 协作协议(示例,项目覆盖层)
|
||||||
|
|
||||||
> 本项目基于 ack v0.14.1。
|
> 本项目基于 ack v0.15.0。
|
||||||
> 通用规范由 `/ack` 从 Skill 自身的 `references/` 读取,本文件只填项目差异。
|
> 通用规范由 `/ack` 从 Skill 自身的 `references/` 读取,本文件只填项目差异。
|
||||||
> 覆盖层文件放在 `docs/ack/project.md`,不占用 `AGENTS.md`。
|
> 覆盖层文件放在 `docs/ack/project.md`,不占用 `AGENTS.md`。
|
||||||
> ACK 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
> ACK 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
||||||
@@ -90,14 +90,15 @@ path/args。
|
|||||||
`orca --command`。v0.10 的自动 profile 只允许 `read-only` 或
|
`orca --command`。v0.10 的自动 profile 只允许 `read-only` 或
|
||||||
`workspace-write`,不允许 full-access、bypass、YOLO/force 或关闭 sandbox。
|
`workspace-write`,不允许 full-access、bypass、YOLO/force 或关闭 sandbox。
|
||||||
- 每次自动派发都先审阅 plan,再用 `--expected-launch-fingerprint` 启动 fresh
|
- 每次自动派发都先审阅 plan,再用 `--expected-launch-fingerprint` 启动 fresh
|
||||||
worker;持久化 receipt 只作审计,不授权自动复用旧终端。
|
worker;持久化 receipt 只作审计。仅同轮空闲、身份匹配并可信清理历史消息后复用;
|
||||||
|
整轮结束回收 verified 任务终端,blocked/failed/leftover 终端保留且不设 TTL。
|
||||||
- `worker_done` 与复测报告都不等于完成,必须 Test 独立复测 + Coordinator 终检后才能 `verified`。
|
- `worker_done` 与复测报告都不等于完成,必须 Test 独立复测 + Coordinator 终检后才能 `verified`。
|
||||||
- 只有 Coordinator 写 `tasks.yaml` 和 `knowledge.yaml`;Test 与 Developer 只读。
|
- 只有 Coordinator 写 `tasks.yaml` 和 `knowledge.yaml`;Test 与 Developer 只读。
|
||||||
- Coordinator 只派发按 scope 命中并显式写入 `knowledgeRefs` 的 `active` 知识;
|
- Coordinator 只派发按 scope 命中并显式写入 `knowledgeRefs` 的 `active` 知识;
|
||||||
`candidate` 不派发,知识库不全量注入。
|
`candidate` 不派发,知识库不全量注入。
|
||||||
- Developer 回报 `knowledgeApplied` 与 `knowledgeCandidates`,Test 回报
|
- Developer 回报 `knowledgeApplied` 与 `knowledgeCandidates`,Test 回报
|
||||||
`knowledgeChecks`;关键约束应继续下沉到测试、lint、CI 或正式规范。
|
`knowledgeChecks`;关键约束应继续下沉到测试、lint、CI 或正式规范。
|
||||||
- 交付只在任务 `verified` 后运行;默认 profile 停在 `review_ready`。stable 发布与
|
- 交付只在任务 `verified` 后运行;默认 profile 停在 `validation_ready` 或 `review_ready`。stable 发布与
|
||||||
production 部署保留显式 approval,配置变更只影响下一次 run。
|
production 部署保留显式 approval,配置变更只影响下一次 run。
|
||||||
- 每个任务最多派发 3 轮,仍不过标记 `leftover` 并继续。
|
- 每个任务最多派发 3 轮,仍不过标记 `leftover` 并继续。
|
||||||
- 不提交、推送、发布或部署,除非用户确认的 ACK 任务或 delivery profile 明确包含。
|
- 不提交、推送、发布或部署,除非用户确认的 ACK 任务或 delivery profile 明确包含。
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
version: 1
|
version: 1
|
||||||
updatedAt: "2026-07-06T09:40:00+08:00"
|
updatedAt: "2026-07-06T09:40:00+08:00"
|
||||||
source: "Coordinator (PM) Agent"
|
source: "Coordinator (PM) Agent"
|
||||||
ackVersion: "0.14.1"
|
ackVersion: "0.15.0"
|
||||||
project:
|
project:
|
||||||
name: "notes-web"
|
name: "notes-web"
|
||||||
baseUrl: "http://localhost:5173"
|
baseUrl: "http://localhost:5173"
|
||||||
@@ -158,6 +158,19 @@ tasks:
|
|||||||
attemptId: "BUG-002-A2"
|
attemptId: "BUG-002-A2"
|
||||||
result: passed
|
result: passed
|
||||||
evidence: "复测 4 行 diff 全部出现,取消不触发写入"
|
evidence: "复测 4 行 diff 全部出现,取消不触发写入"
|
||||||
|
environmentIncidents:
|
||||||
|
- id: "BUG-002-ENV-1"
|
||||||
|
attemptId: "BUG-002-A2"
|
||||||
|
role: test
|
||||||
|
phase: service
|
||||||
|
status: resolved
|
||||||
|
summary: "第一次复测连接到旧构建"
|
||||||
|
evidence: "服务进程的 worktree 与当前任务不一致"
|
||||||
|
impact: "该次执行没有形成产品通过或失败结论,不计复验轮次"
|
||||||
|
recoveryAction: "重启当前 worktree 的最新服务后重新复验"
|
||||||
|
userAction: "无需操作;Coordinator 已完成服务对齐"
|
||||||
|
reportedAt: "2026-07-06T09:30:00+08:00"
|
||||||
|
resolvedAt: "2026-07-06T09:32:00+08:00"
|
||||||
resolution:
|
resolution:
|
||||||
fixedBy: "dev-worker-1"
|
fixedBy: "dev-worker-1"
|
||||||
verifiedBy: "test-worker-1"
|
verifiedBy: "test-worker-1"
|
||||||
|
|||||||
@@ -88,8 +88,9 @@
|
|||||||
`--expected-launch-fingerprint`;漂移时重新 plan。
|
`--expected-launch-fingerprint`;漂移时重新 plan。
|
||||||
- [ ] Developer/Test 只使用 `read-only` 或 `workspace-write` profile;
|
- [ ] Developer/Test 只使用 `read-only` 或 `workspace-write` profile;
|
||||||
v0.10 没有 full-access、bypass、YOLO/force 或关闭 sandbox。
|
v0.10 没有 full-access、bypass、YOLO/force 或关闭 sandbox。
|
||||||
- [ ] v0.10 每次自动派发都启动 fresh worker;没有把历史 receipt checksum 或
|
- [ ] 只考虑同轮、空闲、角色/profile/worktree/启动身份匹配的复用候选;历史消息必须
|
||||||
Orca live metadata 当作自动复用授权。
|
由受信后端清理并返回新会话身份。任一条件不符或无法确认清理成功时启动 fresh
|
||||||
|
worker;没有把历史 receipt checksum 或 Orca live metadata 当作复用授权。
|
||||||
- [ ] launcher receipt 已追加到顶层 `workerReceipts`,任务分别引用
|
- [ ] launcher receipt 已追加到顶层 `workerReceipts`,任务分别引用
|
||||||
`dispatch.developer` 与 `dispatch.test`;每个引用的 task/role/profile/attempt
|
`dispatch.developer` 与 `dispatch.test`;每个引用的 task/role/profile/attempt
|
||||||
都与当前任务、本轮 receipt 完全一致,没有用单一 handle 混淆两个角色。
|
都与当前任务、本轮 receipt 完全一致,没有用单一 handle 混淆两个角色。
|
||||||
@@ -103,6 +104,8 @@
|
|||||||
- [ ] 每轮写入 `dispatch.rounds`。
|
- [ ] 每轮写入 `dispatch.rounds`。
|
||||||
- [ ] 三轮失败后标记 `leftover` 并填写原因。
|
- [ ] 三轮失败后标记 `leftover` 并填写原因。
|
||||||
- [ ] `leftover` 不阻塞其它任务。
|
- [ ] `leftover` 不阻塞其它任务。
|
||||||
|
- [ ] Coordinator 标记整轮完成后,已关闭所有只属于 `verified` 任务的 worker;仍关联
|
||||||
|
blocked/failed/leftover、未完成任务或未解决环境事件的终端保留且不设 TTL。
|
||||||
|
|
||||||
首次接入建议选择一个低风险问题跑完整闭环。项目差异写回
|
首次接入建议选择一个低风险问题跑完整闭环。项目差异写回
|
||||||
`docs/ack/project.md`;通用问题回流到 ACK Skill 的 `references/`、`templates/`
|
`docs/ack/project.md`;通用问题回流到 ACK Skill 的 `references/`、`templates/`
|
||||||
|
|||||||
@@ -36,24 +36,27 @@ Coordinator 发现或读取 open 任务
|
|||||||
-> 决定 worktree:当前 worktree 起子 agent,还是新建隔离 worktree(见下节「子任务放哪」)
|
-> 决定 worktree:当前 worktree 起子 agent,还是新建隔离 worktree(见下节「子任务放哪」)
|
||||||
-> 解析安全 profile:机器配置只读 tasks.yaml.project.orchestration
|
-> 解析安全 profile:机器配置只读 tasks.yaml.project.orchestration
|
||||||
-> 运行 launcher plan 并审阅 launchFingerprint
|
-> 运行 launcher plan 并审阅 launchFingerprint
|
||||||
-> 带 expected fingerprint 启动 fresh worker,把 receipt 写回 tasks.yaml(见 orca-adapter.md)
|
-> 检查同轮空闲 worker;可信清理历史消息成功才复用,否则带 expected fingerprint 启动 fresh worker
|
||||||
|
-> 把本次 task/attempt receipt 写回 tasks.yaml(见 orca-adapter.md)
|
||||||
-> dispatch 给 Developer(--to <worker handle>)
|
-> dispatch 给 Developer(--to <worker handle>)
|
||||||
-> wait:Developer 的 worker_done / escalation(含 knowledgeApplied / knowledgeCandidates)
|
-> wait:Developer 的 worker_done / escalation(含 knowledgeApplied / knowledgeCandidates)
|
||||||
-> writeback fixed_by_dev
|
-> writeback fixed_by_dev
|
||||||
-> 为 Test 独立解析安全 profile,重新 plan/launch fresh worker 并写回 receipt
|
-> 为 Test 独立解析安全 profile;安全重置同角色空闲 worker,或重新 plan/launch fresh worker
|
||||||
-> dispatch 给 Test(retesting)
|
-> dispatch 给 Test(retesting)
|
||||||
-> wait:Test 的 retest_result(含 knowledgeChecks 和 candidate 独立证据)
|
-> wait:Test 的 retest_result(含 knowledgeChecks 和 candidate 独立证据)
|
||||||
-> Test 通过:gate(Coordinator 读证据对齐意图)
|
-> Test 通过:gate(Coordinator 读证据对齐意图)
|
||||||
-> 通过 gate:writeback verified
|
-> 通过 gate:writeback verified
|
||||||
-> gate 不满足意图:writeback failed_retest,带意图差异再派发 Developer
|
-> gate 不满足意图:writeback failed_retest,带意图差异再派发 Developer
|
||||||
-> Test 失败:writeback failed_retest,追加证据,再派发 Developer(最多累计三轮)
|
-> Test 因环境无法完成:记录 environmentIncidents,不计轮次;有界恢复并明确告知用户下一步
|
||||||
-> 累计三轮失败:writeback leftover,继续下一个任务
|
-> Test 观察到产品失败:writeback failed_retest,追加证据,再派发 Developer(最多累计三轮有效产品复验)
|
||||||
|
-> 累计三轮有效产品失败:writeback leftover,继续下一个任务
|
||||||
```
|
```
|
||||||
|
|
||||||
一次派发只修一个明确问题(细则见 `optimization-method.md` §「每轮派发只修一个明确问题」)。
|
一次派发只修一个明确问题(细则见 `optimization-method.md` §「每轮派发只修一个明确问题」)。
|
||||||
「决定 worktree」「解析 profile/receipt」几步的决策见下节与 `model-routing.md` /
|
「决定 worktree」「解析 profile/receipt」几步的决策见下节与 `model-routing.md` /
|
||||||
`orca-adapter.md`。v0.10 不自动复用历史 receipt 或既有终端;标题、preview、分支名、
|
`orca-adapter.md`。历史 receipt、标题、preview、分支名、worker 自报和无密钥 receipt
|
||||||
worker 自报和无密钥 receipt checksum 都不能代替可信启动 attestation。
|
checksum 都不能代替可信启动 attestation 或历史消息清理证明。只复用同一轮内明确空闲、
|
||||||
|
身份完全匹配且得到新会话身份的 worker;清理能力缺失或结果不确定时必须 fresh launch。
|
||||||
Coordinator 默认给新逻辑轮次生成 `<task-id>-A<round>`,并在记录轮次结果时写入
|
Coordinator 默认给新逻辑轮次生成 `<task-id>-A<round>`,并在记录轮次结果时写入
|
||||||
`tasks[].dispatch.rounds[].attemptId`。它独立于编排工具产生的 `taskId` 和
|
`tasks[].dispatch.rounds[].attemptId`。它独立于编排工具产生的 `taskId` 和
|
||||||
`dispatchId`:前者用于知识来源追溯,后两者只用于查询运行时。0.9.0 之前的历史
|
`dispatchId`:前者用于知识来源追溯,后两者只用于查询运行时。0.9.0 之前的历史
|
||||||
@@ -106,10 +109,10 @@ Test 对显式引用回报 `not_applicable` 时,说明 Coordinator 的选择
|
|||||||
|
|
||||||
Coordinator 派发前先决定 Developer/Test 在哪工作。两种方式:
|
Coordinator 派发前先决定 Developer/Test 在哪工作。两种方式:
|
||||||
|
|
||||||
**方式 1:当前 worktree 起 fresh 子 agent(终端)**
|
**方式 1:当前 worktree 复用安全重置的空闲子 agent,或起 fresh 子 agent(终端)**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# v0.10 不自动复用既有终端;先审阅 plan:
|
# 当前 Orca 不能可信清理历史消息,因此先审阅 fresh launch plan:
|
||||||
python3 <ack-skill-dir>/scripts/launch_worker.py plan \
|
python3 <ack-skill-dir>/scripts/launch_worker.py plan \
|
||||||
--project-root <project-root> --task-id <task-id> \
|
--project-root <project-root> --task-id <task-id> \
|
||||||
--attempt-id <task-id>-A<round> --role <developer-or-test> \
|
--attempt-id <task-id>-A<round> --role <developer-or-test> \
|
||||||
@@ -189,7 +192,9 @@ v0.10 的 full-access 授权与撤销机制仍是 Deferred;launcher 遇到 ful
|
|||||||
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 做终检并回写 `tasks.yaml`:通过 `verified`,不过 `failed_retest`。
|
||||||
7. 失败则用「复测失败再派发模板」重新发给 Developer,最多累计三轮。
|
7. 产品失败则用「复测失败再派发模板」重新发给 Developer,最多累计三轮有效产品
|
||||||
|
复验;环境失败写入 `dispatch.environmentIncidents`,恢复环境后重新派发 Test,
|
||||||
|
不派 Developer、不消耗产品轮次。
|
||||||
|
|
||||||
手动模式下同样遵守:worker_done / 复测报告都不等于最终结论、只有 Coordinator
|
手动模式下同样遵守:worker_done / 复测报告都不等于最终结论、只有 Coordinator
|
||||||
写 `tasks.yaml` 和 `knowledge.yaml`、三轮失败留档。若本次确认计划还包含交付,所有
|
写 `tasks.yaml` 和 `knowledge.yaml`、三轮失败留档。若本次确认计划还包含交付,所有
|
||||||
@@ -297,3 +302,32 @@ resolution:
|
|||||||
evidence:
|
evidence:
|
||||||
final: "<latest failing evidence>"
|
final: "<latest failing evidence>"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
环境失败不进入上面的 `rounds`,单独记录:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
status: fixed_by_dev
|
||||||
|
dispatch:
|
||||||
|
environmentIncidents:
|
||||||
|
- id: "BUG-001-ENV-1"
|
||||||
|
role: test
|
||||||
|
phase: service
|
||||||
|
status: open
|
||||||
|
summary: "无法访问待测服务"
|
||||||
|
evidence: "preflight connection refused"
|
||||||
|
impact: "未执行产品验收信号"
|
||||||
|
recoveryAction: "启动正确 worktree 的最新服务后重试"
|
||||||
|
userAction: "无需操作;Coordinator 将重启服务"
|
||||||
|
reportedAt: "<timestamp>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Worker 收尾
|
||||||
|
|
||||||
|
Coordinator 写入本轮全部最终状态并完成已确认的 delivery 停止点后,再回收 worker。
|
||||||
|
按 terminal handle 聚合所有 receipt 引用:只关闭全部关联任务均为 `verified`、且没有
|
||||||
|
未解决环境事件的 handle。任何关联任务仍为 `open`、`dispatched`、`fixed_by_dev`、
|
||||||
|
`retesting`、`blocked`、`failed_retest`、`leftover` 或状态不明时都保留该终端,不设置
|
||||||
|
TTL。关闭前后按 `orca-adapter.md` 核对 runtime/handle/incarnation 和关闭回执;关闭
|
||||||
|
不确定时报告并保留审计记录。
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
# ACK 交付阶段
|
# ACK 交付阶段
|
||||||
|
|
||||||
本文件定义可选的 `verified -> review_ready/released` 交付阶段。开发、独立复测和
|
本文件定义可选的 `verified -> validation_ready/review_ready/released` 交付阶段。开发、独立复测和
|
||||||
Coordinator 终检仍由 ACK 原有闭环负责;只有选中的任务全部 `verified` 后才能进入
|
Coordinator 终检仍由 ACK 原有闭环负责;只有选中的任务全部 `verified` 后才能进入
|
||||||
交付。项目配置位于 `docs/ack/delivery.yaml`,运行证据写入
|
交付。项目配置位于 `docs/ack/delivery.yaml`,运行证据写入
|
||||||
`docs/ack/tasks.yaml.deliveryRuns`。
|
`docs/ack/tasks.yaml.deliveryRuns`。
|
||||||
|
|
||||||
## 1. 配置与授权不是一回事
|
## 1. 配置与授权不是一回事
|
||||||
|
|
||||||
`delivery.yaml` 描述项目能怎样构建、上传和部署,不能单独授予远端写权限。ACK 在
|
`delivery.yaml` 描述项目能怎样构建、上传和部署,不能单独授予远端写权限。启用交付
|
||||||
kickoff 的既有用户确认点同时展示本次 profile、remote、产物目标、环境和停止点;用户
|
时,ACK 在 kickoff 的既有用户确认点默认展示 `defaultProfile`、remote、产物目标、环境
|
||||||
|
和停止点,不得静默省略;用户
|
||||||
确认该任务计划后,才允许执行计划中准确列出的 `review_ready` 步骤。目标、remote、
|
确认该任务计划后,才允许执行计划中准确列出的 `review_ready` 步骤。目标、remote、
|
||||||
channel、environment 或 source revision 漂移时重新确认。
|
channel、environment 或 source revision 漂移时重新确认。
|
||||||
|
|
||||||
@@ -42,8 +43,9 @@ channel、environment 或 source revision 漂移时重新确认。
|
|||||||
stable。
|
stable。
|
||||||
- `environments`:SSH host、Docker Compose、Kubernetes 或 custom;必须声明环境等级、
|
- `environments`:SSH host、Docker Compose、Kubernetes 或 custom;必须声明环境等级、
|
||||||
deploy 和 health check,production 还必须声明 rollback。
|
deploy 和 health check,production 还必须声明 rollback。
|
||||||
- `profiles`:按顺序执行的步骤和停止点。默认 profile 必须停在 `review_ready`,不能
|
- `profiles`:按顺序执行的步骤和停止点。默认 profile 必须停在 `validation_ready` 或
|
||||||
发布 stable 或部署 production。
|
`review_ready`,不能发布 stable 或部署 production。`validation_ready` profile 必须
|
||||||
|
至少部署一个 development/staging 环境,并对每个部署目标执行健康检查。
|
||||||
|
|
||||||
配置不允许 `shell`、自由 `command`、`env`、外部 executable、token、密码、私钥路径
|
配置不允许 `shell`、自由 `command`、`env`、外部 executable、token、密码、私钥路径
|
||||||
或凭据 URL。entrypoint 的 `requiredSecrets` 只能列大写 secret 名称,值必须由外部
|
或凭据 URL。entrypoint 的 `requiredSecrets` 只能列大写 secret 名称,值必须由外部
|
||||||
@@ -100,7 +102,9 @@ channel、environment 或 source revision 漂移时重新确认。
|
|||||||
## 6. 状态与恢复
|
## 6. 状态与恢复
|
||||||
|
|
||||||
`task.status=verified` 表示代码正确性通过;交付状态单独记录为 `planned`、`running`、
|
`task.status=verified` 表示代码正确性通过;交付状态单独记录为 `planned`、`running`、
|
||||||
`blocked`、`failed`、`review_ready`、`released` 或 `skipped`。部署或 Forge 暂时失败不把
|
`blocked`、`failed`、`validation_ready`、`review_ready`、`released` 或 `skipped`。
|
||||||
|
`validation_ready` 表示已把不可变产物部署到开发/测试环境并通过健康检查,可以交给
|
||||||
|
用户手工验证,不要求存在 PR/MR。部署或 Forge 暂时失败不把
|
||||||
任务改回 `failed_retest`。
|
任务改回 `failed_retest`。
|
||||||
|
|
||||||
重复运行先核对已有 branch、PR/MR、artifact 和部署目标,复用身份匹配的资源。相同
|
重复运行先核对已有 branch、PR/MR、artifact 和部署目标,复用身份匹配的资源。相同
|
||||||
@@ -110,7 +114,8 @@ ID 指向不同 commit、digest 或目标时停止,不覆盖或另建伪装成
|
|||||||
验证闭环,Test 重新复测后才能创建新的 delivery run。只有外部瞬时失败且 Git 内容未变
|
验证闭环,Test 重新复测后才能创建新的 delivery run。只有外部瞬时失败且 Git 内容未变
|
||||||
时,才可从失败步骤继续。
|
时,才可从失败步骤继续。
|
||||||
|
|
||||||
`review_ready` 至少记录:source/config revision、PR/MR URL、所有产物引用与 digest、
|
`validation_ready` 至少记录:source/config revision、所有产物引用与 digest、部署环境、
|
||||||
|
访问地址和用户下一步,以及健康检查证据。`review_ready` 至少记录:source/config revision、PR/MR URL、所有产物引用与 digest、
|
||||||
部署环境和健康检查证据。最终回复分别报告代码验证、PR、产物、部署和未完成项,不能用
|
部署环境和健康检查证据。最终回复分别报告代码验证、PR、产物、部署和未完成项,不能用
|
||||||
“完成”掩盖其中某一阶段失败或待审批。
|
“完成”掩盖其中某一阶段失败或待审批。
|
||||||
|
|
||||||
|
|||||||
@@ -1,53 +1,71 @@
|
|||||||
# 飞书 Base Bug 整理与审核
|
# 飞书 Base Bug 整理与审核
|
||||||
|
|
||||||
飞书 Base 是 Bug 在**审核通过前的唯一协作区**。用户先在飞书记录 Bug 描述,
|
飞书 Base 是 Bug 在审核通过前的唯一协作区。用户只需填写 `标题`,可选填写
|
||||||
Coordinator 读取记录和项目现状,补全或修改修复逻辑、验收标准,再写回同一条飞书
|
`详细描述`,并把截图、录屏或日志放在 `附件`。Coordinator 结合来源事实、附件和项目
|
||||||
记录。用户可以继续在飞书反馈,Coordinator 按反馈反复修订。只有用户明确表示审核
|
现状整理 `问题说明`、`期望效果`、`验收标准`,但不在收件箱写修复逻辑。
|
||||||
通过后,Coordinator 才把最终版本写入 `docs/ack/tasks.yaml`,随后启动 ACK 三角色闭环。
|
|
||||||
|
|
||||||
审核前不创建或刷新 `tasks.yaml` 中的 ACK 任务,不得启动 worker,不得派发
|
审核通过前不创建或刷新 `tasks.yaml` 中的 ACK 任务,不得启动 worker、派发 Developer/Test
|
||||||
Developer/Test,也不得修改应用代码。飞书字段已填满、记录进入某个 view、用户暂时没有
|
或修改应用代码。字段已填满、记录进入某个 view、用户暂时没有回复,都不等于审核通过。
|
||||||
回复,都不等于审核通过。
|
|
||||||
|
|
||||||
## 项目配置
|
## 项目配置与 Base 结构
|
||||||
|
|
||||||
项目在 `docs/ack/tasks.yaml` 的 `project.bugIntake` 声明 `provider: feishu-base`、
|
`docs/ack/tasks.yaml` 的 `project.bugIntake` 必须声明 `provider: feishu-base`、
|
||||||
`workflow: reviewed-writeback-v1`、显式 `profile`、`baseToken`、`tableId`、用于候选 Bug
|
`workflow: clarified-writeback-v1`、显式 `profile`、`baseToken`、`tableId`、`viewId` 和字段映射:
|
||||||
的 `viewId` 和字段映射;不要保存 App Secret、access token 或任何 profile 凭据。
|
|
||||||
|
|
||||||
原有八字段配置在未声明 `workflow` 时按 `read-only-v1` 兼容读取,不得写回或进入本节
|
```yaml
|
||||||
流程。启用审核前协作必须显式选择 `reviewed-writeback-v1`,并在 `title`、`actual`、
|
fields:
|
||||||
`expected`、`stepsToReproduce`、`acceptance`、`priority`、`attachments`、`updatedAt`
|
title: 标题
|
||||||
之外配置可写文本字段 `fixLogic`。其中 `fixLogic` 和 `acceptance` 由 Coordinator 维护;
|
details: 详细描述
|
||||||
用户提供的标题、实际表现、预期结果和附件属于来源事实,Coordinator 不得覆盖。
|
problemStatement: 问题说明
|
||||||
|
expectedOutcome: 期望效果
|
||||||
|
acceptance: 验收标准
|
||||||
|
intakeStatus: 处理状态
|
||||||
|
ackTaskId: ACK任务ID
|
||||||
|
attachments: 附件
|
||||||
|
updatedAt: 更新时间
|
||||||
|
```
|
||||||
|
|
||||||
## 一次性安装与 profile 设置
|
人维护 `标题`、`详细描述`、`附件`;ACK 维护 `问题说明`、`期望效果`、`验收标准`;系统字段
|
||||||
|
是 `处理状态`、`ACK任务ID`、`更新时间`。状态按
|
||||||
|
`待整理 → 需补充/待审核 → 已确认 → 已导入` 流转。优先级在批准后做任务规划时确定,
|
||||||
|
不属于收件箱审核内容。原 `read-only-v1` 和 `reviewed-writeback-v1` 继续兼容旧项目。
|
||||||
|
|
||||||
在账号级可信工具目录安装官方 CLI;ACK 读取器只会搜索账号的 `~/.local/bin`、mise/cargo
|
结构变更必须先执行只读计划,再携带计划指纹应用;适配器只新增目标字段、迁移来源信息并
|
||||||
shim 目录和固定系统目录,绝不会采用项目 `PATH` 中的同名文件。它同时识别官方 npm
|
调整当前 view 的可见字段,不删除旧列:
|
||||||
包生成的 `lark-cli -> @larksuite/cli/scripts/run.js` wrapper,并校验 package 名与 bin
|
|
||||||
映射,然后直接执行该官方包下载的 native binary:
|
```bash
|
||||||
|
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py schema-plan docs/ack/tasks.yaml
|
||||||
|
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py schema-apply docs/ack/tasks.yaml \
|
||||||
|
--expected-schema-fingerprint <schemaFingerprint>
|
||||||
|
```
|
||||||
|
|
||||||
|
迁移时,旧 `期望结果` 会以 `用户原始期望:…` 合并进 `详细描述`,旧字段继续保留但从
|
||||||
|
当前 view 隐藏;空状态初始化为 `待整理`。不要保存 App Secret、access token 或 profile
|
||||||
|
凭据。
|
||||||
|
|
||||||
|
## Profile 和安全边界
|
||||||
|
|
||||||
|
在账号级可信目录安装官方 CLI:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install --global --prefix "$HOME/.local" @larksuite/cli@latest
|
npm install --global --prefix "$HOME/.local" @larksuite/cli@latest
|
||||||
```
|
|
||||||
|
|
||||||
随后在受控终端中用 stdin 提供 App Secret,选择 Feishu brand,避免 secret 进入 shell
|
|
||||||
history、进程参数或项目文件:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
printf '%s' "$FEISHU_APP_SECRET" | lark-cli profile add \
|
printf '%s' "$FEISHU_APP_SECRET" | lark-cli profile add \
|
||||||
--name project-feishu --app-id "$FEISHU_APP_ID" \
|
--name project-feishu --app-id "$FEISHU_APP_ID" \
|
||||||
--app-secret-stdin --brand feishu
|
--app-secret-stdin --brand feishu
|
||||||
```
|
```
|
||||||
|
|
||||||
项目配置只填写 `profile: project-feishu`。不要执行 `profile use`,也不要依赖 active
|
使用官方 `lark-cli`,项目只保存 profile 名。不要依赖 active profile;所有读取、附件下载、
|
||||||
profile;读取、附件下载和写回都必须显式传 `--profile project-feishu`。profile 至少需要
|
写回和结构迁移都显式传配置 profile。profile 需要 Base 读写和附件下载权限。
|
||||||
`base:record:read`、`base:record:write` 和 `docs:document.media:download`。
|
所需 scope 至少包括 `base:record:read`、`base:record:write` 和
|
||||||
不要把 `lark-cli auth check` 当作 app/bot scope 的证明,因为它检查的是当前用户的
|
`docs:document.media:download`。不要把 `lark-cli auth check` 当作 app/bot scope 的证明,
|
||||||
stored user token。
|
它只检查当前用户的 stored user token。
|
||||||
|
|
||||||
## 读取与整理计划
|
适配器只搜索账号和系统的可信工具目录,子进程只收到实际账号 HOME、可信 PATH 和基础
|
||||||
|
locale;调用者环境中的凭据和运行时注入变量不会传入。不得绕过适配器直接操作 Base。
|
||||||
|
每条记录最多 10 个附件、单批最多 100 个,单个附件最多 20 MiB、合计最多 200 MiB,
|
||||||
|
整批下载最多 5 分钟。
|
||||||
|
|
||||||
|
## 读取与整理
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py check docs/ack/tasks.yaml
|
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py check docs/ack/tasks.yaml
|
||||||
@@ -58,84 +76,73 @@ python3 <ack-skill-dir>/scripts/feishu_bug_intake.py plan docs/ack/tasks.yaml \
|
|||||||
--output-dir "$tmpdir"
|
--output-dir "$tmpdir"
|
||||||
```
|
```
|
||||||
|
|
||||||
`fetch` 只调用官方 `base +record-list` 和附件下载命令,输出标准化 JSON,并为每条记录
|
`fetch` 输出标准化 JSON,并为每条记录计算覆盖来源事实、附件身份、整理字段和状态的
|
||||||
计算覆盖来源事实、附件元数据和审核字段的 `draftRevision`。`plan` 只生成
|
`draftRevision`。标题和更新时间是必需来源事实;详细描述和附件可为空。问题说明、期望
|
||||||
`create` / `refresh` / `unchanged` / `drift` 候选动作,不修改飞书或任务板。**审核通过前
|
效果、验收标准缺失时返回 `enrichmentRequired`。`plan` 只给出查重和漂移预览,审核前
|
||||||
不得执行这些候选动作**;它们只用于查重和预览最终导入结果。
|
不得据此创建任务。
|
||||||
|
|
||||||
标题、实际表现、预期结果和更新时间属于来源事实,任一缺失时读取失败。复现步骤、
|
Coordinator 对每条 Bug:
|
||||||
修复逻辑、验收标准和优先级属于 Coordinator 可整理字段;缺失时记录与 action 返回
|
|
||||||
`enrichmentRequired`。没有附件且所有 Bug 内容字段都为空的误建行会跳过,并在批次
|
|
||||||
`warnings` 中返回 `blank_record_skipped`。
|
|
||||||
|
|
||||||
子进程只收到实际账号 HOME、可信 PATH 和基础 locale;调用者环境中的
|
1. 读取标题、详细描述、附件及相关产品/代码上下文;证据不足时明确假设,不伪装成用户原文。
|
||||||
`LARKSUITE_CLI_*`、`FEISHU_*`、`NODE_OPTIONS` 等变量不会传入。每条记录最多 10 个
|
2. 写 `problemStatement`:说清现象、影响范围和边界,不包含修复方案。
|
||||||
附件、单批最多 100 个,单个附件最多 20 MiB、合计最多 200 MiB,整批下载最多 5 分钟;
|
3. 写 `expectedOutcome`:说明正确情况下用户能观察到的行为,不包含实现方式。
|
||||||
始终使用新的临时目录作为 `--output-dir`。
|
4. 写 `acceptance`:形成可独立复测的、可观察的标准,不扩张用户未表达的产品范围。
|
||||||
|
5. 将草案保存为不超过 64 KiB 的 JSON,且只含上述三个键:
|
||||||
|
|
||||||
## 审核前协作循环
|
```json
|
||||||
|
{
|
||||||
|
"problemStatement": "非空字符串",
|
||||||
|
"expectedOutcome": "非空字符串",
|
||||||
|
"acceptance": ["非空验收项"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
对每条候选 Bug,Coordinator 按以下顺序工作:
|
使用当前 `sourceRef` 和 `draftRevision` 写回:
|
||||||
|
|
||||||
1. 读取用户填写的来源事实、附件和项目代码/测试,确认问题边界;证据不足时把不确定点
|
```bash
|
||||||
明确写成假设,不伪装成用户原文。
|
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py write-draft \
|
||||||
2. 整理复现步骤和优先级,并生成:
|
docs/ack/tasks.yaml --record-id <record-id> \
|
||||||
- `fixLogic`:说明根因判断、计划修改的位置与行为、需要保持的不变量,以及回归风险;
|
--expected-source-ref <sourceRef> \
|
||||||
它是待审核的实现方向,不宣称代码已经修改。
|
--expected-draft-revision <draftRevision> --input <draft.json>
|
||||||
- `acceptance`:写成可观测、可独立复测的标准,至少覆盖用户可见结果、真实状态或 API
|
```
|
||||||
结果,以及原失败不再出现;不扩张用户没有表达的产品范围。
|
|
||||||
3. 把本轮草案保存为不超过 64 KiB 的临时 JSON:只包含非空字符串 `fixLogic` 和非空
|
|
||||||
字符串数组 `acceptance`。通过安全适配器写回当前飞书记录:
|
|
||||||
|
|
||||||
```bash
|
适配器在写前检查记录仍位于配置 view 且 revision 未漂移,只覆盖问题说明、期望效果、
|
||||||
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py write-draft \
|
验收标准和处理状态(设为 `待审核`),随后回读并返回新 revision。把新 revision 连同整理
|
||||||
docs/ack/tasks.yaml --record-id <record-id> \
|
结果交给用户审核。用户反馈后重新读取、修订和写回;不要在聊天或本地维护分叉版本。
|
||||||
--expected-source-ref <sourceRef> \
|
|
||||||
--expected-draft-revision <draftRevision> --input <draft.json>
|
|
||||||
```
|
|
||||||
|
|
||||||
`write-draft` 会在写前确认 record 属于配置 view,且 `sourceRef`、`draftRevision` 与调用者
|
|
||||||
看到的版本一致;它只允许覆盖配置映射的 `fixLogic` 和 `acceptance`,拒绝 symlink、超限
|
|
||||||
或多余字段输入,并复用读取器的可信 CLI、显式 profile、最小环境和成功 envelope 校验。
|
|
||||||
不得绕过适配器直接调用 PATH 中的 CLI,也不得改标题、实际表现、预期结果、附件或其它
|
|
||||||
用户字段。
|
|
||||||
4. `write-draft` 会回读同一记录、确认落盘值一致并返回当前 `draftRevision`。把这个 revision
|
|
||||||
连同记录交给用户审核;不能只展示没有 revision 的自由文本。
|
|
||||||
5. 用户提出意见时,重新读取最新记录和反馈,修订后再次写回、回读、等待审核;不要在
|
|
||||||
本地或聊天中维护一份与飞书分叉的“最终草案”。
|
|
||||||
|
|
||||||
任一写回、回读或字段校验失败时停止本轮并报告,不能转入任务板或三角色处理。
|
|
||||||
|
|
||||||
## 审核门禁与导入
|
## 审核门禁与导入
|
||||||
|
|
||||||
只有用户针对当前飞书记录的明确 `draftRevision` 表示“审核通过”“可以执行”或等价授权,
|
只有用户针对当前 revision 明确表示审核通过,并由用户本人在飞书把处理状态改为
|
||||||
才解除门禁。授权必须发生在最后一次草案写回和回读之后;之后若用户字段、附件、
|
`已确认`,才可继续导入。Coordinator 使用的适配器不提供把草案自行标成已确认的命令;
|
||||||
`fixLogic` 或 `acceptance` 再次变化,revision 会变化,原授权失效,必须重新审核。
|
状态和任务 ID 不参与内容 revision,因此用户确认状态不会改变已批准内容的 revision。
|
||||||
|
|
||||||
审核通过后,Coordinator 才执行以下动作:
|
重新读取并核对状态和 revision 后执行:
|
||||||
|
|
||||||
1. 通过适配器重新读取并生成规范任务草案;命令会要求当前 `sourceRef` 和
|
```bash
|
||||||
`draftRevision` 精确等于用户批准值:
|
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py import-approved \
|
||||||
|
docs/ack/tasks.yaml --record-id <record-id> \
|
||||||
|
--expected-source-ref <approved-sourceRef> \
|
||||||
|
--expected-draft-revision <approvedDraftRevision>
|
||||||
|
```
|
||||||
|
|
||||||
```bash
|
任一来源事实、附件或整理字段变化都会使旧批准失效。导出的 `taskDraft` 把问题说明映射为
|
||||||
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py import-approved \
|
`description`、期望效果映射为 `expected`、验收标准映射为 `acceptanceCriteria`;不包含
|
||||||
docs/ack/tasks.yaml --record-id <record-id> \
|
修复逻辑、复现步骤或优先级。Coordinator 在后续任务规划中补充优先级,但不得改写已审核
|
||||||
--expected-source-ref <approved-sourceRef> \
|
字段;校验器会重算 `approvedPayloadHash`。
|
||||||
--expected-draft-revision <approved-draftRevision>
|
|
||||||
```
|
|
||||||
|
|
||||||
不一致时停止并重新审核。输出的 `taskDraft` 包含批准后的规范字段以及
|
把 `taskDraft` 写入 `tasks.yaml`、补齐任务 ID 和规划字段并通过任务板校验后,再把最终
|
||||||
`source.workflow`、`source.approvedRevision`、`source.approvedPayloadHash`。
|
任务 ID 与同一批准 revision 写回飞书:
|
||||||
2. 按 `source.ref` 查重,把 `taskDraft` 原样合并到新的 ACK task,只补任务 ID、状态、
|
|
||||||
dispatch 等 ACK 运行字段;不得手工改写 `fixLogic`、`acceptanceCriteria` 或其它审核字段。
|
|
||||||
可在 `evidence.intakeEnrichment` 标明 Coordinator 推断依据,但它不替代规范字段。
|
|
||||||
3. 运行 `validate_tasks.py`。校验器会重算 `approvedPayloadHash`;只有写入和校验都成功,
|
|
||||||
任务才可作为已确认的 `open` 任务
|
|
||||||
进入 ACK 闭环。
|
|
||||||
4. 按 Coordinator → Developer → Test 的标准流程派发、实现和独立复测。
|
|
||||||
|
|
||||||
相同来源只更新尚未派发的 `open` 任务;`dispatched`、`fixed_by_dev`、`retesting`、
|
```bash
|
||||||
`failed_retest`、`verified`、`blocked` 和 `leftover` 任务只告警来源漂移,由用户决定是否
|
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py mark-imported \
|
||||||
新建任务。项目启用 reviewed workflow 后,历史 `verified` / `leftover` 只读来源继续保留;
|
docs/ack/tasks.yaml --record-id <record-id> --task-id <ack-task-id> \
|
||||||
其它仍可执行的 legacy 飞书任务必须先按当前流程重新整理和审核,不能通过省略或降级
|
--expected-source-ref <approved-sourceRef> \
|
||||||
`source.workflow` 绕过门禁。飞书记录消失、不可访问或同步失败时,已有 ACK 任务一律保留。
|
--expected-draft-revision <approvedDraftRevision>
|
||||||
|
```
|
||||||
|
|
||||||
|
适配器只在任务板恰有一条 ID 匹配、来源引用、记录 ID、批准 revision 和 payload hash
|
||||||
|
全部一致的任务时,写入 `ACK任务ID` 并把状态推进到 `已导入`;回读不一致则失败。重复执行
|
||||||
|
同一绑定是幂等的,不能把一条记录改挂到另一个任务。
|
||||||
|
|
||||||
|
按 `source.ref` 查重:仅未派发的 `open` 任务可刷新;其它状态只报告来源漂移,不覆盖。
|
||||||
|
来源消失、不可访问或同步失败时,不删除已有 ACK 任务。
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ python3 <ack-skill-dir>/scripts/validate_delivery.py docs/ack/delivery.yaml \
|
|||||||
`read-only` 或 `workspace-write`;旧任务板未迁移时保持手动模式。
|
`read-only` 或 `workspace-write`;旧任务板未迁移时保持手动模式。
|
||||||
- 顶层 `workerReceipts` 和 `dispatch.developer/test` 的 task/role/profile/attempt
|
- 顶层 `workerReceipts` 和 `dispatch.developer/test` 的 task/role/profile/attempt
|
||||||
引用一致;`receiptId` 与 `attemptId` 同时为空或同时填写。持久 receipt 只作审计,
|
引用一致;`receiptId` 与 `attemptId` 同时为空或同时填写。持久 receipt 只作审计,
|
||||||
v0.10 不自动复用旧终端。
|
不能单独授权复用旧终端;复用还需要空闲状态、身份匹配和可信历史清理证明。
|
||||||
- 网站或 API 项目写清服务启动、重启和 Base URL。
|
- 网站或 API 项目写清服务启动、重启和 Base URL。
|
||||||
- 任务中的固定 revision `knowledgeRefs` 都能解析,非 `active` 条目没有被派发。
|
- 任务中的固定 revision `knowledgeRefs` 都能解析,非 `active` 条目没有被派发。
|
||||||
|
|
||||||
|
|||||||
@@ -34,11 +34,15 @@
|
|||||||
5. 先把「产品文档 + 任务拆分 + 验收信号 + 适用知识引用」给我确认;若启用了交付,
|
5. 先把「产品文档 + 任务拆分 + 验收信号 + 适用知识引用」给我确认;若启用了交付,
|
||||||
同时列明本次 profile、目标、停止点与审批步骤。不要急着派发或交付。
|
同时列明本次 profile、目标、停止点与审批步骤。不要急着派发或交付。
|
||||||
6. 我确认后,按 ack 闭环循环:先用 `scripts/launch_worker.py` 校验结构化
|
6. 我确认后,按 ack 闭环循环:先用 `scripts/launch_worker.py` 校验结构化
|
||||||
Developer/Test profile,审阅 plan 后用 expected fingerprint 创建 fresh worker,
|
Developer/Test profile;优先选择同一轮内角色/profile/worktree 匹配的空闲 worker,
|
||||||
|
只有历史消息已可信清理并取得新会话身份才复用,否则审阅 plan 后用 expected
|
||||||
|
fingerprint 创建 fresh worker;
|
||||||
dispatch 开发 → worker_done → dispatch 测试独立复测 → 你读证据终检 → 回写 tasks.yaml;
|
dispatch 开发 → worker_done → dispatch 测试独立复测 → 你读证据终检 → 回写 tasks.yaml;
|
||||||
每个任务最多三轮,三轮不过记 leftover 并升级我复盘。
|
每个任务最多三轮有效产品复验,三轮不过记 leftover 并升级我复盘;环境失败单独
|
||||||
|
记录、恢复并告诉我下一步,不占产品复验轮次。
|
||||||
7. 所选任务都 verified 后,只有本次计划包含交付时才按 profile 顺序执行并写
|
7. 所选任务都 verified 后,只有本次计划包含交付时才按 profile 顺序执行并写
|
||||||
deliveryRuns;默认停在 review_ready,stable/production 步骤再次向我确认。
|
deliveryRuns;启用 delivery 时不能省略 defaultProfile,默认停在 validation_ready
|
||||||
|
或 review_ready,stable/production 步骤再次向我确认。
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -89,9 +93,11 @@ python3 <ack-skill-dir>/scripts/select_tasks.py docs/ack/tasks.yaml \
|
|||||||
|
|
||||||
## 第 3 步:审阅并启动 worker
|
## 第 3 步:审阅并启动 worker
|
||||||
|
|
||||||
先读 `tasks.yaml.project.orchestration` 的结构化 profiles。v0.10 不把历史 receipt
|
先读 `tasks.yaml.project.orchestration` 的结构化 profiles。历史 receipt 或既有 Orca
|
||||||
或既有 Orca terminal 当作自动复用授权;每次自动派发都创建 fresh worker。原因和
|
terminal 不能单独授权复用。复用候选必须属于同一轮 ACK、处于空闲状态,且角色、
|
||||||
边界见 `model-routing.md` §「Receipt、审计与复用边界」。
|
profile、worktree 与启动身份仍完全匹配;还必须通过受信后端清理历史消息并取得可核对
|
||||||
|
的新会话身份。当前 Orca 接口缺少该清理证明,所以 Orca 派发仍创建 fresh worker。
|
||||||
|
原因和边界见 `model-routing.md` §「Receipt、审计与复用边界」。
|
||||||
|
|
||||||
先查看目标 profile hash,确认本次结构化配置。这个 hash 只用于审计和漂移比较,
|
先查看目标 profile hash,确认本次结构化配置。这个 hash 只用于审计和漂移比较,
|
||||||
不能用于匹配或复用旧 receipt / 既有终端:
|
不能用于匹配或复用旧 receipt / 既有终端:
|
||||||
@@ -128,7 +134,7 @@ receipt 原样追加到 `tasks.yaml.workerReceipts`,把 `receipt.id` 写入该
|
|||||||
task/role/profile/attempt 完全绑定;不得把旧任务或旧轮次的 receipt 改挂到当前
|
task/role/profile/attempt 完全绑定;不得把旧任务或旧轮次的 receipt 改挂到当前
|
||||||
dispatch。后续
|
dispatch。后续
|
||||||
紧随其后的本次 Orca dispatch 只使用这次 fresh receipt binding 中的 handle;持久化
|
紧随其后的本次 Orca dispatch 只使用这次 fresh receipt binding 中的 handle;持久化
|
||||||
receipt 仅用于审计,不能在后续轮次自动复用。不要自行拼装 Agent CLI 或 Orca 的
|
receipt 仅用于审计,不能在后续轮次单独授权复用。不要自行拼装 Agent CLI 或 Orca 的
|
||||||
底层创建命令。
|
底层创建命令。
|
||||||
|
|
||||||
新 worktree 时可先运行
|
新 worktree 时可先运行
|
||||||
@@ -146,12 +152,13 @@ worktree 走同一套 `plan` -> 带 expected fingerprint 的 `launch`。在调
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
task-create → dispatch 给 DEV → 等 worker_done
|
task-create → dispatch 给 DEV → 等 worker_done
|
||||||
→ 每个角色都通过 plan + expected fingerprint launch fresh worker 并写回 receipt
|
→ 每个角色先检查可安全重置的空闲 worker;不符合即通过 plan + expected fingerprint launch fresh worker
|
||||||
→ 每轮使用 Coordinator 分配的稳定 <task-id>-A<round>
|
→ 每轮使用 Coordinator 分配的稳定 <task-id>-A<round>
|
||||||
→ 回写 fixed_by_dev → dispatch 给 TEST 复测 → 等 retest_result
|
→ 回写 fixed_by_dev → dispatch 给 TEST 复测 → 等 retest_result
|
||||||
→ Developer 回 knowledgeApplied / knowledgeCandidates,Test 回 knowledgeChecks
|
→ Developer 回 knowledgeApplied / knowledgeCandidates,Test 回 knowledgeChecks
|
||||||
→ Coordinator 读证据终检 → 过则 verified,不过则 failed_retest 再派 DEV(最多累计 3 轮)
|
→ 环境无法完成:记录 environmentIncidents,报告影响与用户下一步,恢复后重新复验(不计轮次)
|
||||||
→ 三轮失败:leftover,升级复盘,继续下一个
|
→ Coordinator 读证据终检 → 过则 verified,产品失败则 failed_retest 再派 DEV(最多累计 3 轮)
|
||||||
|
→ 三轮有效产品失败:leftover,升级复盘,继续下一个
|
||||||
```
|
```
|
||||||
|
|
||||||
具体命令见 `orca-adapter.md`(Orca)或 `closed-loop.md` §「手动模式」(无 Orca);派发文案见 `prompt-templates.md`。
|
具体命令见 `orca-adapter.md`(Orca)或 `closed-loop.md` §「手动模式」(无 Orca);派发文案见 `prompt-templates.md`。
|
||||||
@@ -167,15 +174,22 @@ Coordinator 只内联本轮 `knowledgeRefs` 指向的少量知识,不要求 wo
|
|||||||
## 第 5 步:可选交付
|
## 第 5 步:可选交付
|
||||||
|
|
||||||
所选任务都由 Coordinator 标记为 `verified` 后,若用户确认的计划包含交付,按
|
所选任务都由 Coordinator 标记为 `verified` 后,若用户确认的计划包含交付,按
|
||||||
`delivery.md` 执行所选 profile。先重新校验 `delivery.yaml`,固定当前 commit 和
|
`delivery.md` 执行所选 profile。启用交付时必须在计划中默认列出 `defaultProfile`,
|
||||||
|
用户可明确取消,Coordinator 不能静默省略。先重新校验 `delivery.yaml`,固定当前 commit 和
|
||||||
config revision,然后按有序步骤调用项目入口与已安装的低层 skill。每一步证据写入
|
config revision,然后按有序步骤调用项目入口与已安装的低层 skill。每一步证据写入
|
||||||
`tasks.yaml.deliveryRuns`;默认 profile 到 `review_ready` 即停止,stable 发布和
|
`tasks.yaml.deliveryRuns`;默认 profile 到 `validation_ready` 或 `review_ready` 即停止。
|
||||||
production 部署必须在 approval 步骤再次确认。失败时保留任务的 `verified`,把
|
前者必须把测试环境地址和用户下一步交付出来;stable 发布和 production 部署必须在
|
||||||
|
approval 步骤再次确认。失败时保留任务的 `verified`,把
|
||||||
delivery run 标为 `blocked` 或 `failed`。
|
delivery run 标为 `blocked` 或 `failed`。
|
||||||
|
|
||||||
## 第 6 步:收尾
|
## 第 6 步:收尾
|
||||||
|
|
||||||
一轮结束时 Coordinator 必须能回答 `optimization-method.md` §「结束条件」的问题:哪些 verified、哪些 leftover、各失败几轮、工作树是否干净、还有没有未处理项。
|
一轮结束时 Coordinator 必须能回答 `optimization-method.md` §「结束条件」的问题:
|
||||||
|
哪些 verified、哪些 leftover、各失败几轮、工作树是否干净、还有没有未处理项。
|
||||||
|
Coordinator 最后标记整轮任务完成后,关闭所有只关联 `verified` 任务的 Developer/Test
|
||||||
|
终端并核对关闭回执;receipt 和落盘证据继续保留。仍关联 `blocked`、`failed_retest`、
|
||||||
|
`leftover` 或其它未完成任务/未解决环境事件的终端保留,不设置 TTL。一个终端只要仍被
|
||||||
|
任一这类任务引用,就不能因为同时关联了 verified 任务而关闭。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -185,4 +199,5 @@ delivery run 标为 `blocked` 或 `failed`。
|
|||||||
`tasks.yaml.project.orchestration` 解析安全 profile → 审阅 plan 并用 expected
|
`tasks.yaml.project.orchestration` 解析安全 profile → 审阅 plan 并用 expected
|
||||||
fingerprint 启动 fresh DEV/TEST → dispatch / 复测 / 终检循环 → 任务结论落
|
fingerprint 启动 fresh DEV/TEST → dispatch / 复测 / 终检循环 → 任务结论落
|
||||||
`tasks.yaml` → 可选 delivery profile 到审核点,验证后的
|
`tasks.yaml` → 可选 delivery profile 到审核点,验证后的
|
||||||
跨任务知识由 Coordinator 落 `knowledge.yaml`。
|
跨任务知识由 Coordinator 落 `knowledge.yaml` → 整轮完成后回收仅属于 verified
|
||||||
|
任务的 worker,保留 blocked/failed/leftover worker。
|
||||||
|
|||||||
@@ -57,9 +57,8 @@ Coordinator 用强模型但**不亲自跑测试**(测试由 Test 承担),
|
|||||||
- 低档位反复产出表面修复。
|
- 低档位反复产出表面修复。
|
||||||
|
|
||||||
升级由 Coordinator 判断并记录原因。升级不是修改一个正在运行的终端:必须选择
|
升级由 Coordinator 判断并记录原因。升级不是修改一个正在运行的终端:必须选择
|
||||||
对应强档 profile,重新计算 `profileHash`,再通过 launcher 创建新的 worker。
|
对应强档 profile,重新计算 `profileHash`,再通过 launcher 创建新的 worker。模型或
|
||||||
v0.10 不自动复用持久化 receipt 指向的旧终端,Test 也不得使用 Developer 的强档
|
profile 升级时不得复用旧 worker,Test 也不得使用 Developer 的强档 worker。
|
||||||
worker。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -211,10 +210,13 @@ receipt 至少把以下事实绑定在一起:
|
|||||||
|
|
||||||
`receiptHash` 是无密钥的规范 JSON checksum,只能发现意外漂移或未同步修改,**不是
|
`receiptHash` 是无密钥的规范 JSON checksum,只能发现意外漂移或未同步修改,**不是
|
||||||
launcher 身份证明,也不是复用授权**。项目内有写权限的一方可以修改 receipt 后重算
|
launcher 身份证明,也不是复用授权**。项目内有写权限的一方可以修改 receipt 后重算
|
||||||
hash;而当前 Orca metadata 又不能证明终端最初执行的命令、模型和权限。因此 v0.10
|
hash;而当前 Orca metadata 又不能证明终端最初执行的命令、模型和权限。因此 ACK
|
||||||
明确禁止根据持久化 receipt 自动复用既有终端:每次需要自动派发 worker,都重新走
|
明确禁止根据持久化 receipt 自动复用既有终端。复用只允许发生在同一轮 ACK 内,并且
|
||||||
`plan` → 带 expected fingerprint 的 `launch`,只使用该次 launcher 标准输出中的
|
必须先证明 worker 空闲、角色/profile/worktree/runtime/incarnation 完全匹配,再由
|
||||||
fresh handle 完成本次派发。
|
受信后端清理历史消息并返回新的 conversation/session identity 与本次 task/attempt
|
||||||
|
绑定。正在工作、等待回报、状态不明或关联未完成任务的 worker 都不是空闲候选。任一
|
||||||
|
条件不满足、清理失败或清理结果无法确认时,重新走 `plan` → 带 expected fingerprint
|
||||||
|
的 `launch`,使用 fresh handle 派发。
|
||||||
|
|
||||||
`launchFingerprint` 是确定性的完整计划漂移校验,不是一次性授权或幂等键。同一份
|
`launchFingerprint` 是确定性的完整计划漂移校验,不是一次性授权或幂等键。同一份
|
||||||
计划重复执行 `launch` 会创建新的 fresh terminal;成功后不得用同一 fingerprint
|
计划重复执行 `launch` 会创建新的 fresh terminal;成功后不得用同一 fingerprint
|
||||||
@@ -223,9 +225,10 @@ fresh handle 完成本次派发。
|
|||||||
提供,而不是把 checksum 冒充成一次性令牌。
|
提供,而不是把 checksum 冒充成一次性令牌。
|
||||||
|
|
||||||
持久化 receipt 仍用于审计、dispatch 关联和检测配置漂移;标题、preview、分支名、
|
持久化 receipt 仍用于审计、dispatch 关联和检测配置漂移;标题、preview、分支名、
|
||||||
worker 自报或单独的 Orca live metadata 都不能把旧终端提升为可信 worker。未来只有
|
worker 自报或单独的 Orca live metadata 都不能把旧终端提升为可信 worker。只有
|
||||||
在 Orca/ACP 提供启动参数 attestation,或存在项目外可信签发与校验通道后,才开放
|
Orca/ACP 同时提供启动参数 attestation、明确空闲状态、可信历史清理和新会话身份,或
|
||||||
自动复用。CLI / 模型变更仍需更新 allowlist 并重新生成 receipt。
|
ACK 接入等价的项目外可信签发与校验通道,才实际启用自动复用。当前 Orca 不满足这些
|
||||||
|
条件,所以仍创建 fresh worker。CLI / 模型变更仍需更新 allowlist 并重新生成 receipt。
|
||||||
|
|
||||||
`ackVersion` 必须使用合法 SemVer。`0.10.0` 及以后版本的任务板必须同时存在
|
`ackVersion` 必须使用合法 SemVer。`0.10.0` 及以后版本的任务板必须同时存在
|
||||||
`project.orchestration` 与顶层 `workerReceipts`;其中任一字段出现,另一个也必须
|
`project.orchestration` 与顶层 `workerReceipts`;其中任一字段出现,另一个也必须
|
||||||
|
|||||||
@@ -62,7 +62,49 @@ Coordinator 不亲自复测,但要做终检:读 Test 的证据,确认它
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 三轮失败策略(SSOT)
|
## 4. 有效复验、环境失败与三轮策略(SSOT)
|
||||||
|
|
||||||
|
### 4.1 什么才计算一轮
|
||||||
|
|
||||||
|
三轮预算只计算**有效产品复验**:Test 已确认正确 worktree、最新服务、必要测试数据和
|
||||||
|
可用验证工具,并实际执行目标验收信号;结果要么全部通过,要么观察到由待测产品行为
|
||||||
|
导致的信号失败。
|
||||||
|
|
||||||
|
以下情况属于环境失败,不是产品失败,也不占复验轮次:worker 未启动或消息未投递、
|
||||||
|
sandbox/权限阻止访问待测服务、服务实例或构建不匹配、必要测试数据缺失、浏览器或测试
|
||||||
|
工具不可用、编排 IPC 失败。若已有独立的产品信号明确失败,只把该产品失败计入轮次;
|
||||||
|
其余环境问题另行记录,不能用“环境失败”掩盖产品证据。
|
||||||
|
|
||||||
|
环境失败写入 `dispatch.environmentIncidents`,不要追加到 `dispatch.rounds`,也不要把
|
||||||
|
任务写成 `failed_retest`。实现已经完成时保持 `fixed_by_dev`;恢复后再进入
|
||||||
|
`retesting`。确实需要用户或外部条件才能继续时可暂时写 `blocked`,环境恢复后回到
|
||||||
|
原闭环状态。
|
||||||
|
|
||||||
|
每条环境事件必须包含:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
dispatch:
|
||||||
|
environmentIncidents:
|
||||||
|
- id: "BUG-001-ENV-1"
|
||||||
|
attemptId: "BUG-001-A1"
|
||||||
|
role: test
|
||||||
|
phase: browser
|
||||||
|
status: resolved
|
||||||
|
summary: "测试环境没有可用浏览器"
|
||||||
|
evidence: "chromium/playwright lookup 均为空"
|
||||||
|
impact: "没有执行点击级验收,不能据此判断产品失败"
|
||||||
|
recoveryAction: "改用受支持的浏览器运行时并启动 fresh Test"
|
||||||
|
userAction: "无需操作;Coordinator 继续恢复"
|
||||||
|
reportedAt: "<timestamp>"
|
||||||
|
resolvedAt: "<timestamp>"
|
||||||
|
```
|
||||||
|
|
||||||
|
`userAction` 必须明确:无需用户操作时写清 Coordinator 下一步;需要用户介入时给出一个
|
||||||
|
具体决定、命令或外部条件,不能只写“请处理环境”。Coordinator 可以先做一次不扩大权限、
|
||||||
|
不改变产品数据的有界恢复;仍未解决、需要用户动作或阻断本轮时,在当前会话立即报告。
|
||||||
|
即使事件已自动恢复,最终报告也必须列出环境事件、影响和恢复结果,让用户知道发生过什么。
|
||||||
|
|
||||||
|
### 4.2 三轮有效产品失败
|
||||||
|
|
||||||
每个任务最多自动派发三轮:
|
每个任务最多自动派发三轮:
|
||||||
|
|
||||||
@@ -73,7 +115,9 @@ round 3: 明确指出重复失败点,要求 worker 自己复现完整路径
|
|||||||
failed after round 3: 标记 leftover,继续下一个任务
|
failed after round 3: 标记 leftover,继续下一个任务
|
||||||
```
|
```
|
||||||
|
|
||||||
三轮失败后不要继续消耗同一个 worker。常见原因:验收标准需要重新设计、Worker 对问题模型理解错了、UI 自动化与实际浏览器状态有差异、需要人工观察或调试工具介入。
|
三轮有效产品失败后不要继续消耗同一个 worker。常见原因:验收标准需要重新设计、
|
||||||
|
Worker 对问题模型理解错了,或需要人工观察和专项调试。环境事件数量不受三轮预算限制,
|
||||||
|
但必须有界恢复和透明报告,不能无限重试。
|
||||||
|
|
||||||
留档字段(结构见 `templates/tasks.schema.json`):
|
留档字段(结构见 `templates/tasks.schema.json`):
|
||||||
|
|
||||||
@@ -180,6 +224,7 @@ Developer 回报实际采用的 `knowledgeApplied` 和带当前观测证据的
|
|||||||
- 每个 leftover 失败了几轮?最后一轮失败证据是什么?
|
- 每个 leftover 失败了几轮?最后一轮失败证据是什么?
|
||||||
- 当前工作树有哪些未提交改动?
|
- 当前工作树有哪些未提交改动?
|
||||||
- 是否还有 open / failed_retest 未处理?
|
- 是否还有 open / failed_retest 未处理?
|
||||||
|
- 本轮有哪些环境事件?是否已解决?用户下一步是“无需操作”还是一个明确动作?
|
||||||
- 本轮显式 `knowledgeRefs` 是否都有必要的 `knowledgeChecks`?
|
- 本轮显式 `knowledgeRefs` 是否都有必要的 `knowledgeChecks`?
|
||||||
- 是否有待验证 candidate,或因依赖、路径、版本变化需要转为 stale 的知识?
|
- 是否有待验证 candidate,或因依赖、路径、版本变化需要转为 stale 的知识?
|
||||||
|
|
||||||
|
|||||||
@@ -24,27 +24,39 @@ orca orchestration inbox --limit 20 --json
|
|||||||
```
|
```
|
||||||
|
|
||||||
确认:Orca runtime 可达并取得本次 `runtimeId`;Coordinator 终端存在;当前没有冲突
|
确认:Orca runtime 可达并取得本次 `runtimeId`;Coordinator 终端存在;当前没有冲突
|
||||||
的活跃编排任务。Developer/Test worker 必须通过本次 launcher 新建,不要求预先存在。
|
的活跃编排任务。Developer/Test worker 先按下节检查安全复用候选;当前 Orca 无可信
|
||||||
|
历史清理能力时必须通过本次 launcher 新建。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 既有 worker 的处理
|
## 既有 worker 的处理
|
||||||
|
|
||||||
v0.10 **不自动复用任何既有终端**。`receiptHash` 是无密钥 checksum,Orca 当前的
|
先找同一轮 ACK 内可复用的空闲 worker,再决定是否创建。候选必须同时满足:
|
||||||
terminal metadata 也不包含原始 Agent argv、模型或权限 attestation;两者即使完全
|
|
||||||
匹配,仍不足以证明旧终端由可信 launcher 以目标 profile 创建。标题、preview、分支
|
- Orca 明确报告终端存活且空闲;不得复用仍在工作、等待 `worker_done` / 复测报告 /
|
||||||
名或 worker 自报更不能作为证明。
|
escalation,或运行状态不明的 worker;
|
||||||
|
- 没有关联 `blocked`、`failed_retest`、`leftover`、未解决环境事件或其它未完成任务;
|
||||||
|
- 角色、profile、worktree、runtime/incarnation 与本次目标完全匹配,Developer 与 Test
|
||||||
|
永不互相复用;
|
||||||
|
- 受信后端能够清理历史消息,并返回可核对的新 conversation/session identity 和本次
|
||||||
|
task/attempt 绑定;只有看到成功回执后才把 worker 视为已重置。
|
||||||
|
|
||||||
|
`receiptHash` 是无密钥 checksum,标题、preview、分支名、worker 自报或普通 Orca
|
||||||
|
terminal metadata 都不能证明历史上下文已清理。任何条件不符、清理接口不存在、清理
|
||||||
|
失败或结果无法确认时,都重新 `plan`/`launch` fresh worker。
|
||||||
|
|
||||||
| 情况 | 动作 |
|
| 情况 | 动作 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 历史 receipt 与 live metadata 看似完全匹配 | 只作审计,不自动复用;重新 launch |
|
| 同轮空闲,身份完全匹配,历史消息清理得到可信回执 | 生成新的 task/attempt 绑定后复用 |
|
||||||
| 没有 receipt,或只有同名终端 | 不复用;重新 launch |
|
| worker 正在工作、等待回报、状态不明或关联未完成任务 | 不复用;保留原终端并重新 launch |
|
||||||
|
| profile、角色、worktree、runtime/incarnation 任一不匹配 | 不复用;重新 launch |
|
||||||
|
| 无历史消息清理能力,或无法确认清理成功 | 不复用;重新 launch |
|
||||||
| 需要并发 worker | 使用下一 `slot` 重新 launch |
|
| 需要并发 worker | 使用下一 `slot` 重新 launch |
|
||||||
| Developer 升级模型档位 | 选择强档安全 profile,重新 plan/launch |
|
| Developer 升级模型档位 | 选择强档安全 profile,重新 plan/launch |
|
||||||
|
|
||||||
只有刚刚成功返回的 `launch` 标准输出中的 fresh handle,才可用于紧随其后的本次
|
当前 Orca terminal API 没有返回新会话身份的可信历史消息清理操作,因此当前适配器
|
||||||
dispatch。未来若 Orca/ACP 增加启动参数 attestation,或 ACK 接入项目外可信签发
|
不会复用 Orca worker;仍只使用刚刚成功返回的 `launch` fresh handle。以后后端补齐
|
||||||
通道,再单独设计自动复用;不能用项目文档声明跳过这条限制。
|
reset/attestation 后才能启用上述复用路径,不能用项目文档声明跳过条件。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -114,8 +126,9 @@ Developer: owns implementation and white-box verification.
|
|||||||
Test: owns independent black-box retest and evidence (verifier != implementer).
|
Test: owns independent black-box retest and evidence (verifier != implementer).
|
||||||
|
|
||||||
Policy:
|
Policy:
|
||||||
- Each issue can be dispatched at most 3 rounds.
|
- Each issue can have at most 3 valid product-retest rounds.
|
||||||
- If still failing after 3 rounds, record as leftover and continue next issue.
|
- 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 tasks.yaml 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
|
||||||
@@ -274,4 +287,26 @@ orca orchestration send \
|
|||||||
无 `retest_result` 类型时用 `--type worker_done`,靠 subject `retest round <n>`
|
无 `retest_result` 类型时用 `--type worker_done`,靠 subject `retest round <n>`
|
||||||
区分。收到复测结果后,Coordinator 按 `closed-loop.md` 做终检并回写 `tasks.yaml`;
|
区分。收到复测结果后,Coordinator 按 `closed-loop.md` 做终检并回写 `tasks.yaml`;
|
||||||
通过独立验证和 gate 的跨任务经验再由 Coordinator 写入 `knowledge.yaml`。任务通过
|
通过独立验证和 gate 的跨任务经验再由 Coordinator 写入 `knowledge.yaml`。任务通过
|
||||||
写 `verified`,不过写 `failed_retest`。
|
写 `verified`;观察到产品信号失败写 `failed_retest`;环境无法完成则写
|
||||||
|
`dispatch.environmentIncidents`,保持或恢复为 `fixed_by_dev`,不占复验轮次。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 整轮结束时回收 worker
|
||||||
|
|
||||||
|
只有 Coordinator 已把本轮最终结论写入 `tasks.yaml` 后才开始回收。先按 receipt 的
|
||||||
|
`binding.handle` 聚合任务引用:只有一个 handle 关联的全部任务都为 `verified`,且没有
|
||||||
|
未解决环境事件,才允许关闭。`open`、`dispatched`、`fixed_by_dev`、`retesting`、
|
||||||
|
`blocked`、`failed_retest`、`leftover` 以及状态不明任务关联的 handle 全部保留,不设置
|
||||||
|
TTL。普通用户 Shell 和 Coordinator 自己的终端不在回收范围。
|
||||||
|
|
||||||
|
逐个回收时:
|
||||||
|
|
||||||
|
1. `orca terminal show --terminal <handle> --json`,把 runtimeId、handle、incarnationId
|
||||||
|
和 worktree 与 receipt 逐项核对;不匹配就停止并报告。
|
||||||
|
2. `orca terminal close --terminal <handle> --tab --json`。
|
||||||
|
3. 核对关闭回执的 runtimeId、handle、tab 和关闭模式,再确认终端不在 live list。
|
||||||
|
4. 关闭结果不确定时保留记录并报告,禁止盲目重试或标记为已回收。
|
||||||
|
|
||||||
|
关闭终端不删除 `workerReceipts`、dispatch 证据或测试记录;这些仍是审计事实。保留下来
|
||||||
|
的 blocked/failed worker 只在后续任务得到验证或用户明确要求清理时回收。
|
||||||
|
|||||||
@@ -134,6 +134,9 @@ Developer 本轮声称(仅供参考,不作数):
|
|||||||
1. <observable signal 1>
|
1. <observable signal 1>
|
||||||
2. <observable signal 2>
|
2. <observable signal 2>
|
||||||
3. <observable signal 3>
|
3. <observable signal 3>
|
||||||
|
- 若 worker、权限、服务、测试数据、浏览器或工具导致验收无法完成,明确回报
|
||||||
|
`environmentFailure`,不要把“未验证”写成产品 `signals-failed`;若已有独立产品失败
|
||||||
|
证据,则分别列出产品信号与环境限制。
|
||||||
- 需要时把易反复误判的路径沉淀成可执行测试(见 optimization-method.md §8)。
|
- 需要时把易反复误判的路径沉淀成可执行测试(见 optimization-method.md §8)。
|
||||||
- 对每条适用的 `knowledgeRef`,把它的 verification.ref 交给
|
- 对每条适用的 `knowledgeRef`,把它的 verification.ref 交给
|
||||||
`<ack-skill-dir>/scripts/run_verification.py docs/ack/knowledge.yaml
|
`<ack-skill-dir>/scripts/run_verification.py docs/ack/knowledge.yaml
|
||||||
@@ -227,6 +230,7 @@ knowledgeCandidates:
|
|||||||
rationale: <why this changes future work>
|
rationale: <why this changes future work>
|
||||||
evidenceRefs: [<independent evidence reference>]
|
evidenceRefs: [<independent evidence reference>]
|
||||||
conclusion: all-signals-pass / signals-failed
|
conclusion: all-signals-pass / signals-failed
|
||||||
|
environmentFailure: null / <phase, evidence, impact, recovery suggestion>
|
||||||
notes: <observations, suspected cause, and independent candidate validation evidence>
|
notes: <observations, suspected cause, and independent candidate validation evidence>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -245,6 +249,9 @@ Orca 模式下用 `orca-adapter.md` §「Test 回报复测结果」的命令发
|
|||||||
已验证(Test 复测通过 + 终检):
|
已验证(Test 复测通过 + 终检):
|
||||||
- <task_id>: <what passed>
|
- <task_id>: <what passed>
|
||||||
|
|
||||||
|
环境事件:
|
||||||
|
- <task_id>: <原因、影响、是否恢复、Coordinator 下一步、userAction>
|
||||||
|
|
||||||
遗留:
|
遗留:
|
||||||
- <task_id>: 三轮仍未通过,已记录为 leftover。最后失败证据:<evidence>
|
- <task_id>: 三轮仍未通过,已记录为 leftover。最后失败证据:<evidence>
|
||||||
|
|
||||||
@@ -257,7 +264,7 @@ Orca 模式下用 `orca-adapter.md` §「Test 回报复测结果」的命令发
|
|||||||
- 待验证 candidate:<remaining candidates or none>
|
- 待验证 candidate:<remaining candidates or none>
|
||||||
|
|
||||||
交付(未启用时写 n/a):
|
交付(未启用时写 n/a):
|
||||||
- run/profile/status:<delivery run id / profile / review_ready|released|blocked|failed>
|
- run/profile/status:<delivery run id / profile / validation_ready|review_ready|released|blocked|failed>
|
||||||
- PR/MR:<URL and head/base>
|
- PR/MR:<URL and head/base>
|
||||||
- 产物:<reference, digest, destination>
|
- 产物:<reference, digest, destination>
|
||||||
- 部署:<environment, health/rollback evidence>
|
- 部署:<environment, health/rollback evidence>
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ ACK 默认三个独立 Agent:**Coordinator 只编排、Test 只验证、Develo
|
|||||||
`dispatchId` 代替。
|
`dispatchId` 代替。
|
||||||
- gate 时检查 Developer 的 `knowledgeApplied`、Test 的 `knowledgeChecks` 和
|
- gate 时检查 Developer 的 `knowledgeApplied`、Test 的 `knowledgeChecks` 和
|
||||||
candidate 独立证据;只有证据充分时才由 Coordinator 激活、废弃或替代知识。
|
candidate 独立证据;只有证据充分时才由 Coordinator 激活、废弃或替代知识。
|
||||||
- 一次派发只针对一个明确问题(`optimization-method.md` §6);每任务最多三轮。
|
- 一次派发只针对一个明确问题(`optimization-method.md` §6);每任务最多三轮有效
|
||||||
|
产品复验。环境失败单独记录、恢复和报告,不占轮次。
|
||||||
- 终检:读 Test 证据,逐条对齐原始意图后才落 `verified`,不亲自复测。
|
- 终检:读 Test 证据,逐条对齐原始意图后才落 `verified`,不亲自复测。
|
||||||
- **Must Not**:改源码、亲自跑测试、凭 `worker_done` 直接标 `verified`、把多个无关失败塞进一次派发、派发 `candidate` 或全量注入知识库、把知识正文当作 shell 执行。
|
- **Must Not**:改源码、亲自跑测试、凭 `worker_done` 直接标 `verified`、把多个无关失败塞进一次派发、派发 `candidate` 或全量注入知识库、把知识正文当作 shell 执行。
|
||||||
- **Evidence**:产品文档、`tasks.yaml` 里的 `expected` + `verification`、Test 回传的复测证据。
|
- **Evidence**:产品文档、`tasks.yaml` 里的 `expected` + `verification`、Test 回传的复测证据。
|
||||||
@@ -131,6 +132,16 @@ retesting -> failed_retest -> dispatched
|
|||||||
failed_retest(累计 3 轮) -> leftover
|
failed_retest(累计 3 轮) -> leftover
|
||||||
```
|
```
|
||||||
|
|
||||||
|
环境分支不进入 `failed_retest`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
launch / service / data / browser / tooling environment failure
|
||||||
|
-> dispatch.environmentIncidents
|
||||||
|
-> 有界恢复并告知用户下一步
|
||||||
|
-> fixed_by_dev(待恢复)或 blocked(需外部动作)
|
||||||
|
-> 环境恢复后 retesting
|
||||||
|
```
|
||||||
|
|
||||||
状态定义(所有状态都只由 Coordinator 写入 `tasks.yaml`,来源不同):
|
状态定义(所有状态都只由 Coordinator 写入 `tasks.yaml`,来源不同):
|
||||||
|
|
||||||
| 状态 | 依据来源 | 含义 |
|
| 状态 | 依据来源 | 含义 |
|
||||||
@@ -139,12 +150,12 @@ failed_retest(累计 3 轮) -> leftover
|
|||||||
| `dispatched` | Coordinator 派发动作 | 已派发给 Developer |
|
| `dispatched` | Coordinator 派发动作 | 已派发给 Developer |
|
||||||
| `fixed_by_dev` | Developer 的 worker_done | 开发声称已修复并提供白盒验证 |
|
| `fixed_by_dev` | Developer 的 worker_done | 开发声称已修复并提供白盒验证 |
|
||||||
| `retesting` | Coordinator 派发动作 | 已派发给 Test,正在黑盒复测 |
|
| `retesting` | Coordinator 派发动作 | 已派发给 Test,正在黑盒复测 |
|
||||||
| `failed_retest` | Test 的复测报告 | 复测失败,可继续派发 Developer |
|
| `failed_retest` | Test 的产品复测报告 | 环境对齐后观察到产品验收失败,可继续派发 Developer |
|
||||||
| `verified` | Test 通过 + Coordinator 终检 | 复测通过且符合原始意图 |
|
| `verified` | Test 通过 + Coordinator 终检 | 复测通过且符合原始意图 |
|
||||||
| `blocked` | Coordinator 判断 | 需要用户决策或外部条件 |
|
| `blocked` | Coordinator 判断 | 需要用户决策或外部条件 |
|
||||||
| `leftover` | Coordinator 判断 | 累计 3 轮仍未通过,留给人工或专项处理 |
|
| `leftover` | Coordinator 判断 | 累计 3 轮仍未通过,留给人工或专项处理 |
|
||||||
|
|
||||||
三轮失败的处理细则见 `optimization-method.md` §「三轮失败策略」。
|
有效复验与环境失败的处理细则见 `optimization-method.md` §4。
|
||||||
|
|
||||||
## 交付状态(与任务状态正交)
|
## 交付状态(与任务状态正交)
|
||||||
|
|
||||||
@@ -152,11 +163,12 @@ failed_retest(累计 3 轮) -> leftover
|
|||||||
`tasks.yaml.deliveryRuns`:
|
`tasks.yaml.deliveryRuns`:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
planned -> running -> review_ready | released
|
planned -> running -> validation_ready | review_ready | released
|
||||||
-> blocked | failed
|
-> blocked | failed
|
||||||
planned -> skipped
|
planned -> skipped
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`validation_ready` 表示开发/测试环境已部署且健康检查通过,等待用户手工验证;
|
||||||
`review_ready` 表示 PR、preview 产物和已授权的非生产部署证据已经齐备,等待用户
|
`review_ready` 表示 PR、preview 产物和已授权的非生产部署证据已经齐备,等待用户
|
||||||
审核;`released` 只用于用户明确批准后的 stable 发布或 production 部署。交付失败
|
审核;`released` 只用于用户明确批准后的 stable 发布或 production 部署。交付失败
|
||||||
不会否定已经独立验证的任务,但必须保留失败步骤、revision 与日志引用。完整顺序、
|
不会否定已经独立验证的任务,但必须保留失败步骤、revision 与日志引用。完整顺序、
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import re
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
PAYLOAD_FIELDS = (
|
LEGACY_PAYLOAD_FIELDS = (
|
||||||
"title",
|
"title",
|
||||||
"description",
|
"description",
|
||||||
"priority",
|
"priority",
|
||||||
@@ -18,6 +18,13 @@ PAYLOAD_FIELDS = (
|
|||||||
"fixLogic",
|
"fixLogic",
|
||||||
"acceptanceCriteria",
|
"acceptanceCriteria",
|
||||||
)
|
)
|
||||||
|
CLARIFIED_PAYLOAD_FIELDS = (
|
||||||
|
"title",
|
||||||
|
"description",
|
||||||
|
"actual",
|
||||||
|
"expected",
|
||||||
|
"acceptanceCriteria",
|
||||||
|
)
|
||||||
NUMBERED_ITEM = re.compile(r"(?:^|\s)([1-9][0-9]*)\.\s+")
|
NUMBERED_ITEM = re.compile(r"(?:^|\s)([1-9][0-9]*)\.\s+")
|
||||||
|
|
||||||
|
|
||||||
@@ -43,7 +50,14 @@ def review_items(value: str) -> list[str]:
|
|||||||
|
|
||||||
def approval_payload_hash(task: dict[str, Any]) -> str:
|
def approval_payload_hash(task: dict[str, Any]) -> str:
|
||||||
"""Hash the exact reviewed fields that Developer and Test consume."""
|
"""Hash the exact reviewed fields that Developer and Test consume."""
|
||||||
payload = {field: task.get(field) for field in PAYLOAD_FIELDS}
|
source = task.get("source")
|
||||||
|
workflow = source.get("workflow") if isinstance(source, dict) else None
|
||||||
|
fields = (
|
||||||
|
CLARIFIED_PAYLOAD_FIELDS
|
||||||
|
if workflow == "clarified-writeback-v1"
|
||||||
|
else LEGACY_PAYLOAD_FIELDS
|
||||||
|
)
|
||||||
|
payload = {field: task.get(field) for field in fields}
|
||||||
encoded = json.dumps(
|
encoded = json.dumps(
|
||||||
payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"),
|
payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"),
|
||||||
).encode("utf-8")
|
).encode("utf-8")
|
||||||
|
|||||||
@@ -25,10 +25,14 @@ 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 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
|
||||||
|
|
||||||
REQUIRED_FIELDS = ("title", "actual", "expected", "stepsToReproduce", "acceptance", "priority", "attachments", "updatedAt")
|
LEGACY_REQUIRED_FIELDS = ("title", "actual", "expected", "stepsToReproduce", "acceptance", "attachments", "updatedAt")
|
||||||
OPTIONAL_FIELDS = ("fixLogic",)
|
LEGACY_OPTIONAL_FIELDS = ("priority", "fixLogic")
|
||||||
|
LEGACY_FIELD_ORDER = ("title", "actual", "expected", "stepsToReproduce", "acceptance", "priority", "attachments", "updatedAt", "fixLogic")
|
||||||
|
CLARIFIED_REQUIRED_FIELDS = ("title", "details", "problemStatement", "expectedOutcome", "acceptance", "intakeStatus", "ackTaskId", "attachments", "updatedAt")
|
||||||
|
CLARIFIED_FIELD_ORDER = CLARIFIED_REQUIRED_FIELDS
|
||||||
SOURCE_FACT_FIELDS = ("title", "actual", "expected", "updatedAt")
|
SOURCE_FACT_FIELDS = ("title", "actual", "expected", "updatedAt")
|
||||||
COORDINATOR_FIELDS = ("steps", "acceptance", "priority")
|
COORDINATOR_FIELDS = ("steps", "acceptance", "priority")
|
||||||
BUG_CONTENT_FIELDS = ("title", "actual", "expected", "fixLogic", *COORDINATOR_FIELDS)
|
BUG_CONTENT_FIELDS = ("title", "actual", "expected", "fixLogic", *COORDINATOR_FIELDS)
|
||||||
@@ -48,7 +52,13 @@ PROFILE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
|||||||
RECORD_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$")
|
RECORD_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$")
|
||||||
SOURCE_REF = re.compile(r"^feishu-base:sha256:[0-9a-f]{64}$")
|
SOURCE_REF = re.compile(r"^feishu-base:sha256:[0-9a-f]{64}$")
|
||||||
DRAFT_REVISION = re.compile(r"^sha256:[0-9a-f]{64}$")
|
DRAFT_REVISION = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||||
WORKFLOWS = {"read-only-v1", "reviewed-writeback-v1"}
|
WORKFLOWS = {"read-only-v1", "reviewed-writeback-v1", "clarified-writeback-v1"}
|
||||||
|
INTAKE_STATUSES = ("待整理", "需补充", "待审核", "已确认", "已导入")
|
||||||
|
TARGET_BASE_FIELDS = (
|
||||||
|
("标题", "text"), ("详细描述", "text"), ("附件", "attachment"),
|
||||||
|
("问题说明", "text"), ("期望效果", "text"), ("验收标准", "text"),
|
||||||
|
("处理状态", "select"), ("ACK任务ID", "text"), ("更新时间", "updated_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class IntakeError(Exception):
|
class IntakeError(Exception):
|
||||||
@@ -188,7 +198,7 @@ def load_board(path: Path) -> dict[str, Any]:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def load_draft(path: Path) -> dict[str, Any]:
|
def load_draft(path: Path, workflow: str = "reviewed-writeback-v1") -> dict[str, Any]:
|
||||||
"""Load one bounded, regular JSON file with the two writable draft fields."""
|
"""Load one bounded, regular JSON file with the two writable draft fields."""
|
||||||
descriptor: int | None = None
|
descriptor: int | None = None
|
||||||
try:
|
try:
|
||||||
@@ -224,6 +234,23 @@ def load_draft(path: Path) -> dict[str, Any]:
|
|||||||
finally:
|
finally:
|
||||||
if descriptor is not None:
|
if descriptor is not None:
|
||||||
os.close(descriptor)
|
os.close(descriptor)
|
||||||
|
if workflow == "clarified-writeback-v1":
|
||||||
|
required = {"problemStatement", "expectedOutcome", "acceptance"}
|
||||||
|
if not isinstance(value, dict) or set(value) != required:
|
||||||
|
raise IntakeError("draft input must contain exactly problemStatement, expectedOutcome and acceptance")
|
||||||
|
for field in ("problemStatement", "expectedOutcome"):
|
||||||
|
if not isinstance(value[field], str) or not value[field].strip():
|
||||||
|
raise IntakeError(f"draft {field} must be a non-empty string")
|
||||||
|
acceptance = value["acceptance"]
|
||||||
|
if not isinstance(acceptance, list) or not acceptance or any(
|
||||||
|
not isinstance(item, str) or not item.strip() for item in acceptance
|
||||||
|
):
|
||||||
|
raise IntakeError("draft acceptance must be a non-empty string list")
|
||||||
|
return {
|
||||||
|
"problemStatement": value["problemStatement"].strip(),
|
||||||
|
"expectedOutcome": value["expectedOutcome"].strip(),
|
||||||
|
"acceptance": [item.strip() for item in acceptance],
|
||||||
|
}
|
||||||
if not isinstance(value, dict) or set(value) != {"fixLogic", "acceptance"}:
|
if not isinstance(value, dict) or set(value) != {"fixLogic", "acceptance"}:
|
||||||
raise IntakeError("draft input must contain exactly fixLogic and acceptance")
|
raise IntakeError("draft input must contain exactly fixLogic and acceptance")
|
||||||
fix_logic = value["fixLogic"]
|
fix_logic = value["fixLogic"]
|
||||||
@@ -266,10 +293,15 @@ def config_from_board(board: dict[str, Any]) -> dict[str, Any]:
|
|||||||
if not isinstance(value, str) or not SAFE_VALUE.fullmatch(value):
|
if not isinstance(value, str) or not SAFE_VALUE.fullmatch(value):
|
||||||
raise IntakeError(f"bugIntake.{key} is invalid")
|
raise IntakeError(f"bugIntake.{key} is invalid")
|
||||||
fields = config.get("fields")
|
fields = config.get("fields")
|
||||||
supported = set(REQUIRED_FIELDS) | set(OPTIONAL_FIELDS)
|
if workflow == "clarified-writeback-v1":
|
||||||
|
required = CLARIFIED_REQUIRED_FIELDS
|
||||||
|
supported = set(required)
|
||||||
|
else:
|
||||||
|
required = LEGACY_REQUIRED_FIELDS
|
||||||
|
supported = set(required) | set(LEGACY_OPTIONAL_FIELDS)
|
||||||
if (
|
if (
|
||||||
not isinstance(fields, dict)
|
not isinstance(fields, dict)
|
||||||
or not set(REQUIRED_FIELDS).issubset(fields)
|
or not set(required).issubset(fields)
|
||||||
or not set(fields).issubset(supported)
|
or not set(fields).issubset(supported)
|
||||||
):
|
):
|
||||||
raise IntakeError("bugIntake.fields must map all required and only supported logical fields")
|
raise IntakeError("bugIntake.fields must map all required and only supported logical fields")
|
||||||
@@ -277,8 +309,12 @@ def config_from_board(board: dict[str, Any]) -> dict[str, Any]:
|
|||||||
raise IntakeError("bugIntake.fields values are invalid")
|
raise IntakeError("bugIntake.fields values are invalid")
|
||||||
if len(set(fields.values())) != len(fields):
|
if len(set(fields.values())) != len(fields):
|
||||||
raise IntakeError("bugIntake.fields values must be unique")
|
raise IntakeError("bugIntake.fields values must be unique")
|
||||||
if workflow == "reviewed-writeback-v1" and "fixLogic" not in fields:
|
if workflow == "reviewed-writeback-v1":
|
||||||
raise IntakeError("reviewed writeback requires bugIntake.fields.fixLogic")
|
missing_review_fields = {"fixLogic", "priority"} - set(fields)
|
||||||
|
if missing_review_fields:
|
||||||
|
raise IntakeError(
|
||||||
|
"reviewed writeback requires bugIntake.fields.fixLogic and priority"
|
||||||
|
)
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
@@ -441,19 +477,34 @@ def matrix_from_response(response: dict[str, Any], field_ids: list[str]) -> tupl
|
|||||||
rows = data.get("data", data.get("records", data.get("items", data.get("rows"))))
|
rows = data.get("data", data.get("records", data.get("items", data.get("rows"))))
|
||||||
if not isinstance(fields, list) or not all(isinstance(x, str) for x in fields):
|
if not isinstance(fields, list) or not all(isinstance(x, str) for x in fields):
|
||||||
raise IntakeError("record list fields are invalid")
|
raise IntakeError("record list fields are invalid")
|
||||||
if fields != field_ids:
|
if len(set(fields)) != len(fields) or len(set(field_ids)) != len(field_ids):
|
||||||
raise IntakeError("record list fields do not match configured projection")
|
raise IntakeError("record list field projection contains duplicates")
|
||||||
|
if set(fields) != set(field_ids):
|
||||||
|
raise IntakeError(
|
||||||
|
"record list fields do not match configured projection: "
|
||||||
|
f"expected={field_ids!r}, actual={fields!r}"
|
||||||
|
)
|
||||||
if not isinstance(ids, list) or not all(isinstance(x, str) and RECORD_ID.fullmatch(x) for x in ids):
|
if not isinstance(ids, list) or not all(isinstance(x, str) and RECORD_ID.fullmatch(x) for x in ids):
|
||||||
raise IntakeError("record list record_id_list is invalid")
|
raise IntakeError("record list record_id_list is invalid")
|
||||||
if not isinstance(rows, list) or len(rows) != len(ids) or any(not isinstance(row, list) or len(row) != len(fields) for row in rows):
|
if not isinstance(rows, list) or len(rows) != len(ids) or any(not isinstance(row, list) or len(row) != len(fields) for row in rows):
|
||||||
raise IntakeError("record list matrix does not match fields and record_id_list")
|
raise IntakeError("record list matrix does not match fields and record_id_list")
|
||||||
return ids, rows
|
if fields == field_ids:
|
||||||
|
return ids, rows
|
||||||
|
positions = {field: index for index, field in enumerate(fields)}
|
||||||
|
return ids, [
|
||||||
|
[row[positions[field_id]] for field_id in field_ids]
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def fetch_pages(config: dict[str, Any]) -> list[tuple[str, list[Any]]]:
|
def fetch_pages(config: dict[str, Any]) -> list[tuple[str, list[Any]]]:
|
||||||
|
field_order = (
|
||||||
|
CLARIFIED_FIELD_ORDER
|
||||||
|
if config.get("workflow") == "clarified-writeback-v1"
|
||||||
|
else LEGACY_FIELD_ORDER
|
||||||
|
)
|
||||||
logical_fields = [
|
logical_fields = [
|
||||||
logical for logical in (*REQUIRED_FIELDS, *OPTIONAL_FIELDS)
|
logical for logical in field_order if logical in config["fields"]
|
||||||
if logical in config["fields"]
|
|
||||||
]
|
]
|
||||||
field_ids = [config["fields"][logical] for logical in logical_fields]
|
field_ids = [config["fields"][logical] for logical in logical_fields]
|
||||||
all_rows: list[tuple[str, list[Any]]] = []
|
all_rows: list[tuple[str, list[Any]]] = []
|
||||||
@@ -526,23 +577,20 @@ def draft_revision(
|
|||||||
if len(tokens) != len(record["attachments"]):
|
if len(tokens) != len(record["attachments"]):
|
||||||
raise IntakeError("draft revision attachment identity is incomplete")
|
raise IntakeError("draft revision attachment identity is incomplete")
|
||||||
stable = {
|
stable = {
|
||||||
"sourceRef": record["sourceRef"],
|
key: record[key]
|
||||||
"updatedAt": record["updatedAt"],
|
for key in record
|
||||||
"title": record["title"],
|
if key not in {
|
||||||
"actual": record["actual"],
|
"attachments", "warnings", "enrichmentRequired", "draftRevision", "recordId",
|
||||||
"expected": record["expected"],
|
"intakeStatus", "ackTaskId",
|
||||||
"steps": record["steps"],
|
}
|
||||||
"fixLogic": record["fixLogic"],
|
|
||||||
"acceptance": record["acceptance"],
|
|
||||||
"priority": record["priority"],
|
|
||||||
"attachments": [
|
|
||||||
{
|
|
||||||
**{key: attachment.get(key) for key in ("name", "type", "size")},
|
|
||||||
"tokenDigest": f"sha256:{hashlib.sha256(('ack-feishu-attachment-v1\x1f' + token).encode('utf-8')).hexdigest()}",
|
|
||||||
}
|
|
||||||
for attachment, token in zip(record["attachments"], tokens)
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
stable["attachments"] = [
|
||||||
|
{
|
||||||
|
**{key: attachment.get(key) for key in ("name", "type", "size")},
|
||||||
|
"tokenDigest": f"sha256:{hashlib.sha256(('ack-feishu-attachment-v1\x1f' + token).encode('utf-8')).hexdigest()}",
|
||||||
|
}
|
||||||
|
for attachment, token in zip(record["attachments"], tokens)
|
||||||
|
]
|
||||||
encoded = json.dumps(
|
encoded = json.dumps(
|
||||||
stable, ensure_ascii=False, sort_keys=True, separators=(",", ":"),
|
stable, ensure_ascii=False, sort_keys=True, separators=(",", ":"),
|
||||||
).encode("utf-8")
|
).encode("utf-8")
|
||||||
@@ -556,10 +604,9 @@ def fetch(config: dict[str, Any], output_dir: Path | None) -> dict[str, Any]:
|
|||||||
total_attachments = 0
|
total_attachments = 0
|
||||||
total_attachment_bytes = 0
|
total_attachment_bytes = 0
|
||||||
for record_id, row in fetch_pages(config):
|
for record_id, row in fetch_pages(config):
|
||||||
logical_fields = [
|
workflow = config.get("workflow", "read-only-v1")
|
||||||
logical for logical in (*REQUIRED_FIELDS, *OPTIONAL_FIELDS)
|
field_order = CLARIFIED_FIELD_ORDER if workflow == "clarified-writeback-v1" else LEGACY_FIELD_ORDER
|
||||||
if logical in config["fields"]
|
logical_fields = [logical for logical in field_order if logical in config["fields"]]
|
||||||
]
|
|
||||||
cells = dict(zip(logical_fields, row))
|
cells = dict(zip(logical_fields, row))
|
||||||
attachment_data = attachment_items(cells["attachments"])
|
attachment_data = attachment_items(cells["attachments"])
|
||||||
total_attachments += len(attachment_data)
|
total_attachments += len(attachment_data)
|
||||||
@@ -568,15 +615,35 @@ def fetch(config: dict[str, Any], output_dir: Path | None) -> dict[str, Any]:
|
|||||||
raise IntakeError("batch exceeded the attachment count limit")
|
raise IntakeError("batch exceeded the attachment count limit")
|
||||||
if total_attachment_bytes > MAX_TOTAL_ATTACHMENT_BYTES:
|
if total_attachment_bytes > MAX_TOTAL_ATTACHMENT_BYTES:
|
||||||
raise IntakeError("batch exceeded the attachment byte limit")
|
raise IntakeError("batch exceeded the attachment byte limit")
|
||||||
record = {"sourceRef": source_ref(config, record_id), "recordId": record_id, "updatedAt": text(cells["updatedAt"]), "title": text(cells["title"]), "actual": text(cells["actual"]), "expected": text(cells["expected"]), "steps": text(cells["stepsToReproduce"]), "fixLogic": text(cells.get("fixLogic")), "acceptance": text(cells["acceptance"]), "priority": text(cells["priority"]), "attachments": [metadata for metadata, _ in attachment_data], "warnings": []}
|
if workflow == "clarified-writeback-v1":
|
||||||
if not attachment_data and not any(record[field] for field in BUG_CONTENT_FIELDS):
|
record = {
|
||||||
|
"sourceRef": source_ref(config, record_id), "recordId": record_id,
|
||||||
|
"updatedAt": text(cells["updatedAt"]), "title": text(cells["title"]),
|
||||||
|
"details": text(cells["details"]),
|
||||||
|
"problemStatement": text(cells["problemStatement"]),
|
||||||
|
"expectedOutcome": text(cells["expectedOutcome"]),
|
||||||
|
"acceptance": text(cells["acceptance"]),
|
||||||
|
"intakeStatus": text(cells["intakeStatus"]),
|
||||||
|
"ackTaskId": text(cells["ackTaskId"]),
|
||||||
|
"attachments": [metadata for metadata, _ in attachment_data], "warnings": [],
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
record = {"sourceRef": source_ref(config, record_id), "recordId": record_id, "updatedAt": text(cells["updatedAt"]), "title": text(cells["title"]), "actual": text(cells["actual"]), "expected": text(cells["expected"]), "steps": text(cells["stepsToReproduce"]), "fixLogic": text(cells.get("fixLogic")), "acceptance": text(cells["acceptance"]), "priority": text(cells.get("priority")), "attachments": [metadata for metadata, _ in attachment_data], "warnings": []}
|
||||||
|
content_fields = (
|
||||||
|
("title", "details", "problemStatement", "expectedOutcome", "acceptance")
|
||||||
|
if workflow == "clarified-writeback-v1"
|
||||||
|
else BUG_CONTENT_FIELDS
|
||||||
|
)
|
||||||
|
if not attachment_data and not any(record.get(field) for field in content_fields):
|
||||||
batch_warnings.append({"recordId": record_id, "code": "blank_record_skipped"})
|
batch_warnings.append({"recordId": record_id, "code": "blank_record_skipped"})
|
||||||
continue
|
continue
|
||||||
for field in SOURCE_FACT_FIELDS:
|
source_fields = ("title", "updatedAt") if workflow == "clarified-writeback-v1" else SOURCE_FACT_FIELDS
|
||||||
|
for field in source_fields:
|
||||||
if not record[field]:
|
if not record[field]:
|
||||||
raise IntakeError(f"record {field} must not be empty")
|
raise IntakeError(f"record {field} must not be empty")
|
||||||
enrichment_fields = list(COORDINATOR_FIELDS)
|
enrichment_fields = (["problemStatement", "expectedOutcome", "acceptance"]
|
||||||
if "fixLogic" in config["fields"]:
|
if workflow == "clarified-writeback-v1" else list(COORDINATOR_FIELDS))
|
||||||
|
if workflow != "clarified-writeback-v1" and "fixLogic" in config["fields"]:
|
||||||
enrichment_fields.append("fixLogic")
|
enrichment_fields.append("fixLogic")
|
||||||
record["enrichmentRequired"] = [
|
record["enrichmentRequired"] = [
|
||||||
field for field in enrichment_fields if not record[field]
|
field for field in enrichment_fields if not record[field]
|
||||||
@@ -640,19 +707,32 @@ def write_draft(
|
|||||||
config: dict[str, Any], record_id: str, expected_source_ref: str,
|
config: dict[str, Any], record_id: str, expected_source_ref: str,
|
||||||
expected_revision: str, draft_path: Path,
|
expected_revision: str, draft_path: Path,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Overwrite only the configured fix logic and acceptance cells."""
|
"""Overwrite only the review-owned cells for the configured workflow."""
|
||||||
if config.get("workflow", "read-only-v1") != "reviewed-writeback-v1":
|
workflow = config.get("workflow", "read-only-v1")
|
||||||
raise IntakeError("draft writeback requires reviewed-writeback-v1 workflow")
|
if workflow not in {"reviewed-writeback-v1", "clarified-writeback-v1"}:
|
||||||
if "fixLogic" not in config["fields"]:
|
raise IntakeError("draft writeback requires a writeback workflow")
|
||||||
|
if workflow == "reviewed-writeback-v1" and "fixLogic" not in config["fields"]:
|
||||||
raise IntakeError("bugIntake.fields.fixLogic is required for draft writeback")
|
raise IntakeError("bugIntake.fields.fixLogic is required for draft writeback")
|
||||||
review_record(config, record_id, expected_source_ref, expected_revision)
|
review_record(config, record_id, expected_source_ref, expected_revision)
|
||||||
draft = load_draft(draft_path)
|
draft = load_draft(draft_path, workflow)
|
||||||
patch = {
|
if workflow == "clarified-writeback-v1":
|
||||||
config["fields"]["fixLogic"]: draft["fixLogic"],
|
patch = {
|
||||||
config["fields"]["acceptance"]: "\n".join(
|
config["fields"]["problemStatement"]: draft["problemStatement"],
|
||||||
f"{index}. {item}" for index, item in enumerate(draft["acceptance"], start=1)
|
config["fields"]["expectedOutcome"]: draft["expectedOutcome"],
|
||||||
),
|
config["fields"]["acceptance"]: "\n".join(
|
||||||
}
|
f"{index}. {item}" for index, item in enumerate(draft["acceptance"], start=1)
|
||||||
|
),
|
||||||
|
config["fields"]["intakeStatus"]: "待审核",
|
||||||
|
}
|
||||||
|
written = ["problemStatement", "expectedOutcome", "acceptance", "intakeStatus"]
|
||||||
|
else:
|
||||||
|
patch = {
|
||||||
|
config["fields"]["fixLogic"]: draft["fixLogic"],
|
||||||
|
config["fields"]["acceptance"]: "\n".join(
|
||||||
|
f"{index}. {item}" for index, item in enumerate(draft["acceptance"], start=1)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
written = ["fixLogic", "acceptance"]
|
||||||
profile_check(config)
|
profile_check(config)
|
||||||
run_cli([
|
run_cli([
|
||||||
"base", "+record-upsert", "--profile", config["profile"],
|
"base", "+record-upsert", "--profile", config["profile"],
|
||||||
@@ -665,15 +745,21 @@ def write_draft(
|
|||||||
if len(matching) != 1 or matching[0]["sourceRef"] != expected_source_ref:
|
if len(matching) != 1 or matching[0]["sourceRef"] != expected_source_ref:
|
||||||
raise IntakeError("draft writeback readback did not find exactly one record")
|
raise IntakeError("draft writeback readback did not find exactly one record")
|
||||||
expected_acceptance = text(patch[config["fields"]["acceptance"]])
|
expected_acceptance = text(patch[config["fields"]["acceptance"]])
|
||||||
if (
|
if workflow == "clarified-writeback-v1":
|
||||||
matching[0]["fixLogic"] != text(draft["fixLogic"])
|
matched = (
|
||||||
or matching[0]["acceptance"] != expected_acceptance
|
matching[0]["problemStatement"] == text(draft["problemStatement"])
|
||||||
):
|
and matching[0]["expectedOutcome"] == text(draft["expectedOutcome"])
|
||||||
|
and matching[0]["acceptance"] == expected_acceptance
|
||||||
|
and matching[0]["intakeStatus"] == "待审核"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
matched = matching[0]["fixLogic"] == text(draft["fixLogic"]) and matching[0]["acceptance"] == expected_acceptance
|
||||||
|
if not matched:
|
||||||
raise IntakeError("draft writeback readback did not match the submitted draft")
|
raise IntakeError("draft writeback readback did not match the submitted draft")
|
||||||
return {
|
return {
|
||||||
"provider": "feishu-base",
|
"provider": "feishu-base",
|
||||||
"recordId": record_id,
|
"recordId": record_id,
|
||||||
"written": ["fixLogic", "acceptance"],
|
"written": written,
|
||||||
"draftRevision": matching[0]["draftRevision"],
|
"draftRevision": matching[0]["draftRevision"],
|
||||||
"ok": True,
|
"ok": True,
|
||||||
}
|
}
|
||||||
@@ -684,13 +770,21 @@ def import_approved(
|
|||||||
expected_revision: str,
|
expected_revision: str,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Emit the canonical task payload for one explicitly approved draft revision."""
|
"""Emit the canonical task payload for one explicitly approved draft revision."""
|
||||||
if config.get("workflow", "read-only-v1") != "reviewed-writeback-v1":
|
workflow = config.get("workflow", "read-only-v1")
|
||||||
raise IntakeError("approved import requires reviewed-writeback-v1 workflow")
|
if workflow not in {"reviewed-writeback-v1", "clarified-writeback-v1"}:
|
||||||
|
raise IntakeError("approved import requires a writeback workflow")
|
||||||
record = review_record(
|
record = review_record(
|
||||||
config, record_id, expected_source_ref, expected_revision,
|
config, record_id, expected_source_ref, expected_revision,
|
||||||
)
|
)
|
||||||
steps = review_items(record["steps"])
|
|
||||||
acceptance = review_items(record["acceptance"])
|
acceptance = review_items(record["acceptance"])
|
||||||
|
if workflow == "clarified-writeback-v1":
|
||||||
|
if record["intakeStatus"] != "已确认":
|
||||||
|
raise IntakeError("approved record must have intakeStatus 已确认")
|
||||||
|
if not record["problemStatement"] or not record["expectedOutcome"] or not acceptance:
|
||||||
|
raise IntakeError("approved record is missing prepared clarification fields")
|
||||||
|
task_draft = clarified_task_draft(record)
|
||||||
|
return {"provider": "feishu-base", "recordId": record_id, "draftRevision": record["draftRevision"], "taskDraft": task_draft, "ok": True}
|
||||||
|
steps = review_items(record["steps"])
|
||||||
if not record["priority"] or not steps or not record["fixLogic"] or not acceptance:
|
if not record["priority"] or not steps or not record["fixLogic"] or not acceptance:
|
||||||
raise IntakeError("approved record is missing prepared review fields")
|
raise IntakeError("approved record is missing prepared review fields")
|
||||||
task_draft: dict[str, Any] = {
|
task_draft: dict[str, Any] = {
|
||||||
@@ -721,6 +815,101 @@ def import_approved(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def clarified_task_draft(record: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Map one normalized clarified record to its immutable reviewed task fields."""
|
||||||
|
task_draft: dict[str, Any] = {
|
||||||
|
"title": record["title"],
|
||||||
|
"description": record["problemStatement"],
|
||||||
|
"actual": record["details"] or record["title"],
|
||||||
|
"expected": record["expectedOutcome"],
|
||||||
|
"acceptanceCriteria": review_items(record["acceptance"]),
|
||||||
|
"source": {
|
||||||
|
"kind": "feishu-base",
|
||||||
|
"workflow": "clarified-writeback-v1",
|
||||||
|
"ref": record["sourceRef"],
|
||||||
|
"recordId": record["recordId"],
|
||||||
|
"updatedAt": record["updatedAt"],
|
||||||
|
"approvedRevision": record["draftRevision"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
task_draft["source"]["approvedPayloadHash"] = approval_payload_hash(task_draft)
|
||||||
|
return task_draft
|
||||||
|
|
||||||
|
|
||||||
|
def mark_imported(
|
||||||
|
board: dict[str, Any], config: dict[str, Any], record_id: str,
|
||||||
|
expected_source_ref: str, expected_revision: str, task_id: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Bind a confirmed Base record to the validated ACK task created from it."""
|
||||||
|
if config.get("workflow") != "clarified-writeback-v1":
|
||||||
|
raise IntakeError("mark-imported requires clarified-writeback-v1 workflow")
|
||||||
|
if not isinstance(task_id, str) or not task_id:
|
||||||
|
raise IntakeError("ACK task id is invalid")
|
||||||
|
if validate_task_board(board):
|
||||||
|
raise IntakeError("task board is invalid for import finalization")
|
||||||
|
record = review_record(config, record_id, expected_source_ref, expected_revision)
|
||||||
|
|
||||||
|
tasks = board.get("tasks")
|
||||||
|
if not isinstance(tasks, list):
|
||||||
|
raise IntakeError("task board tasks must be a list")
|
||||||
|
matches = [
|
||||||
|
task for task in tasks
|
||||||
|
if isinstance(task, dict) and task.get("id") == task_id
|
||||||
|
]
|
||||||
|
if len(matches) != 1:
|
||||||
|
raise IntakeError("task board did not contain exactly one imported ACK task")
|
||||||
|
task = matches[0]
|
||||||
|
source = task.get("source")
|
||||||
|
expected_task = clarified_task_draft(record)
|
||||||
|
reviewed_fields = (
|
||||||
|
"title", "description", "actual", "expected", "acceptanceCriteria",
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
not isinstance(source, dict)
|
||||||
|
or source != expected_task["source"]
|
||||||
|
or any(task.get(field) != expected_task[field] for field in reviewed_fields)
|
||||||
|
):
|
||||||
|
raise IntakeError("ACK task does not match the approved Base record")
|
||||||
|
|
||||||
|
if record["intakeStatus"] == "已导入" and record["ackTaskId"] == task_id:
|
||||||
|
return {
|
||||||
|
"provider": "feishu-base", "recordId": record_id,
|
||||||
|
"intakeStatus": "已导入", "ackTaskId": task_id,
|
||||||
|
"draftRevision": expected_revision, "ok": True,
|
||||||
|
}
|
||||||
|
if record["intakeStatus"] != "已确认" or record["ackTaskId"]:
|
||||||
|
raise IntakeError("Base record is not ready to mark as imported")
|
||||||
|
|
||||||
|
profile_check(config)
|
||||||
|
run_cli([
|
||||||
|
"base", "+record-upsert", "--profile", config["profile"],
|
||||||
|
"--base-token", config["baseToken"], "--table-id", config["tableId"],
|
||||||
|
"--record-id", record_id, "--json",
|
||||||
|
json.dumps({
|
||||||
|
config["fields"]["intakeStatus"]: "已导入",
|
||||||
|
config["fields"]["ackTaskId"]: task_id,
|
||||||
|
}, ensure_ascii=False, separators=(",", ":")),
|
||||||
|
"--format", "json",
|
||||||
|
])
|
||||||
|
matching = [
|
||||||
|
item for item in fetch(config, None)["records"]
|
||||||
|
if item["recordId"] == record_id
|
||||||
|
]
|
||||||
|
if (
|
||||||
|
len(matching) != 1
|
||||||
|
or matching[0]["sourceRef"] != expected_source_ref
|
||||||
|
or matching[0]["draftRevision"] != expected_revision
|
||||||
|
or matching[0]["intakeStatus"] != "已导入"
|
||||||
|
or matching[0]["ackTaskId"] != task_id
|
||||||
|
):
|
||||||
|
raise IntakeError("import marker readback did not match the ACK task")
|
||||||
|
return {
|
||||||
|
"provider": "feishu-base", "recordId": record_id,
|
||||||
|
"intakeStatus": "已导入", "ackTaskId": task_id,
|
||||||
|
"draftRevision": expected_revision, "ok": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def plan_actions(board: dict[str, Any], records: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
def plan_actions(board: dict[str, Any], records: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
"""Plan idempotent Coordinator actions without mutating the task board."""
|
"""Plan idempotent Coordinator actions without mutating the task board."""
|
||||||
tasks = board.get("tasks")
|
tasks = board.get("tasks")
|
||||||
@@ -803,20 +992,190 @@ def plan_actions(board: dict[str, Any], records: list[dict[str, Any]]) -> list[d
|
|||||||
return actions
|
return actions
|
||||||
|
|
||||||
|
|
||||||
|
def field_list(config: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
|
"""Return the bounded Base field inventory used by schema migration."""
|
||||||
|
profile_check(config)
|
||||||
|
response = run_cli([
|
||||||
|
"base", "+field-list", "--profile", config["profile"],
|
||||||
|
"--base-token", config["baseToken"], "--table-id", config["tableId"],
|
||||||
|
"--format", "json",
|
||||||
|
])
|
||||||
|
data = response.get("data", response)
|
||||||
|
items = data.get("fields") if isinstance(data, dict) else None
|
||||||
|
if not isinstance(items, list) or len(items) > 256:
|
||||||
|
raise IntakeError("field list returned an invalid response")
|
||||||
|
result: list[dict[str, str]] = []
|
||||||
|
for item in items:
|
||||||
|
if not isinstance(item, dict) or not all(
|
||||||
|
isinstance(item.get(key), str) and item[key]
|
||||||
|
for key in ("id", "name", "type")
|
||||||
|
):
|
||||||
|
raise IntakeError("field list returned an invalid field")
|
||||||
|
result.append({key: item[key] for key in ("id", "name", "type")})
|
||||||
|
if len({item["name"] for item in result}) != len(result):
|
||||||
|
raise IntakeError("field list contains duplicate names")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def schema_target(config: dict[str, Any]) -> dict[str, str]:
|
||||||
|
token_digest = hashlib.sha256(
|
||||||
|
("ack-feishu-schema-target-v1\x1f" + config["baseToken"]).encode("utf-8")
|
||||||
|
).hexdigest()
|
||||||
|
return {
|
||||||
|
"profile": config["profile"],
|
||||||
|
"baseTokenDigest": f"sha256:{token_digest}",
|
||||||
|
"tableId": config["tableId"],
|
||||||
|
"viewId": config["viewId"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def schema_fingerprint(config: dict[str, Any], fields: list[dict[str, str]]) -> str:
|
||||||
|
encoded = json.dumps(
|
||||||
|
{
|
||||||
|
"contract": "clarified-writeback-v1",
|
||||||
|
"target": schema_target(config),
|
||||||
|
"fields": sorted(fields, key=lambda item: item["id"]),
|
||||||
|
},
|
||||||
|
ensure_ascii=False, sort_keys=True, separators=(",", ":"),
|
||||||
|
).encode("utf-8")
|
||||||
|
return f"sha256:{hashlib.sha256(encoded).hexdigest()}"
|
||||||
|
|
||||||
|
|
||||||
|
def schema_plan(config: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
if config.get("workflow") != "clarified-writeback-v1":
|
||||||
|
raise IntakeError("schema migration requires clarified-writeback-v1 workflow")
|
||||||
|
fields = field_list(config)
|
||||||
|
by_name = {item["name"]: item for item in fields}
|
||||||
|
missing = [name for name, _ in TARGET_BASE_FIELDS if name not in by_name]
|
||||||
|
wrong_type = [
|
||||||
|
{"name": name, "expected": field_type, "actual": by_name[name]["type"]}
|
||||||
|
for name, field_type in TARGET_BASE_FIELDS
|
||||||
|
if name in by_name and by_name[name]["type"] != field_type
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"provider": "feishu-base",
|
||||||
|
"target": schema_target(config),
|
||||||
|
"schemaFingerprint": schema_fingerprint(config, fields),
|
||||||
|
"missingFields": missing,
|
||||||
|
"typeConflicts": wrong_type,
|
||||||
|
"visibleFields": [name for name, _ in TARGET_BASE_FIELDS],
|
||||||
|
"legacyFieldsPreserved": [
|
||||||
|
name for name in ("期望结果", "问题澄清", "复现步骤", "ACK Ready")
|
||||||
|
if name in by_name
|
||||||
|
],
|
||||||
|
"ok": not wrong_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_target_field(config: dict[str, Any], name: str) -> None:
|
||||||
|
field_type = dict(TARGET_BASE_FIELDS)[name]
|
||||||
|
if field_type in {"attachment", "updated_at"}:
|
||||||
|
raise IntakeError("schema migration cannot create a missing system/source field")
|
||||||
|
payload: dict[str, Any] = {"name": name, "type": field_type}
|
||||||
|
if name == "处理状态":
|
||||||
|
payload.update({"multiple": False, "options": [{"name": value} for value in INTAKE_STATUSES]})
|
||||||
|
run_cli([
|
||||||
|
"base", "+field-create", "--profile", config["profile"],
|
||||||
|
"--base-token", config["baseToken"], "--table-id", config["tableId"],
|
||||||
|
"--json", json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
|
||||||
|
"--format", "json",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def migration_rows(config: dict[str, Any], fields: list[dict[str, str]]) -> list[tuple[str, dict[str, Any]]]:
|
||||||
|
by_name = {item["name"]: item for item in fields}
|
||||||
|
names = ["标题", "详细描述", "期望结果", "处理状态"]
|
||||||
|
present = [name for name in names if name in by_name]
|
||||||
|
# record-list projects cells by configured field name and returns those
|
||||||
|
# names in its matrix, even when the REST field inventory exposes IDs.
|
||||||
|
ids = present
|
||||||
|
args = [
|
||||||
|
"base", "+record-list", "--profile", config["profile"],
|
||||||
|
"--base-token", config["baseToken"], "--table-id", config["tableId"],
|
||||||
|
"--view-id", config["viewId"], "--format", "json", "--offset", "0",
|
||||||
|
"--limit", str(PAGE_SIZE),
|
||||||
|
]
|
||||||
|
for field_id in ids:
|
||||||
|
args.extend(["--field-id", field_id])
|
||||||
|
response = run_cli(args)
|
||||||
|
record_ids, rows = matrix_from_response(response, ids)
|
||||||
|
data = response.get("data", response)
|
||||||
|
if data.get("has_more", data.get("hasMore", False)):
|
||||||
|
raise IntakeError("schema migration view exceeded one bounded page")
|
||||||
|
return [(record_id, dict(zip(present, row))) for record_id, row in zip(record_ids, rows)]
|
||||||
|
|
||||||
|
|
||||||
|
def schema_apply(config: dict[str, Any], expected_fingerprint: str) -> dict[str, Any]:
|
||||||
|
if DRAFT_REVISION.fullmatch(expected_fingerprint) is None:
|
||||||
|
raise IntakeError("expected schema fingerprint is invalid")
|
||||||
|
before = schema_plan(config)
|
||||||
|
if before["schemaFingerprint"] != expected_fingerprint:
|
||||||
|
raise IntakeError("Base schema changed after planning")
|
||||||
|
if before["typeConflicts"]:
|
||||||
|
raise IntakeError("Base schema has incompatible target field types")
|
||||||
|
for name in before["missingFields"]:
|
||||||
|
create_target_field(config, name)
|
||||||
|
fields = field_list(config)
|
||||||
|
for _ in range(4):
|
||||||
|
if all(name in {item["name"] for item in fields} for name, _ in TARGET_BASE_FIELDS):
|
||||||
|
break
|
||||||
|
time.sleep(0.5)
|
||||||
|
fields = field_list(config)
|
||||||
|
by_name = {item["name"]: item for item in fields}
|
||||||
|
if any(name not in by_name for name, _ in TARGET_BASE_FIELDS):
|
||||||
|
raise IntakeError("schema migration did not create all target fields")
|
||||||
|
migrated: list[str] = []
|
||||||
|
for record_id, cells in migration_rows(config, fields):
|
||||||
|
details = text(cells.get("详细描述"))
|
||||||
|
legacy_expected = text(cells.get("期望结果"))
|
||||||
|
patch: dict[str, Any] = {}
|
||||||
|
marker = f"用户原始期望:{legacy_expected}" if legacy_expected else ""
|
||||||
|
if marker and marker not in details:
|
||||||
|
patch["详细描述"] = f"{details}\n\n{marker}".strip()
|
||||||
|
if not text(cells.get("处理状态")):
|
||||||
|
patch["处理状态"] = "待整理"
|
||||||
|
if patch:
|
||||||
|
run_cli([
|
||||||
|
"base", "+record-upsert", "--profile", config["profile"],
|
||||||
|
"--base-token", config["baseToken"], "--table-id", config["tableId"],
|
||||||
|
"--record-id", record_id, "--json",
|
||||||
|
json.dumps(patch, ensure_ascii=False, separators=(",", ":")),
|
||||||
|
"--format", "json",
|
||||||
|
])
|
||||||
|
migrated.append(record_id)
|
||||||
|
visible_ids = [by_name[name]["id"] for name, _ in TARGET_BASE_FIELDS]
|
||||||
|
run_cli([
|
||||||
|
"base", "+view-set-visible-fields", "--profile", config["profile"],
|
||||||
|
"--base-token", config["baseToken"], "--table-id", config["tableId"],
|
||||||
|
"--view-id", config["viewId"], "--json",
|
||||||
|
json.dumps({"visible_fields": visible_ids}, separators=(",", ":")),
|
||||||
|
"--format", "json",
|
||||||
|
])
|
||||||
|
return {
|
||||||
|
"provider": "feishu-base", "createdFields": before["missingFields"],
|
||||||
|
"migratedRecordIds": migrated, "visibleFields": [name for name, _ in TARGET_BASE_FIELDS],
|
||||||
|
"schemaFingerprint": schema_fingerprint(config, fields), "ok": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
parser = argparse.ArgumentParser(description="Read and review a configured Feishu Base bug intake")
|
parser = argparse.ArgumentParser(description="Read and review a configured Feishu Base bug intake")
|
||||||
sub = parser.add_subparsers(dest="command", required=True)
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
for name in ("check", "fetch", "plan", "write-draft", "import-approved"):
|
for name in ("check", "fetch", "plan", "write-draft", "import-approved", "mark-imported", "schema-plan", "schema-apply"):
|
||||||
command = sub.add_parser(name)
|
command = sub.add_parser(name)
|
||||||
command.add_argument("tasks", type=Path, help="ACK tasks.yaml or JSON board")
|
command.add_argument("tasks", type=Path, help="ACK tasks.yaml or JSON board")
|
||||||
if name in {"fetch", "plan"}:
|
if name in {"fetch", "plan"}:
|
||||||
command.add_argument("--output-dir", type=Path, help="explicit directory for downloaded attachments")
|
command.add_argument("--output-dir", type=Path, help="explicit directory for downloaded attachments")
|
||||||
if name in {"write-draft", "import-approved"}:
|
if name in {"write-draft", "import-approved", "mark-imported"}:
|
||||||
command.add_argument("--record-id", required=True, help="existing Feishu Base record id")
|
command.add_argument("--record-id", required=True, help="existing Feishu Base record id")
|
||||||
command.add_argument("--expected-source-ref", required=True, help="sourceRef returned by fetch")
|
command.add_argument("--expected-source-ref", required=True, help="sourceRef returned by fetch")
|
||||||
command.add_argument("--expected-draft-revision", required=True, help="draftRevision returned by fetch")
|
command.add_argument("--expected-draft-revision", required=True, help="draftRevision returned by fetch")
|
||||||
if name == "write-draft":
|
if name == "write-draft":
|
||||||
command.add_argument("--input", type=Path, required=True, help="bounded JSON draft file")
|
command.add_argument("--input", type=Path, required=True, help="bounded JSON draft file")
|
||||||
|
if name == "mark-imported":
|
||||||
|
command.add_argument("--task-id", required=True, help="validated ACK task id")
|
||||||
|
if name == "schema-apply":
|
||||||
|
command.add_argument("--expected-schema-fingerprint", required=True)
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
try:
|
try:
|
||||||
board = load_board(args.tasks)
|
board = load_board(args.tasks)
|
||||||
@@ -829,11 +1188,20 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
elif args.command == "plan":
|
elif args.command == "plan":
|
||||||
output = fetch(config, args.output_dir)
|
output = fetch(config, args.output_dir)
|
||||||
output["actions"] = plan_actions(board, output["records"])
|
output["actions"] = plan_actions(board, output["records"])
|
||||||
|
elif args.command == "schema-plan":
|
||||||
|
output = schema_plan(config)
|
||||||
|
elif args.command == "schema-apply":
|
||||||
|
output = schema_apply(config, args.expected_schema_fingerprint)
|
||||||
elif args.command == "write-draft":
|
elif args.command == "write-draft":
|
||||||
output = write_draft(
|
output = write_draft(
|
||||||
config, args.record_id, args.expected_source_ref,
|
config, args.record_id, args.expected_source_ref,
|
||||||
args.expected_draft_revision, args.input,
|
args.expected_draft_revision, args.input,
|
||||||
)
|
)
|
||||||
|
elif args.command == "mark-imported":
|
||||||
|
output = mark_imported(
|
||||||
|
board, config, args.record_id, args.expected_source_ref,
|
||||||
|
args.expected_draft_revision, args.task_id,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
output = import_approved(
|
output = import_approved(
|
||||||
config, args.record_id, args.expected_source_ref,
|
config, args.record_id, args.expected_source_ref,
|
||||||
|
|||||||
@@ -1071,6 +1071,8 @@ def build_receipt(
|
|||||||
"profileId": plan["profileId"],
|
"profileId": plan["profileId"],
|
||||||
"profileHash": plan["profileHash"],
|
"profileHash": plan["profileHash"],
|
||||||
"launchFingerprint": plan["launchFingerprint"],
|
"launchFingerprint": plan["launchFingerprint"],
|
||||||
|
"projectRoot": plan["projectRoot"],
|
||||||
|
"boardHash": plan["boardHash"],
|
||||||
"slot": plan["slot"],
|
"slot": plan["slot"],
|
||||||
"createdFor": {
|
"createdFor": {
|
||||||
"taskId": plan["taskId"],
|
"taskId": plan["taskId"],
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ DESTINATION_TYPES = {"apt-repository", "oci-registry", "ci-artifact"}
|
|||||||
CHANNELS = {"preview", "staging", "stable"}
|
CHANNELS = {"preview", "staging", "stable"}
|
||||||
ENVIRONMENT_TYPES = {"ssh-host", "docker-compose", "kubernetes", "custom"}
|
ENVIRONMENT_TYPES = {"ssh-host", "docker-compose", "kubernetes", "custom"}
|
||||||
CLASSIFICATIONS = {"development", "staging", "production"}
|
CLASSIFICATIONS = {"development", "staging", "production"}
|
||||||
STOP_POINTS = {"verified", "review_ready", "released"}
|
STOP_POINTS = {"verified", "validation_ready", "review_ready", "released"}
|
||||||
ACTIONS = {
|
ACTIONS = {
|
||||||
"verify",
|
"verify",
|
||||||
"pull-request",
|
"pull-request",
|
||||||
@@ -506,6 +506,7 @@ def _validate_profiles(
|
|||||||
built_artifacts: set[str] = set()
|
built_artifacts: set[str] = set()
|
||||||
published_artifacts: set[str] = set()
|
published_artifacts: set[str] = set()
|
||||||
deployed_environments: set[str] = set()
|
deployed_environments: set[str] = set()
|
||||||
|
checked_environments: set[str] = set()
|
||||||
approvals: set[str] = set()
|
approvals: set[str] = set()
|
||||||
has_pull_request = False
|
has_pull_request = False
|
||||||
has_mark_ready = False
|
has_mark_ready = False
|
||||||
@@ -610,6 +611,8 @@ def _validate_profiles(
|
|||||||
errors.append(
|
errors.append(
|
||||||
f"{step_where}: health-check 前必须先 deploy {environment_id!r}"
|
f"{step_where}: health-check 前必须先 deploy {environment_id!r}"
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
checked_environments.add(environment_id)
|
||||||
if action == "approval":
|
if action == "approval":
|
||||||
gate = step.get("gate")
|
gate = step.get("gate")
|
||||||
if gate not in {"release", "production"}:
|
if gate not in {"release", "production"}:
|
||||||
@@ -635,10 +638,21 @@ def _validate_profiles(
|
|||||||
)
|
)
|
||||||
if stop_at == "released" and not ({"release", "production"} & approvals):
|
if stop_at == "released" and not ({"release", "production"} & approvals):
|
||||||
errors.append(f"{where}: released profile 必须包含 release 或 production approval")
|
errors.append(f"{where}: released profile 必须包含 release 或 production approval")
|
||||||
|
if stop_at == "validation_ready":
|
||||||
|
if not deployed_environments:
|
||||||
|
errors.append(f"{where}: validation_ready 必须至少部署一个环境")
|
||||||
|
missing_health = deployed_environments - checked_environments
|
||||||
|
if missing_health:
|
||||||
|
errors.append(
|
||||||
|
f"{where}: validation_ready 的部署环境必须全部完成 health-check: "
|
||||||
|
f"{sorted(missing_health)}"
|
||||||
|
)
|
||||||
|
|
||||||
if profile_id == default_profile:
|
if profile_id == default_profile:
|
||||||
if stop_at != "review_ready":
|
if stop_at not in {"validation_ready", "review_ready"}:
|
||||||
errors.append(f"{where}: defaultProfile 必须停在 review_ready")
|
errors.append(
|
||||||
|
f"{where}: defaultProfile 必须停在 validation_ready 或 review_ready"
|
||||||
|
)
|
||||||
used_destinations = {
|
used_destinations = {
|
||||||
step.get("destination")
|
step.get("destination")
|
||||||
for step in steps
|
for step in steps
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ DELIVERY_STATUSES = {
|
|||||||
"running",
|
"running",
|
||||||
"blocked",
|
"blocked",
|
||||||
"failed",
|
"failed",
|
||||||
|
"validation_ready",
|
||||||
"review_ready",
|
"review_ready",
|
||||||
"released",
|
"released",
|
||||||
"skipped",
|
"skipped",
|
||||||
@@ -115,16 +116,20 @@ DELIVERY_STATUSES = {
|
|||||||
DELIVERY_ARTIFACT_FIELDS = {"id", "type", "reference", "digest"}
|
DELIVERY_ARTIFACT_FIELDS = {"id", "type", "reference", "digest"}
|
||||||
DELIVERY_DEPLOYMENT_FIELDS = {"environment", "result", "evidence"}
|
DELIVERY_DEPLOYMENT_FIELDS = {"environment", "result", "evidence"}
|
||||||
FEISHU_REQUIRED_FIELDS = {
|
FEISHU_REQUIRED_FIELDS = {
|
||||||
"title", "actual", "expected", "stepsToReproduce", "acceptance", "priority",
|
"title", "actual", "expected", "stepsToReproduce", "acceptance",
|
||||||
"attachments", "updatedAt",
|
"attachments", "updatedAt",
|
||||||
}
|
}
|
||||||
FEISHU_OPTIONAL_FIELDS = {"fixLogic"}
|
FEISHU_OPTIONAL_FIELDS = {"priority", "fixLogic"}
|
||||||
|
FEISHU_CLARIFIED_FIELDS = {
|
||||||
|
"title", "details", "problemStatement", "expectedOutcome", "acceptance",
|
||||||
|
"intakeStatus", "ackTaskId", "attachments", "updatedAt",
|
||||||
|
}
|
||||||
FEISHU_CONFIG_FIELDS = {"provider", "workflow", "profile", "baseToken", "tableId", "viewId", "fields"}
|
FEISHU_CONFIG_FIELDS = {"provider", "workflow", "profile", "baseToken", "tableId", "viewId", "fields"}
|
||||||
FEISHU_SOURCE_FIELDS = {
|
FEISHU_SOURCE_FIELDS = {
|
||||||
"kind", "workflow", "ref", "recordId", "updatedAt", "approvedRevision",
|
"kind", "workflow", "ref", "recordId", "updatedAt", "approvedRevision",
|
||||||
"approvedPayloadHash",
|
"approvedPayloadHash",
|
||||||
}
|
}
|
||||||
FEISHU_WORKFLOWS = {"read-only-v1", "reviewed-writeback-v1"}
|
FEISHU_WORKFLOWS = {"read-only-v1", "reviewed-writeback-v1", "clarified-writeback-v1"}
|
||||||
FEISHU_PROFILE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
FEISHU_PROFILE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
||||||
FEISHU_SOURCE_REF_RE = re.compile(r"^feishu-base:sha256:[0-9a-f]{64}$")
|
FEISHU_SOURCE_REF_RE = re.compile(r"^feishu-base:sha256:[0-9a-f]{64}$")
|
||||||
FEISHU_RECORD_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$")
|
FEISHU_RECORD_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$")
|
||||||
@@ -135,7 +140,34 @@ DISPATCH_FIELDS = {
|
|||||||
"developer",
|
"developer",
|
||||||
"test",
|
"test",
|
||||||
"rounds",
|
"rounds",
|
||||||
|
"environmentIncidents",
|
||||||
}
|
}
|
||||||
|
ENVIRONMENT_INCIDENT_FIELDS = {
|
||||||
|
"id",
|
||||||
|
"attemptId",
|
||||||
|
"role",
|
||||||
|
"phase",
|
||||||
|
"status",
|
||||||
|
"summary",
|
||||||
|
"evidence",
|
||||||
|
"impact",
|
||||||
|
"recoveryAction",
|
||||||
|
"userAction",
|
||||||
|
"reportedAt",
|
||||||
|
"resolvedAt",
|
||||||
|
}
|
||||||
|
ENVIRONMENT_INCIDENT_ROLES = {"coordinator", "developer", "test"}
|
||||||
|
ENVIRONMENT_INCIDENT_PHASES = {
|
||||||
|
"launch",
|
||||||
|
"orchestration",
|
||||||
|
"service",
|
||||||
|
"test_data",
|
||||||
|
"browser",
|
||||||
|
"tooling",
|
||||||
|
"permissions",
|
||||||
|
"other",
|
||||||
|
}
|
||||||
|
ENVIRONMENT_INCIDENT_STATUSES = {"open", "resolved"}
|
||||||
KNOWLEDGE_CANDIDATE_REQUIRED_FIELDS = {
|
KNOWLEDGE_CANDIDATE_REQUIRED_FIELDS = {
|
||||||
"kind",
|
"kind",
|
||||||
"title",
|
"title",
|
||||||
@@ -541,7 +573,7 @@ def validate_delivery_runs(
|
|||||||
or re.fullmatch(r"sha256:[0-9a-f]{64}", digest) is None
|
or re.fullmatch(r"sha256:[0-9a-f]{64}", digest) is None
|
||||||
):
|
):
|
||||||
errors.append(f"{artifact_where}.digest: 必须是 sha256:<64 hex> 或 null")
|
errors.append(f"{artifact_where}.digest: 必须是 sha256:<64 hex> 或 null")
|
||||||
if status in {"review_ready", "released"} and not _nonempty_string(digest):
|
if status in {"validation_ready", "review_ready", "released"} and not _nonempty_string(digest):
|
||||||
errors.append(
|
errors.append(
|
||||||
f"{artifact_where}.digest: status={status!r} 时必须填写"
|
f"{artifact_where}.digest: status={status!r} 时必须填写"
|
||||||
)
|
)
|
||||||
@@ -584,7 +616,7 @@ def validate_delivery_runs(
|
|||||||
not _nonempty_string(item) for item in evidence
|
not _nonempty_string(item) for item in evidence
|
||||||
):
|
):
|
||||||
errors.append(f"{where}.evidence: 必须是字符串列表")
|
errors.append(f"{where}.evidence: 必须是字符串列表")
|
||||||
elif status in {"blocked", "failed", "review_ready", "released", "skipped"} and not evidence:
|
elif status in {"blocked", "failed", "validation_ready", "review_ready", "released", "skipped"} and not evidence:
|
||||||
errors.append(f"{where}.evidence: status={status!r} 时不能为空")
|
errors.append(f"{where}.evidence: status={status!r} 时不能为空")
|
||||||
if not _nonempty_string(run.get("updatedAt")):
|
if not _nonempty_string(run.get("updatedAt")):
|
||||||
errors.append(f"{where}.updatedAt: 必须是非空字符串")
|
errors.append(f"{where}.updatedAt: 必须是非空字符串")
|
||||||
@@ -691,18 +723,30 @@ def validate_builtin(data: dict) -> list[str]:
|
|||||||
if not isinstance(value, str) or not value.strip() or any(char.isspace() for char in value):
|
if not isinstance(value, str) or not value.strip() or any(char.isspace() for char in value):
|
||||||
errors.append(f"project.bugIntake.{key} 必须是无空白非空字符串")
|
errors.append(f"project.bugIntake.{key} 必须是无空白非空字符串")
|
||||||
fields = intake.get("fields")
|
fields = intake.get("fields")
|
||||||
|
expected_fields = (
|
||||||
|
FEISHU_CLARIFIED_FIELDS
|
||||||
|
if workflow == "clarified-writeback-v1"
|
||||||
|
else FEISHU_REQUIRED_FIELDS
|
||||||
|
)
|
||||||
|
allowed_fields = expected_fields | (
|
||||||
|
set() if workflow == "clarified-writeback-v1" else FEISHU_OPTIONAL_FIELDS
|
||||||
|
)
|
||||||
if (
|
if (
|
||||||
not isinstance(fields, dict)
|
not isinstance(fields, dict)
|
||||||
or not FEISHU_REQUIRED_FIELDS.issubset(fields)
|
or not expected_fields.issubset(fields)
|
||||||
or not set(fields).issubset(FEISHU_REQUIRED_FIELDS | FEISHU_OPTIONAL_FIELDS)
|
or not set(fields).issubset(allowed_fields)
|
||||||
):
|
):
|
||||||
errors.append("project.bugIntake.fields 必须且只能映射所需逻辑字段")
|
errors.append("project.bugIntake.fields 必须且只能映射所需逻辑字段")
|
||||||
elif any(not isinstance(v, str) or not v.strip() or any(c.isspace() for c in v) for v in fields.values()):
|
elif any(not isinstance(v, str) or not v.strip() or any(c.isspace() for c in v) for v in fields.values()):
|
||||||
errors.append("project.bugIntake.fields 字段值必须是无空白非空字符串")
|
errors.append("project.bugIntake.fields 字段值必须是无空白非空字符串")
|
||||||
elif len(set(fields.values())) != len(fields):
|
elif len(set(fields.values())) != len(fields):
|
||||||
errors.append("project.bugIntake.fields 字段值不能重复")
|
errors.append("project.bugIntake.fields 字段值不能重复")
|
||||||
elif workflow == "reviewed-writeback-v1" and "fixLogic" not in fields:
|
elif workflow == "reviewed-writeback-v1" and not {
|
||||||
errors.append("reviewed-writeback-v1 必须映射 project.bugIntake.fields.fixLogic")
|
"fixLogic", "priority"
|
||||||
|
}.issubset(fields):
|
||||||
|
errors.append(
|
||||||
|
"reviewed-writeback-v1 必须映射 project.bugIntake.fields.fixLogic 和 priority"
|
||||||
|
)
|
||||||
if (
|
if (
|
||||||
"knowledgeFile" in project
|
"knowledgeFile" in project
|
||||||
and project.get("knowledgeFile") != "docs/ack/knowledge.yaml"
|
and project.get("knowledgeFile") != "docs/ack/knowledge.yaml"
|
||||||
@@ -829,8 +873,8 @@ def validate_builtin(data: dict) -> list[str]:
|
|||||||
if source_workflow not in FEISHU_WORKFLOWS:
|
if source_workflow not in FEISHU_WORKFLOWS:
|
||||||
errors.append(f"{where}.source.workflow: 非法")
|
errors.append(f"{where}.source.workflow: 非法")
|
||||||
if (
|
if (
|
||||||
project_intake_workflow == "reviewed-writeback-v1"
|
project_intake_workflow in {"reviewed-writeback-v1", "clarified-writeback-v1"}
|
||||||
and source_workflow != "reviewed-writeback-v1"
|
and source_workflow != project_intake_workflow
|
||||||
and status not in {"verified", "leftover"}
|
and status not in {"verified", "leftover"}
|
||||||
):
|
):
|
||||||
errors.append(
|
errors.append(
|
||||||
@@ -838,24 +882,34 @@ def validate_builtin(data: dict) -> list[str]:
|
|||||||
)
|
)
|
||||||
approved_revision = source.get("approvedRevision")
|
approved_revision = source.get("approvedRevision")
|
||||||
stored_payload_hash = source.get("approvedPayloadHash")
|
stored_payload_hash = source.get("approvedPayloadHash")
|
||||||
if source_workflow == "reviewed-writeback-v1" and approved_revision is None:
|
is_approved_workflow = source_workflow in {"reviewed-writeback-v1", "clarified-writeback-v1"}
|
||||||
errors.append(f"{where}.source.approvedRevision: reviewed workflow 必填")
|
if is_approved_workflow and approved_revision is None:
|
||||||
|
errors.append(f"{where}.source.approvedRevision: writeback workflow 必填")
|
||||||
elif approved_revision is not None and (
|
elif approved_revision is not None and (
|
||||||
not isinstance(approved_revision, str)
|
not isinstance(approved_revision, str)
|
||||||
or re.fullmatch(r"sha256:[0-9a-f]{64}", approved_revision) is None
|
or re.fullmatch(r"sha256:[0-9a-f]{64}", approved_revision) is None
|
||||||
):
|
):
|
||||||
errors.append(f"{where}.source.approvedRevision: 必须是 sha256 revision")
|
errors.append(f"{where}.source.approvedRevision: 必须是 sha256 revision")
|
||||||
if source_workflow == "reviewed-writeback-v1":
|
if is_approved_workflow:
|
||||||
if (
|
if (
|
||||||
not isinstance(stored_payload_hash, str)
|
not isinstance(stored_payload_hash, str)
|
||||||
or re.fullmatch(r"sha256:[0-9a-f]{64}", stored_payload_hash) is None
|
or re.fullmatch(r"sha256:[0-9a-f]{64}", stored_payload_hash) is None
|
||||||
):
|
):
|
||||||
errors.append(f"{where}.source.approvedPayloadHash: reviewed workflow 必填")
|
errors.append(f"{where}.source.approvedPayloadHash: reviewed workflow 必填")
|
||||||
required_strings = ("title", "priority", "actual", "expected", "fixLogic")
|
required_strings = (
|
||||||
|
("title", "description", "actual", "expected")
|
||||||
|
if source_workflow == "clarified-writeback-v1"
|
||||||
|
else ("title", "priority", "actual", "expected", "fixLogic")
|
||||||
|
)
|
||||||
for field in required_strings:
|
for field in required_strings:
|
||||||
if not _nonempty_string(task.get(field)):
|
if not _nonempty_string(task.get(field)):
|
||||||
errors.append(f"{where}.{field}: reviewed workflow 必须是非空字符串")
|
errors.append(f"{where}.{field}: reviewed workflow 必须是非空字符串")
|
||||||
for field in ("stepsToReproduce", "acceptanceCriteria"):
|
required_lists = (
|
||||||
|
("acceptanceCriteria",)
|
||||||
|
if source_workflow == "clarified-writeback-v1"
|
||||||
|
else ("stepsToReproduce", "acceptanceCriteria")
|
||||||
|
)
|
||||||
|
for field in required_lists:
|
||||||
items = task.get(field)
|
items = task.get(field)
|
||||||
if (
|
if (
|
||||||
not isinstance(items, list)
|
not isinstance(items, list)
|
||||||
@@ -959,6 +1013,72 @@ def validate_builtin(data: dict) -> list[str]:
|
|||||||
f"{where}.dispatch.rounds: round 必须从 1 连续递增且不重复"
|
f"{where}.dispatch.rounds: round 必须从 1 连续递增且不重复"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
incidents = dispatch.get("environmentIncidents", [])
|
||||||
|
if not isinstance(incidents, list):
|
||||||
|
errors.append(f"{where}.dispatch.environmentIncidents: 必须是列表")
|
||||||
|
else:
|
||||||
|
seen_incident_ids: set[str] = set()
|
||||||
|
for incident_index, incident in enumerate(incidents):
|
||||||
|
incident_where = (
|
||||||
|
f"{where}.dispatch.environmentIncidents[{incident_index}]"
|
||||||
|
)
|
||||||
|
if not isinstance(incident, dict):
|
||||||
|
errors.append(f"{incident_where}: 必须是对象")
|
||||||
|
continue
|
||||||
|
reject_unknown_fields(
|
||||||
|
incident,
|
||||||
|
ENVIRONMENT_INCIDENT_FIELDS,
|
||||||
|
incident_where,
|
||||||
|
errors,
|
||||||
|
)
|
||||||
|
incident_id = incident.get("id")
|
||||||
|
expected_id = (
|
||||||
|
f"{tid}-ENV-{incident_index + 1}"
|
||||||
|
if isinstance(tid, str)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if not isinstance(incident_id, str) or incident_id != expected_id:
|
||||||
|
errors.append(f"{incident_where}.id: 应为 {expected_id}")
|
||||||
|
elif incident_id in seen_incident_ids:
|
||||||
|
errors.append(f"{incident_where}.id: 不能重复 {incident_id}")
|
||||||
|
else:
|
||||||
|
seen_incident_ids.add(incident_id)
|
||||||
|
if incident.get("role") not in ENVIRONMENT_INCIDENT_ROLES:
|
||||||
|
errors.append(
|
||||||
|
f"{incident_where}.role: 必须是 coordinator/developer/test"
|
||||||
|
)
|
||||||
|
if incident.get("phase") not in ENVIRONMENT_INCIDENT_PHASES:
|
||||||
|
errors.append(f"{incident_where}.phase: 非法环境阶段")
|
||||||
|
incident_status = incident.get("status")
|
||||||
|
if incident_status not in ENVIRONMENT_INCIDENT_STATUSES:
|
||||||
|
errors.append(f"{incident_where}.status: 必须是 open/resolved")
|
||||||
|
for field in (
|
||||||
|
"summary",
|
||||||
|
"evidence",
|
||||||
|
"impact",
|
||||||
|
"recoveryAction",
|
||||||
|
"userAction",
|
||||||
|
"reportedAt",
|
||||||
|
):
|
||||||
|
if not _nonempty_string(incident.get(field)):
|
||||||
|
errors.append(f"{incident_where}.{field}: 必须是非空字符串")
|
||||||
|
if "attemptId" in incident and not (
|
||||||
|
incident["attemptId"] is None
|
||||||
|
or _nonempty_string(incident["attemptId"])
|
||||||
|
):
|
||||||
|
errors.append(f"{incident_where}.attemptId: 必须是字符串或 null")
|
||||||
|
if "resolvedAt" in incident and not (
|
||||||
|
incident["resolvedAt"] is None
|
||||||
|
or _nonempty_string(incident["resolvedAt"])
|
||||||
|
):
|
||||||
|
errors.append(f"{incident_where}.resolvedAt: 必须是字符串或 null")
|
||||||
|
if incident_status == "resolved" and not _nonempty_string(
|
||||||
|
incident.get("resolvedAt")
|
||||||
|
):
|
||||||
|
errors.append(
|
||||||
|
f"{incident_where}: resolved 必须填写 resolvedAt"
|
||||||
|
)
|
||||||
|
|
||||||
resolution = task.get("resolution")
|
resolution = task.get("resolution")
|
||||||
if "resolution" in task:
|
if "resolution" in task:
|
||||||
if not isinstance(resolution, dict):
|
if not isinstance(resolution, dict):
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ RECEIPT_FIELDS = frozenset(
|
|||||||
"receiptHash",
|
"receiptHash",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
RECEIPT_CONTEXT_FIELDS = frozenset({"projectRoot", "boardHash"})
|
||||||
CREATED_FOR_FIELDS = frozenset({"taskId", "attemptId", "role"})
|
CREATED_FOR_FIELDS = frozenset({"taskId", "attemptId", "role"})
|
||||||
WORKTREE_FIELDS = frozenset(
|
WORKTREE_FIELDS = frozenset(
|
||||||
{
|
{
|
||||||
@@ -222,7 +223,6 @@ def validate_profile(profile: Any, *, where: str = "profile") -> list[str]:
|
|||||||
errors.append(
|
errors.append(
|
||||||
f"{where}.permissionMode: must be read-only/workspace-write"
|
f"{where}.permissionMode: must be read-only/workspace-write"
|
||||||
)
|
)
|
||||||
|
|
||||||
if cli == "codex":
|
if cli == "codex":
|
||||||
if not isinstance(effort, str) or effort not in REASONING_EFFORTS:
|
if not isinstance(effort, str) or effort not in REASONING_EFFORTS:
|
||||||
errors.append(
|
errors.append(
|
||||||
@@ -613,8 +613,20 @@ def validate_worker_receipt(
|
|||||||
|
|
||||||
if not isinstance(receipt, dict):
|
if not isinstance(receipt, dict):
|
||||||
return [f"{where}: must be an object"]
|
return [f"{where}: must be an object"]
|
||||||
errors = _unknown_fields(receipt, RECEIPT_FIELDS, where)
|
errors = _unknown_fields(receipt, RECEIPT_FIELDS | RECEIPT_CONTEXT_FIELDS, where)
|
||||||
errors.extend(_missing_fields(receipt, RECEIPT_FIELDS, where))
|
errors.extend(_missing_fields(receipt, RECEIPT_FIELDS, where))
|
||||||
|
project_root = receipt.get("projectRoot")
|
||||||
|
board_hash = receipt.get("boardHash")
|
||||||
|
if (project_root is None) != (board_hash is None):
|
||||||
|
errors.append(f"{where}: projectRoot and boardHash must be present together")
|
||||||
|
if project_root is not None and (
|
||||||
|
not isinstance(project_root, str) or not project_root.startswith("/")
|
||||||
|
):
|
||||||
|
errors.append(f"{where}.projectRoot: must be an absolute path")
|
||||||
|
if board_hash is not None and (
|
||||||
|
not isinstance(board_hash, str) or SHA256_RE.fullmatch(board_hash) is None
|
||||||
|
):
|
||||||
|
errors.append(f"{where}.boardHash: must be a canonical sha256 hex digest")
|
||||||
|
|
||||||
version = receipt.get("receiptVersion")
|
version = receipt.get("receiptVersion")
|
||||||
if version != RECEIPT_VERSION or isinstance(version, bool):
|
if version != RECEIPT_VERSION or isinstance(version, bool):
|
||||||
@@ -728,18 +740,20 @@ def validate_worker_receipt(
|
|||||||
and isinstance(requested, dict)
|
and isinstance(requested, dict)
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
expected_fingerprint = canonical_sha256(
|
facts = {
|
||||||
{
|
"protocolVersion": LAUNCH_PROTOCOL_VERSION,
|
||||||
"protocolVersion": LAUNCH_PROTOCOL_VERSION,
|
"backend": "orca",
|
||||||
"backend": "orca",
|
"profileId": receipt.get("profileId"),
|
||||||
"profileId": receipt.get("profileId"),
|
"profileHash": receipt.get("profileHash"),
|
||||||
"profileHash": receipt.get("profileHash"),
|
"createdFor": created_for,
|
||||||
"createdFor": created_for,
|
"worktree": worktree,
|
||||||
"worktree": worktree,
|
"requested": requested,
|
||||||
"requested": requested,
|
"slot": slot,
|
||||||
"slot": slot,
|
}
|
||||||
}
|
if project_root is not None and board_hash is not None:
|
||||||
)
|
facts["projectRoot"] = project_root
|
||||||
|
facts["boardHash"] = board_hash
|
||||||
|
expected_fingerprint = canonical_sha256(facts)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
errors.append(f"{where}.launchFingerprint: cannot hash launch facts")
|
errors.append(f"{where}.launchFingerprint: cannot hash launch facts")
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -216,7 +216,7 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"stopAt": {
|
"stopAt": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["verified", "review_ready", "released"]
|
"enum": ["verified", "validation_ready", "review_ready", "released"]
|
||||||
},
|
},
|
||||||
"steps": {
|
"steps": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
|
|||||||
@@ -51,8 +51,8 @@ receipt 全部以 `docs/ack/tasks.yaml` 的 `project.orchestration` 与顶层
|
|||||||
项目如改用 Cursor,应修改结构化 profile、allowlist 和 defaults,再运行任务板
|
项目如改用 Cursor,应修改结构化 profile、allowlist 和 defaults,再运行任务板
|
||||||
校验;不能在这里粘贴 `orca --command`、Agent CLI 参数或自由 shell。
|
校验;不能在这里粘贴 `orca --command`、Agent CLI 参数或自由 shell。
|
||||||
每次自动派发必须先审阅 launcher plan,再把其中的 `launchFingerprint` 作为
|
每次自动派发必须先审阅 launcher plan,再把其中的 `launchFingerprint` 作为
|
||||||
`--expected-launch-fingerprint` 启动 fresh worker。v0.10 的 `receiptHash` 只作
|
`--expected-launch-fingerprint` 启动 fresh worker。`receiptHash` 只作审计 checksum,
|
||||||
审计 checksum,不授权自动复用旧终端。
|
不授权自动复用旧终端;只有同轮空闲、身份匹配且历史消息可信清理后才可复用。
|
||||||
|
|
||||||
## 路径权限(项目覆盖层,必须填实际路径)
|
## 路径权限(项目覆盖层,必须填实际路径)
|
||||||
|
|
||||||
@@ -108,7 +108,8 @@ Skill 的 `scripts/run_verification.py` 执行,不直接拼接 path/args。检
|
|||||||
等第二配置面。
|
等第二配置面。
|
||||||
- v0.10 自动 launcher 只支持 `read-only` 和 `workspace-write`。full-access、
|
- v0.10 自动 launcher 只支持 `read-only` 和 `workspace-write`。full-access、
|
||||||
bypass、YOLO/force 与关闭 sandbox 均 fail closed;需要可信平台审批通道后再开放。
|
bypass、YOLO/force 与关闭 sandbox 均 fail closed;需要可信平台审批通道后再开放。
|
||||||
- v0.10 不自动复用持久化 receipt 指向的旧终端;每次自动派发都重新 plan/launch。
|
- 复用仅限同轮空闲、身份匹配且历史消息可信清理的 worker;否则重新 plan/launch。
|
||||||
|
- 整轮结束后回收只属于 verified 任务的终端;blocked/failed/leftover 终端保留且不设 TTL。
|
||||||
- `worker_done` 与复测报告都不等于完成。必须 Test 独立复测 + Coordinator 终检后才能 `verified`。
|
- `worker_done` 与复测报告都不等于完成。必须 Test 独立复测 + Coordinator 终检后才能 `verified`。
|
||||||
- 只有 Coordinator 写 `tasks.yaml` 和 `knowledge.yaml`;Developer 与 Test 都只读,
|
- 只有 Coordinator 写 `tasks.yaml` 和 `knowledge.yaml`;Developer 与 Test 都只读,
|
||||||
通过消息回报。
|
通过消息回报。
|
||||||
@@ -119,7 +120,7 @@ Skill 的 `scripts/run_verification.py` 执行,不直接拼接 path/args。检
|
|||||||
- ACK 不自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
- ACK 不自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
||||||
- `delivery.yaml` 默认关闭,只描述能力,不自动授权提交、推送、发布或部署;交付仅在
|
- `delivery.yaml` 默认关闭,只描述能力,不自动授权提交、推送、发布或部署;交付仅在
|
||||||
任务 `verified` 且本次 profile/目标/停止点得到确认后运行。
|
任务 `verified` 且本次 profile/目标/停止点得到确认后运行。
|
||||||
- 默认交付 profile 最多到 `review_ready`;stable 发布或 production 部署必须有
|
- 默认交付 profile 最多到 `validation_ready` 或 `review_ready`;stable 发布或 production 部署必须有
|
||||||
approval 步骤并再次获得明确批准。配置变更只影响下一次 run。
|
approval 步骤并再次获得明确批准。配置变更只影响下一次 run。
|
||||||
- 每个任务最多派发 3 轮,仍不过标记 `leftover` 并继续下一个。
|
- 每个任务最多派发 3 轮,仍不过标记 `leftover` 并继续下一个。
|
||||||
- 不提交、推送、发布或部署,除非用户确认的 ACK 任务或 delivery profile 明确包含。
|
- 不提交、推送、发布或部署,除非用户确认的 ACK 任务或 delivery profile 明确包含。
|
||||||
|
|||||||
@@ -181,7 +181,7 @@
|
|||||||
"provider": { "const": "feishu-base" },
|
"provider": { "const": "feishu-base" },
|
||||||
"workflow": {
|
"workflow": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["read-only-v1", "reviewed-writeback-v1"]
|
"enum": ["read-only-v1", "reviewed-writeback-v1", "clarified-writeback-v1"]
|
||||||
},
|
},
|
||||||
"profile": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" },
|
"profile": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" },
|
||||||
"baseToken": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
|
"baseToken": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
|
||||||
@@ -189,7 +189,7 @@
|
|||||||
"viewId": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
|
"viewId": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
|
||||||
"fields": {
|
"fields": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["title", "actual", "expected", "stepsToReproduce", "acceptance", "priority", "attachments", "updatedAt"],
|
"required": [],
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
"title": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
|
"title": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
|
||||||
@@ -201,10 +201,28 @@
|
|||||||
"priority": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
|
"priority": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
|
||||||
"attachments": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
|
"attachments": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
|
||||||
"updatedAt": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }
|
"updatedAt": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }
|
||||||
|
,"details": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }
|
||||||
|
,"problemStatement": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }
|
||||||
|
,"expectedOutcome": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }
|
||||||
|
,"intakeStatus": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }
|
||||||
|
,"ackTaskId": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"allOf": [
|
"allOf": [
|
||||||
|
{
|
||||||
|
"if": {
|
||||||
|
"not": {
|
||||||
|
"properties": { "workflow": { "const": "clarified-writeback-v1" } },
|
||||||
|
"required": ["workflow"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"properties": {
|
||||||
|
"fields": { "required": ["title", "actual", "expected", "stepsToReproduce", "acceptance", "attachments", "updatedAt"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"if": {
|
"if": {
|
||||||
"properties": { "workflow": { "const": "reviewed-writeback-v1" } },
|
"properties": { "workflow": { "const": "reviewed-writeback-v1" } },
|
||||||
@@ -212,7 +230,18 @@
|
|||||||
},
|
},
|
||||||
"then": {
|
"then": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"fields": { "required": ["fixLogic"] }
|
"fields": { "required": ["fixLogic", "priority"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
,{
|
||||||
|
"if": {
|
||||||
|
"properties": { "workflow": { "const": "clarified-writeback-v1" } },
|
||||||
|
"required": ["workflow"]
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"properties": {
|
||||||
|
"fields": { "required": ["title", "details", "problemStatement", "expectedOutcome", "acceptance", "intakeStatus", "ackTaskId", "attachments", "updatedAt"] }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -226,7 +255,7 @@
|
|||||||
"kind": { "const": "feishu-base" },
|
"kind": { "const": "feishu-base" },
|
||||||
"workflow": {
|
"workflow": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["read-only-v1", "reviewed-writeback-v1"]
|
"enum": ["read-only-v1", "reviewed-writeback-v1", "clarified-writeback-v1"]
|
||||||
},
|
},
|
||||||
"ref": {
|
"ref": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -250,6 +279,15 @@
|
|||||||
"required": ["approvedRevision", "approvedPayloadHash"]
|
"required": ["approvedRevision", "approvedPayloadHash"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
,{
|
||||||
|
"if": {
|
||||||
|
"properties": { "workflow": { "const": "clarified-writeback-v1" } },
|
||||||
|
"required": ["workflow"]
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"required": ["approvedRevision", "approvedPayloadHash"]
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"profileId": {
|
"profileId": {
|
||||||
@@ -614,6 +652,13 @@
|
|||||||
"launchFingerprint": {
|
"launchFingerprint": {
|
||||||
"$ref": "#/definitions/sha256"
|
"$ref": "#/definitions/sha256"
|
||||||
},
|
},
|
||||||
|
"projectRoot": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^/"
|
||||||
|
},
|
||||||
|
"boardHash": {
|
||||||
|
"$ref": "#/definitions/sha256"
|
||||||
|
},
|
||||||
"slot": {
|
"slot": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
@@ -730,6 +775,57 @@
|
|||||||
"evidence": { "type": "string" }
|
"evidence": { "type": "string" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"environmentIncident": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"role",
|
||||||
|
"phase",
|
||||||
|
"status",
|
||||||
|
"summary",
|
||||||
|
"evidence",
|
||||||
|
"impact",
|
||||||
|
"recoveryAction",
|
||||||
|
"userAction",
|
||||||
|
"reportedAt"
|
||||||
|
],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*-ENV-[1-9][0-9]*$"
|
||||||
|
},
|
||||||
|
"attemptId": { "type": ["string", "null"] },
|
||||||
|
"role": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["coordinator", "developer", "test"]
|
||||||
|
},
|
||||||
|
"phase": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"launch",
|
||||||
|
"orchestration",
|
||||||
|
"service",
|
||||||
|
"test_data",
|
||||||
|
"browser",
|
||||||
|
"tooling",
|
||||||
|
"permissions",
|
||||||
|
"other"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["open", "resolved"]
|
||||||
|
},
|
||||||
|
"summary": { "type": "string", "minLength": 1, "pattern": "\\S" },
|
||||||
|
"evidence": { "type": "string", "minLength": 1, "pattern": "\\S" },
|
||||||
|
"impact": { "type": "string", "minLength": 1, "pattern": "\\S" },
|
||||||
|
"recoveryAction": { "type": "string", "minLength": 1, "pattern": "\\S" },
|
||||||
|
"userAction": { "type": "string", "minLength": 1, "pattern": "\\S" },
|
||||||
|
"reportedAt": { "type": "string", "minLength": 1, "pattern": "\\S" },
|
||||||
|
"resolvedAt": { "type": ["string", "null"] }
|
||||||
|
}
|
||||||
|
},
|
||||||
"knowledgeRef": {
|
"knowledgeRef": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"pattern": "^K-[A-Z0-9][A-Z0-9-]*@[1-9][0-9]*$"
|
"pattern": "^K-[A-Z0-9][A-Z0-9-]*@[1-9][0-9]*$"
|
||||||
@@ -906,6 +1002,7 @@
|
|||||||
"running",
|
"running",
|
||||||
"blocked",
|
"blocked",
|
||||||
"failed",
|
"failed",
|
||||||
|
"validation_ready",
|
||||||
"review_ready",
|
"review_ready",
|
||||||
"released",
|
"released",
|
||||||
"skipped"
|
"skipped"
|
||||||
@@ -944,6 +1041,7 @@
|
|||||||
"running",
|
"running",
|
||||||
"blocked",
|
"blocked",
|
||||||
"failed",
|
"failed",
|
||||||
|
"validation_ready",
|
||||||
"review_ready",
|
"review_ready",
|
||||||
"released"
|
"released"
|
||||||
]
|
]
|
||||||
@@ -965,6 +1063,7 @@
|
|||||||
"enum": [
|
"enum": [
|
||||||
"blocked",
|
"blocked",
|
||||||
"failed",
|
"failed",
|
||||||
|
"validation_ready",
|
||||||
"review_ready",
|
"review_ready",
|
||||||
"released",
|
"released",
|
||||||
"skipped"
|
"skipped"
|
||||||
@@ -1062,6 +1161,10 @@
|
|||||||
"rounds": {
|
"rounds": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": { "$ref": "#/definitions/round" }
|
"items": { "$ref": "#/definitions/round" }
|
||||||
|
},
|
||||||
|
"environmentIncidents": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/definitions/environmentIncident" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,19 +12,19 @@ project:
|
|||||||
# 可选:飞书 Base Bug 收件箱。只保存 profile 名和资源 ID,绝不保存 App Secret。
|
# 可选:飞书 Base Bug 收件箱。只保存 profile 名和资源 ID,绝不保存 App Secret。
|
||||||
# bugIntake:
|
# bugIntake:
|
||||||
# provider: "feishu-base"
|
# provider: "feishu-base"
|
||||||
# workflow: "reviewed-writeback-v1"
|
# workflow: "clarified-writeback-v1"
|
||||||
# profile: "project-feishu"
|
# profile: "project-feishu"
|
||||||
# baseToken: "<base_token>"
|
# baseToken: "<base_token>"
|
||||||
# tableId: "<table_id>"
|
# tableId: "<table_id>"
|
||||||
# viewId: "<ack_ready_view_id>"
|
# viewId: "<ack_ready_view_id>"
|
||||||
# fields:
|
# fields:
|
||||||
# title: "<field_id>"
|
# title: "<field_id>"
|
||||||
# actual: "<field_id>"
|
# details: "<field_id>"
|
||||||
# expected: "<field_id>"
|
# problemStatement: "<field_id>"
|
||||||
# stepsToReproduce: "<field_id>"
|
# expectedOutcome: "<field_id>"
|
||||||
# fixLogic: "<field_id>" # 启用飞书审核前协作时必填
|
|
||||||
# acceptance: "<field_id>"
|
# acceptance: "<field_id>"
|
||||||
# priority: "<field_id>"
|
# intakeStatus: "<field_id>"
|
||||||
|
# ackTaskId: "<field_id>"
|
||||||
# attachments: "<field_id>"
|
# attachments: "<field_id>"
|
||||||
# updatedAt: "<field_id>"
|
# updatedAt: "<field_id>"
|
||||||
orchestration:
|
orchestration:
|
||||||
@@ -170,6 +170,8 @@ tasks:
|
|||||||
taskId: null
|
taskId: null
|
||||||
dispatchId: null
|
dispatchId: null
|
||||||
rounds: []
|
rounds: []
|
||||||
|
# 环境失败不占产品复验轮次;按 BUG-001-ENV-1... 连续编号,并写清 userAction。
|
||||||
|
environmentIncidents: []
|
||||||
|
|
||||||
resolution:
|
resolution:
|
||||||
fixedBy: null
|
fixedBy: null
|
||||||
|
|||||||
@@ -185,6 +185,33 @@ class AckDeliveryValidationTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(validate_delivery.validate_builtin(contract), [])
|
self.assertEqual(validate_delivery.validate_builtin(contract), [])
|
||||||
|
|
||||||
|
def test_default_validation_profile_requires_deploy_and_health_check(self) -> None:
|
||||||
|
contract = valid_contract()
|
||||||
|
contract["defaultProfile"] = "local-validation"
|
||||||
|
contract["profiles"]["local-validation"] = {
|
||||||
|
"stopAt": "validation_ready",
|
||||||
|
"steps": [
|
||||||
|
{"id": "build-local", "action": "build", "artifact": "service-deb"},
|
||||||
|
{
|
||||||
|
"id": "deploy-local",
|
||||||
|
"action": "deploy",
|
||||||
|
"artifact": "service-deb",
|
||||||
|
"environment": "test-server",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "health-local",
|
||||||
|
"action": "health-check",
|
||||||
|
"environment": "test-server",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
self.assertEqual(validate_delivery.validate_builtin(contract), [])
|
||||||
|
|
||||||
|
contract["profiles"]["local-validation"]["steps"].pop()
|
||||||
|
errors = validate_delivery.validate_builtin(contract)
|
||||||
|
self.assertTrue(any("必须全部完成 health-check" in item for item in errors))
|
||||||
|
|
||||||
def test_publish_and_health_check_require_prior_steps(self) -> None:
|
def test_publish_and_health_check_require_prior_steps(self) -> None:
|
||||||
contract = valid_contract()
|
contract = valid_contract()
|
||||||
steps = contract["profiles"]["review"]["steps"]
|
steps = contract["profiles"]["review"]["steps"]
|
||||||
|
|||||||
@@ -239,7 +239,8 @@ tasks: []
|
|||||||
self.assertIn("审核通过前的唯一协作区", content)
|
self.assertIn("审核通过前的唯一协作区", content)
|
||||||
self.assertIn("不创建或刷新 `tasks.yaml` 中的 ACK 任务", content)
|
self.assertIn("不创建或刷新 `tasks.yaml` 中的 ACK 任务", content)
|
||||||
self.assertIn("base:record:write", content)
|
self.assertIn("base:record:write", content)
|
||||||
self.assertIn("fixLogic", content)
|
self.assertIn("problemStatement", content)
|
||||||
|
self.assertIn("不在收件箱写修复逻辑", content)
|
||||||
self.assertNotIn("--lark-cli", content)
|
self.assertNotIn("--lark-cli", content)
|
||||||
self.assertNotIn("--executable", content)
|
self.assertNotIn("--executable", content)
|
||||||
|
|
||||||
|
|||||||
@@ -38,8 +38,95 @@ project:
|
|||||||
tasks: []
|
tasks: []
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
CLARIFIED_BOARD = """version: 1
|
||||||
|
project:
|
||||||
|
name: demo
|
||||||
|
bugIntake:
|
||||||
|
provider: feishu-base
|
||||||
|
workflow: clarified-writeback-v1
|
||||||
|
profile: tenant-b
|
||||||
|
baseToken: bascnDemo
|
||||||
|
tableId: tblDemo
|
||||||
|
viewId: vewReady
|
||||||
|
fields:
|
||||||
|
title: 标题
|
||||||
|
details: 详细描述
|
||||||
|
problemStatement: 问题说明
|
||||||
|
expectedOutcome: 期望效果
|
||||||
|
acceptance: 验收标准
|
||||||
|
intakeStatus: 处理状态
|
||||||
|
ackTaskId: ACK任务ID
|
||||||
|
attachments: 附件
|
||||||
|
updatedAt: 更新时间
|
||||||
|
tasks: []
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class FeishuBugIntakeUnitTests(unittest.TestCase):
|
class FeishuBugIntakeUnitTests(unittest.TestCase):
|
||||||
|
def test_clarified_workflow_normalizes_only_source_and_clarification_fields(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
board_path = Path(temp_dir) / "tasks.yaml"
|
||||||
|
board_path.write_text(CLARIFIED_BOARD, encoding="utf-8")
|
||||||
|
config = feishu_bug_intake.config_from_board(
|
||||||
|
feishu_bug_intake.load_board(board_path)
|
||||||
|
)
|
||||||
|
row = [
|
||||||
|
"Bug", "用户描述", "问题说明", "期望效果", "1. 可观察结果",
|
||||||
|
"待审核", "", [], "2026-08-03T12:00:00Z",
|
||||||
|
]
|
||||||
|
with mock.patch.object(feishu_bug_intake, "profile_check"), mock.patch.object(
|
||||||
|
feishu_bug_intake, "fetch_pages", return_value=[("recA", row)]
|
||||||
|
):
|
||||||
|
record = feishu_bug_intake.fetch(config, None)["records"][0]
|
||||||
|
self.assertEqual(record["details"], "用户描述")
|
||||||
|
self.assertEqual(record["expectedOutcome"], "期望效果")
|
||||||
|
self.assertNotIn("fixLogic", record)
|
||||||
|
self.assertNotIn("priority", record)
|
||||||
|
|
||||||
|
def test_clarified_draft_contract_rejects_fix_logic(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
path = Path(temp_dir) / "draft.json"
|
||||||
|
path.write_text(json.dumps({
|
||||||
|
"problemStatement": "问题说清楚",
|
||||||
|
"expectedOutcome": "期望说清楚",
|
||||||
|
"acceptance": ["结果可从界面观察"],
|
||||||
|
}), encoding="utf-8")
|
||||||
|
draft = feishu_bug_intake.load_draft(path, "clarified-writeback-v1")
|
||||||
|
self.assertEqual(set(draft), {"problemStatement", "expectedOutcome", "acceptance"})
|
||||||
|
path.write_text(json.dumps({**draft, "fixLogic": "不应出现"}), encoding="utf-8")
|
||||||
|
with self.assertRaisesRegex(feishu_bug_intake.IntakeError, "exactly"):
|
||||||
|
feishu_bug_intake.load_draft(path, "clarified-writeback-v1")
|
||||||
|
|
||||||
|
def test_schema_plan_preserves_legacy_fields_and_adds_new_contract(self) -> None:
|
||||||
|
config = {
|
||||||
|
"workflow": "clarified-writeback-v1",
|
||||||
|
"profile": "tenant-b",
|
||||||
|
"baseToken": "bascnDemo",
|
||||||
|
"tableId": "tblDemo",
|
||||||
|
"viewId": "vewReady",
|
||||||
|
}
|
||||||
|
fields = [
|
||||||
|
{"id": "a", "name": "标题", "type": "text"},
|
||||||
|
{"id": "b", "name": "详细描述", "type": "text"},
|
||||||
|
{"id": "c", "name": "附件", "type": "attachment"},
|
||||||
|
{"id": "d", "name": "验收标准", "type": "text"},
|
||||||
|
{"id": "e", "name": "更新时间", "type": "updated_at"},
|
||||||
|
{"id": "f", "name": "期望结果", "type": "text"},
|
||||||
|
]
|
||||||
|
with mock.patch.object(feishu_bug_intake, "field_list", return_value=fields):
|
||||||
|
plan = feishu_bug_intake.schema_plan(config)
|
||||||
|
self.assertEqual(plan["missingFields"], ["问题说明", "期望效果", "处理状态", "ACK任务ID"])
|
||||||
|
self.assertIn("期望结果", plan["legacyFieldsPreserved"])
|
||||||
|
self.assertEqual(plan["typeConflicts"], [])
|
||||||
|
self.assertEqual(plan["target"]["tableId"], "tblDemo")
|
||||||
|
changed_target = {**config, "tableId": "tblOther"}
|
||||||
|
self.assertNotEqual(
|
||||||
|
feishu_bug_intake.schema_fingerprint(config, fields),
|
||||||
|
feishu_bug_intake.schema_fingerprint(changed_target, fields),
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(feishu_bug_intake.IntakeError, "requires clarified"):
|
||||||
|
feishu_bug_intake.schema_plan({**config, "workflow": "read-only-v1"})
|
||||||
|
|
||||||
def make_fake_cli(self, root: Path) -> tuple[Path, Path]:
|
def make_fake_cli(self, root: Path) -> tuple[Path, Path]:
|
||||||
log_path = root / "calls.jsonl"
|
log_path = root / "calls.jsonl"
|
||||||
fake = root / "lark-cli"
|
fake = root / "lark-cli"
|
||||||
@@ -73,9 +160,12 @@ class FeishuBugIntakeUnitTests(unittest.TestCase):
|
|||||||
return code, stdout.getvalue(), stderr.getvalue()
|
return code, stdout.getvalue(), stderr.getvalue()
|
||||||
|
|
||||||
def test_reader_exposes_check_and_fetch_commands(self) -> None:
|
def test_reader_exposes_check_and_fetch_commands(self) -> None:
|
||||||
with self.assertRaises(SystemExit) as exited, contextlib.redirect_stdout(io.StringIO()):
|
output = io.StringIO()
|
||||||
|
with self.assertRaises(SystemExit) as exited, contextlib.redirect_stdout(output):
|
||||||
feishu_bug_intake.main(["--help"])
|
feishu_bug_intake.main(["--help"])
|
||||||
self.assertEqual(exited.exception.code, 0)
|
self.assertEqual(exited.exception.code, 0)
|
||||||
|
self.assertIn("mark-imported", output.getvalue())
|
||||||
|
self.assertNotIn("confirm", output.getvalue())
|
||||||
|
|
||||||
def test_fetch_uses_mocked_trusted_executable_and_official_wire_shapes(self) -> None:
|
def test_fetch_uses_mocked_trusted_executable_and_official_wire_shapes(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
@@ -214,6 +304,165 @@ class FeishuBugIntakeUnitTests(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
self.assertEqual(feishu_bug_intake.matrix_from_response(response, fields), (["recA"], [["Bug"]]))
|
self.assertEqual(feishu_bug_intake.matrix_from_response(response, fields), (["recA"], [["Bug"]]))
|
||||||
|
|
||||||
|
def test_matrix_reorders_same_field_projection(self) -> None:
|
||||||
|
response = {
|
||||||
|
"ok": True,
|
||||||
|
"data": {
|
||||||
|
"fields": ["fldExpected", "fldTitle"],
|
||||||
|
"record_id_list": ["recA"],
|
||||||
|
"data": [["expected", "Bug"]],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
feishu_bug_intake.matrix_from_response(
|
||||||
|
response,
|
||||||
|
["fldTitle", "fldExpected"],
|
||||||
|
),
|
||||||
|
(["recA"], [["Bug", "expected"]]),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_matrix_rejects_different_field_projection_with_diagnostics(self) -> None:
|
||||||
|
response = {
|
||||||
|
"ok": True,
|
||||||
|
"data": {
|
||||||
|
"fields": ["fldActual"],
|
||||||
|
"record_id_list": ["recA"],
|
||||||
|
"data": [["actual"]],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
feishu_bug_intake.IntakeError,
|
||||||
|
"expected=.*fldTitle.*actual=.*fldActual",
|
||||||
|
):
|
||||||
|
feishu_bug_intake.matrix_from_response(response, ["fldTitle"])
|
||||||
|
|
||||||
|
def test_missing_priority_mapping_is_normalized_as_enrichment(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
board_path = Path(temp_dir) / "tasks.yaml"
|
||||||
|
board_path.write_text(
|
||||||
|
BOARD.replace(" priority: fldPriority\n", ""),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
config = feishu_bug_intake.config_from_board(
|
||||||
|
feishu_bug_intake.load_board(board_path)
|
||||||
|
)
|
||||||
|
row = [
|
||||||
|
"Bug", "actual", "expected", "steps", "acceptance", [],
|
||||||
|
"2026-08-01T12:00:00Z",
|
||||||
|
]
|
||||||
|
with mock.patch.object(feishu_bug_intake, "profile_check"), mock.patch.object(
|
||||||
|
feishu_bug_intake, "fetch_pages", return_value=[("recA", row)]
|
||||||
|
):
|
||||||
|
payload = feishu_bug_intake.fetch(config, None)
|
||||||
|
|
||||||
|
self.assertEqual(payload["records"][0]["priority"], "")
|
||||||
|
self.assertIn("priority", payload["records"][0]["enrichmentRequired"])
|
||||||
|
|
||||||
|
def test_reviewed_workflow_requires_priority_mapping(self) -> None:
|
||||||
|
board = feishu_bug_intake.load_yaml_subset(
|
||||||
|
BOARD.replace(
|
||||||
|
" profile: tenant-b\n",
|
||||||
|
" workflow: reviewed-writeback-v1\n profile: tenant-b\n",
|
||||||
|
).replace(
|
||||||
|
" acceptance: fldAcceptance\n",
|
||||||
|
" acceptance: fldAcceptance\n fixLogic: fldFixLogic\n",
|
||||||
|
).replace(" priority: fldPriority\n", "")
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(feishu_bug_intake.IntakeError, "fixLogic and priority"):
|
||||||
|
feishu_bug_intake.config_from_board(board)
|
||||||
|
|
||||||
|
def test_fully_blank_clarified_row_is_skipped_without_key_error(self) -> None:
|
||||||
|
config = feishu_bug_intake.config_from_board(
|
||||||
|
feishu_bug_intake.load_yaml_subset(CLARIFIED_BOARD)
|
||||||
|
)
|
||||||
|
row = ["", "", "", "", "", "", "", [], ""]
|
||||||
|
with mock.patch.object(feishu_bug_intake, "profile_check"), mock.patch.object(
|
||||||
|
feishu_bug_intake, "fetch_pages", return_value=[("recA", row)]
|
||||||
|
):
|
||||||
|
payload = feishu_bug_intake.fetch(config, None)
|
||||||
|
self.assertEqual(payload["records"], [])
|
||||||
|
self.assertEqual(payload["warnings"][0]["code"], "blank_record_skipped")
|
||||||
|
|
||||||
|
def test_mark_imported_binds_confirmed_record_to_existing_task(self) -> None:
|
||||||
|
source_ref = "feishu-base:sha256:" + "a" * 64
|
||||||
|
revision = "sha256:" + "b" * 64
|
||||||
|
task = {
|
||||||
|
"id": "BUG-001",
|
||||||
|
"title": "Bug",
|
||||||
|
"description": "问题说明",
|
||||||
|
"actual": "用户描述",
|
||||||
|
"expected": "期望效果",
|
||||||
|
"acceptanceCriteria": ["结果可观察"],
|
||||||
|
"source": {
|
||||||
|
"kind": "feishu-base",
|
||||||
|
"workflow": "clarified-writeback-v1",
|
||||||
|
"ref": source_ref,
|
||||||
|
"recordId": "recA",
|
||||||
|
"updatedAt": "2026-08-04T10:00:00Z",
|
||||||
|
"approvedRevision": revision,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
task["source"]["approvedPayloadHash"] = feishu_bug_intake.approval_payload_hash(task)
|
||||||
|
board = {"tasks": [task]}
|
||||||
|
config = feishu_bug_intake.config_from_board(
|
||||||
|
feishu_bug_intake.load_yaml_subset(CLARIFIED_BOARD)
|
||||||
|
)
|
||||||
|
confirmed = {
|
||||||
|
"recordId": "recA", "sourceRef": source_ref,
|
||||||
|
"draftRevision": revision, "intakeStatus": "已确认", "ackTaskId": "",
|
||||||
|
"title": "Bug", "details": "用户描述",
|
||||||
|
"problemStatement": "问题说明", "expectedOutcome": "期望效果",
|
||||||
|
"acceptance": "1. 结果可观察", "updatedAt": "2026-08-04T10:00:00Z",
|
||||||
|
}
|
||||||
|
imported = {
|
||||||
|
**confirmed, "intakeStatus": "已导入", "ackTaskId": "BUG-001",
|
||||||
|
}
|
||||||
|
with mock.patch.object(
|
||||||
|
feishu_bug_intake, "review_record", return_value=confirmed,
|
||||||
|
), mock.patch.object(
|
||||||
|
feishu_bug_intake, "validate_task_board", return_value=[],
|
||||||
|
), mock.patch.object(feishu_bug_intake, "profile_check"), mock.patch.object(
|
||||||
|
feishu_bug_intake, "run_cli", return_value={"ok": True},
|
||||||
|
) as run_cli, mock.patch.object(
|
||||||
|
feishu_bug_intake, "fetch", return_value={"records": [imported]},
|
||||||
|
):
|
||||||
|
result = feishu_bug_intake.mark_imported(
|
||||||
|
board, config, "recA", source_ref, revision, "BUG-001",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(result["ok"])
|
||||||
|
patch = json.loads(run_cli.call_args.args[0][run_cli.call_args.args[0].index("--json") + 1])
|
||||||
|
self.assertEqual(patch, {"处理状态": "已导入", "ACK任务ID": "BUG-001"})
|
||||||
|
|
||||||
|
tampered = json.loads(json.dumps(board, ensure_ascii=False))
|
||||||
|
tampered["tasks"][0]["description"] = "未批准的改写"
|
||||||
|
tampered["tasks"][0]["source"]["approvedPayloadHash"] = (
|
||||||
|
feishu_bug_intake.approval_payload_hash(tampered["tasks"][0])
|
||||||
|
)
|
||||||
|
with mock.patch.object(
|
||||||
|
feishu_bug_intake, "review_record", return_value=confirmed,
|
||||||
|
), mock.patch.object(
|
||||||
|
feishu_bug_intake, "validate_task_board", return_value=[],
|
||||||
|
), mock.patch.object(feishu_bug_intake, "run_cli") as blocked_write:
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
feishu_bug_intake.IntakeError, "does not match the approved",
|
||||||
|
):
|
||||||
|
feishu_bug_intake.mark_imported(
|
||||||
|
tampered, config, "recA", source_ref, revision, "BUG-001",
|
||||||
|
)
|
||||||
|
blocked_write.assert_not_called()
|
||||||
|
|
||||||
|
with mock.patch.object(
|
||||||
|
feishu_bug_intake, "validate_task_board", return_value=["invalid"],
|
||||||
|
), mock.patch.object(feishu_bug_intake, "review_record") as blocked_read:
|
||||||
|
with self.assertRaisesRegex(feishu_bug_intake.IntakeError, "board is invalid"):
|
||||||
|
feishu_bug_intake.mark_imported(
|
||||||
|
board, config, "recA", source_ref, revision, "BUG-001",
|
||||||
|
)
|
||||||
|
blocked_read.assert_not_called()
|
||||||
|
|
||||||
def test_optional_fix_logic_field_is_normalized_for_preapproval_review(self) -> None:
|
def test_optional_fix_logic_field_is_normalized_for_preapproval_review(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
board_path = Path(temp_dir) / "tasks.yaml"
|
board_path = Path(temp_dir) / "tasks.yaml"
|
||||||
|
|||||||
+62
-1
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -47,6 +48,66 @@ class AckSkillContentTests(unittest.TestCase):
|
|||||||
self.assertIn("禁止根据持久化 receipt 自动复用", content)
|
self.assertIn("禁止根据持久化 receipt 自动复用", content)
|
||||||
self.assertIn("launcher 身份证明", content)
|
self.assertIn("launcher 身份证明", content)
|
||||||
|
|
||||||
|
def test_worker_reuse_requires_idle_state_and_verified_history_reset(self) -> None:
|
||||||
|
skill = (REPO_ROOT / "skills" / "ack" / "SKILL.md").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
adapter = (
|
||||||
|
REPO_ROOT / "skills" / "ack" / "references" / "orca-adapter.md"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertIn("空闲", skill)
|
||||||
|
self.assertIn("清理历史消息", skill)
|
||||||
|
self.assertIn("无法确认清理成功时创建 fresh worker", skill)
|
||||||
|
self.assertIn("角色、profile、worktree", adapter)
|
||||||
|
self.assertIn("不得复用仍在工作", adapter)
|
||||||
|
self.assertIn("或运行状态不明的 worker", adapter)
|
||||||
|
|
||||||
|
def test_coordinator_reclaims_only_verified_task_terminals_at_run_end(self) -> None:
|
||||||
|
skill = (REPO_ROOT / "skills" / "ack" / "SKILL.md").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
kickoff = (
|
||||||
|
REPO_ROOT / "skills" / "ack" / "references" / "kickoff.md"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertIn("整轮任务完成", skill)
|
||||||
|
self.assertIn("回收所有只属于 `verified` 任务的 worker", skill)
|
||||||
|
self.assertIn("终端,并核对关闭回执", skill)
|
||||||
|
self.assertIn("不设置 TTL", skill)
|
||||||
|
self.assertIn("blocked", kickoff)
|
||||||
|
self.assertIn("failed_retest", kickoff)
|
||||||
|
self.assertIn("leftover", kickoff)
|
||||||
|
|
||||||
|
def test_environment_failures_are_reported_without_consuming_retest_rounds(self) -> None:
|
||||||
|
skill = (REPO_ROOT / "skills" / "ack" / "SKILL.md").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
optimization = (
|
||||||
|
REPO_ROOT / "skills" / "ack" / "references" / "optimization-method.md"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
schema = json.loads(
|
||||||
|
(REPO_ROOT / "skills" / "ack" / "templates" / "tasks.schema.json")
|
||||||
|
.read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("环境失败不占复验轮次", skill)
|
||||||
|
self.assertIn("userAction", optimization)
|
||||||
|
self.assertIn("environmentIncidents", schema["definitions"]["task"]["properties"]["dispatch"]["properties"])
|
||||||
|
|
||||||
|
def test_validation_ready_hands_off_a_deployed_test_environment(self) -> None:
|
||||||
|
skill = (REPO_ROOT / "skills" / "ack" / "SKILL.md").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
delivery = (
|
||||||
|
REPO_ROOT / "skills" / "ack" / "references" / "delivery.md"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertIn("不能停在", skill)
|
||||||
|
self.assertIn("`verified` 却声称整轮 ACK 已结束", skill)
|
||||||
|
self.assertIn("validation_ready", delivery)
|
||||||
|
self.assertIn("访问地址和用户下一步", delivery)
|
||||||
|
|
||||||
def test_ack_knowledge_resources_and_version_are_present(self) -> None:
|
def test_ack_knowledge_resources_and_version_are_present(self) -> None:
|
||||||
ack_dir = REPO_ROOT / "skills" / "ack"
|
ack_dir = REPO_ROOT / "skills" / "ack"
|
||||||
|
|
||||||
@@ -67,7 +128,7 @@ class AckSkillContentTests(unittest.TestCase):
|
|||||||
):
|
):
|
||||||
self.assertTrue((ack_dir / relative_path).is_file(), relative_path)
|
self.assertTrue((ack_dir / relative_path).is_file(), relative_path)
|
||||||
version = (ack_dir / "VERSION").read_text(encoding="utf-8").strip()
|
version = (ack_dir / "VERSION").read_text(encoding="utf-8").strip()
|
||||||
self.assertEqual(version, "0.14.1")
|
self.assertEqual(version, "0.15.0")
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
f'ackVersion: "{version}"',
|
f'ackVersion: "{version}"',
|
||||||
(ack_dir / "examples" / "tasks.example.yaml").read_text(encoding="utf-8"),
|
(ack_dir / "examples" / "tasks.example.yaml").read_text(encoding="utf-8"),
|
||||||
|
|||||||
@@ -403,6 +403,41 @@ class AckTaskValidationTests(unittest.TestCase):
|
|||||||
"delivery run 只能引用 verified 任务",
|
"delivery run 只能引用 verified 任务",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_validation_ready_delivery_run_does_not_require_pull_request(self) -> None:
|
||||||
|
board = valid_manual_routing_board()
|
||||||
|
board["project"]["deliveryFile"] = "docs/ack/delivery.yaml"
|
||||||
|
board["tasks"][0]["status"] = "verified"
|
||||||
|
board["deliveryRuns"] = [
|
||||||
|
{
|
||||||
|
"id": "DR-local-1",
|
||||||
|
"profile": "local-validation",
|
||||||
|
"taskIds": ["T-1"],
|
||||||
|
"status": "validation_ready",
|
||||||
|
"sourceRevision": "a" * 64,
|
||||||
|
"configRevision": "b" * 64,
|
||||||
|
"pullRequest": None,
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"id": "local-service",
|
||||||
|
"type": "file",
|
||||||
|
"reference": "music-pilot",
|
||||||
|
"digest": "sha256:" + "c" * 64,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"deployments": [
|
||||||
|
{
|
||||||
|
"environment": "local-8080",
|
||||||
|
"result": "succeeded",
|
||||||
|
"evidence": "HTTP 200 and preflight passed",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"evidence": ["http://127.0.0.1:8080 ready for user validation"],
|
||||||
|
"updatedAt": "2026-08-03T23:10:00+08:00",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
self.assert_board_accepted_in_all_modes(board)
|
||||||
|
|
||||||
def test_delivery_runs_and_delivery_file_must_appear_together(self) -> None:
|
def test_delivery_runs_and_delivery_file_must_appear_together(self) -> None:
|
||||||
board = valid_manual_routing_board()
|
board = valid_manual_routing_board()
|
||||||
board["deliveryRuns"] = []
|
board["deliveryRuns"] = []
|
||||||
@@ -1111,6 +1146,62 @@ class AckTaskValidationTests(unittest.TestCase):
|
|||||||
"dispatch.rounds: round 必须从 1 连续递增且不重复",
|
"dispatch.rounds: round 必须从 1 连续递增且不重复",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_environment_incidents_do_not_consume_round_budget(self) -> None:
|
||||||
|
board = valid_knowledge_board()
|
||||||
|
board["tasks"][0]["status"] = "fixed_by_dev"
|
||||||
|
board["tasks"][0]["dispatch"] = {
|
||||||
|
"rounds": [],
|
||||||
|
"environmentIncidents": [
|
||||||
|
{
|
||||||
|
"id": f"T-1-ENV-{index}",
|
||||||
|
"attemptId": f"T-1-A{index}",
|
||||||
|
"role": "test",
|
||||||
|
"phase": "browser",
|
||||||
|
"status": "resolved",
|
||||||
|
"summary": "browser runtime was unavailable",
|
||||||
|
"evidence": "browser executable lookup returned no result",
|
||||||
|
"impact": "interactive acceptance signals were not evaluated",
|
||||||
|
"recoveryAction": "launch a network-enabled fresh Test worker",
|
||||||
|
"userAction": "none; Coordinator continues the recovery",
|
||||||
|
"reportedAt": "2026-08-03T20:00:00+08:00",
|
||||||
|
"resolvedAt": "2026-08-03T20:05:00+08:00",
|
||||||
|
}
|
||||||
|
for index in range(1, 5)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
self.assert_board_accepted_in_all_modes(board)
|
||||||
|
|
||||||
|
def test_environment_incidents_require_actionable_reporting(self) -> None:
|
||||||
|
board = valid_knowledge_board()
|
||||||
|
board["tasks"][0]["dispatch"] = {
|
||||||
|
"rounds": [],
|
||||||
|
"environmentIncidents": [
|
||||||
|
{
|
||||||
|
"id": "WRONG-ENV-9",
|
||||||
|
"role": "observer",
|
||||||
|
"phase": "unknown",
|
||||||
|
"status": "resolved",
|
||||||
|
"summary": "",
|
||||||
|
"evidence": "",
|
||||||
|
"impact": "",
|
||||||
|
"recoveryAction": "",
|
||||||
|
"userAction": "",
|
||||||
|
"reportedAt": "",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
self.assert_board_rejected_in_all_modes(
|
||||||
|
board,
|
||||||
|
"environmentIncidents[0].id: 应为 T-1-ENV-1",
|
||||||
|
"environmentIncidents[0].role: 必须是 coordinator/developer/test",
|
||||||
|
"environmentIncidents[0].phase: 非法环境阶段",
|
||||||
|
"environmentIncidents[0].summary: 必须是非空字符串",
|
||||||
|
"environmentIncidents[0].userAction: 必须是非空字符串",
|
||||||
|
"environmentIncidents[0]: resolved 必须填写 resolvedAt",
|
||||||
|
)
|
||||||
|
|
||||||
def test_leftover_reason_must_be_nonempty_string_in_all_modes(self) -> None:
|
def test_leftover_reason_must_be_nonempty_string_in_all_modes(self) -> None:
|
||||||
board = {
|
board = {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
|
|||||||
@@ -602,6 +602,32 @@ class ReceiptValidationTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_receipt_accepts_project_and_board_bound_launch_fingerprint(self) -> None:
|
||||||
|
routing = valid_orchestration()
|
||||||
|
receipt = valid_receipt(routing)
|
||||||
|
receipt["projectRoot"] = "/repo/demo"
|
||||||
|
receipt["boardHash"] = worker_profiles.canonical_sha256({"tasks": []})
|
||||||
|
receipt["launchFingerprint"] = worker_profiles.canonical_sha256({
|
||||||
|
"protocolVersion": 1,
|
||||||
|
"backend": "orca",
|
||||||
|
"projectRoot": receipt["projectRoot"],
|
||||||
|
"boardHash": receipt["boardHash"],
|
||||||
|
"profileId": receipt["profileId"],
|
||||||
|
"profileHash": receipt["profileHash"],
|
||||||
|
"createdFor": receipt["createdFor"],
|
||||||
|
"worktree": receipt["worktree"],
|
||||||
|
"requested": receipt["requested"],
|
||||||
|
"slot": receipt["slot"],
|
||||||
|
})
|
||||||
|
receipt["receiptHash"] = worker_profiles.receipt_hash(receipt)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
worker_profiles.validate_worker_receipt(
|
||||||
|
receipt, orchestration=routing, task_ids={"TASK-001"},
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
def test_receipt_slot_is_bounded_and_bound_into_launch_fingerprint(self) -> None:
|
def test_receipt_slot_is_bounded_and_bound_into_launch_fingerprint(self) -> None:
|
||||||
routing = valid_orchestration()
|
routing = valid_orchestration()
|
||||||
invalid = valid_receipt(routing)
|
invalid = valid_receipt(routing)
|
||||||
|
|||||||
Reference in New Issue
Block a user