feat(ack): add structured worker model routing
This commit is contained in:
+119
-4
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: ACK 设计评审记录
|
||||
date: "2026-07-31T17:15:37+08:00"
|
||||
updated: "2026-07-31T20:45:09+08:00"
|
||||
updated: "2026-07-31T23:31:48+08:00"
|
||||
---
|
||||
|
||||
# ACK 设计评审记录
|
||||
@@ -11,7 +11,8 @@ updated: "2026-07-31T20:45:09+08:00"
|
||||
|
||||
本文同时记录后续方案和实施结果。带“建议”“目标”或“待验证”的内容默认是设计
|
||||
方向,不代表当前版本已经具备;实际运行契约仍以 `skills/ack/SKILL.md`、schema、
|
||||
校验器和测试为准。知识护栏方案的 v0.9.0 落地边界见下文状态表。
|
||||
校验器和测试为准。知识护栏方案的 v0.9.0 落地边界、model-routing v0.10.0 的
|
||||
实施结论见下文状态表。
|
||||
|
||||
## 背景
|
||||
|
||||
@@ -173,6 +174,10 @@ transfer:
|
||||
|
||||
## P0:Worker 启动策略改为安全默认
|
||||
|
||||
> v0.10 已以结构化 profile 和可信 launcher 取代本节描述的自由 command。下方是
|
||||
> 当时的评审记录,不再代表当前实现;其中“full-access 写入任务板授权”的建议已被
|
||||
> v0.10 决策取代,当前明确为 Deferred。
|
||||
|
||||
当前校验器强制 Codex 使用 bypass、Cursor 使用 YOLO,并写死模型和 CLI。项目文档
|
||||
允许收紧权限或替换模型,但合法的安全覆盖会被校验器拒绝。
|
||||
|
||||
@@ -204,8 +209,10 @@ workers:
|
||||
permissionMode: sandbox
|
||||
```
|
||||
|
||||
可信代码根据这些字段构造 argv,不接受自由 shell 拼接。安全模式默认通过,
|
||||
full-access 需要用户单独授权,并把授权范围和时间写入任务板。
|
||||
可信代码根据这些字段构造 argv,不接受自由 shell 拼接。安全模式默认通过。
|
||||
原建议设想 full-access 由用户单独授权并写入任务板;v0.10 没有实现这条授权通道,
|
||||
项目文件也不能充当授权证据。full-access 的可信授权、期限、撤销和外层隔离均为
|
||||
Deferred,当前 launcher 必须 fail closed。
|
||||
|
||||
## P0:初始化需要原子化
|
||||
|
||||
@@ -627,6 +634,114 @@ issue、日志和外部网页只能作为不可信 evidence。进入知识库前
|
||||
触发条件限定,自动匹配会漏掉关键项或产生大量噪声。这类知识应继续由人维护在 ADR
|
||||
或项目文档中,不能强行进入自动派发流程。
|
||||
|
||||
## v0.10.0 实施结论:结构化 model-routing
|
||||
|
||||
### 当前决策
|
||||
|
||||
旧的 `validate_worker_command.py --command '<shell>'` 只能检查少量 token,无法证明
|
||||
整段 shell 没有追加命令、重复参数、环境注入或 cwd 漂移。v0.10 删除这条自由命令
|
||||
配置面:旧脚本只保留 fail-closed 迁移提示;自动 worker 的唯一入口是
|
||||
`launch_worker.py profile-hash|plan|launch`。
|
||||
|
||||
机器 SSOT 固定为 `tasks.yaml.project.orchestration`。profile 只声明 role、CLI、
|
||||
tier、model、reasoning effort 和 permission mode;model 必须命中按
|
||||
CLI/role/tier 分组的 allowlist。自动权限只允许 `read-only` 与
|
||||
`workspace-write`,full-access、Codex bypass、Cursor YOLO/force 和关闭 sandbox
|
||||
全部 fail closed。
|
||||
|
||||
### 已落地约束
|
||||
|
||||
| 控制面 | v0.10.0 实现 |
|
||||
|--------|--------------|
|
||||
| 自由 command / extra argv / env / cwd | schema 与语义校验拒绝;旧 validator fail closed |
|
||||
| Agent argv | 由 `worker_profiles.py` 按 profile 生成唯一 argv;子进程 `shell=False` |
|
||||
| plan → launch 漂移 | `launch` 强制接收刚审阅的 `--expected-launch-fingerprint`,slot 也纳入 hash |
|
||||
| PATH / loader / 凭据串用 | 不按调用者 PATH 找可执行文件;固定可信目录;控制进程无供应商凭据,worker 使用 `per-cli-allowlist-v1` |
|
||||
| worktree 冒充 | 绝对规范路径、无 symlink、同 Git common-dir,并命中 `git worktree list -z` |
|
||||
| Orca command 注入 | `--command` 只含固定 bootstrap 和随机 launch ID |
|
||||
| bootstrap 抢跑 | 先绑定 runtime/handle/incarnation/worktree,再用 terminal stdin nonce/proof 授权 |
|
||||
| cleanup / bootstrap 竞态 | Popen 前在同一 record lock 内复核 awaiting 状态;cleanup 先发生则拒绝启动 Agent |
|
||||
| create 部分失败 | 取得确定 handle 前的超时、transport/解码异常、中断、非零、畸形/缺字段响应均记为 indeterminate,禁止自动重试 |
|
||||
| handle 后失败清理 | record 写失败不阻断 close;只有 `terminal close --tab --json` 完整匹配且最终 failed 状态可靠落盘才记普通失败,否则保持 indeterminate/reconcile-required |
|
||||
| receipt 漂移 | 规范化 profileHash、argvHash、slot、launchFingerprint 与 receiptHash 交叉校验 |
|
||||
| dispatch 旧 receipt 改挂 | 每个角色显式记录 attemptId;receipt 必须绑定当前 ACK task.id、同 role/profile/attempt,receiptId/attemptId 同空同填 |
|
||||
| 配置兼容 | ackVersion 必须是合法 SemVer;v0.10+ 的 orchestration/workerReceipts 必须同时存在,v0.9 旧板仍可只读校验和手动协作 |
|
||||
|
||||
### 不能被弱证明掩盖的边界
|
||||
|
||||
`receiptHash` 是无密钥 checksum,不是签名。项目内可写方能修改 receipt 后重算
|
||||
hash;而当前 Orca terminal metadata 不提供原始 Agent argv、模型或权限
|
||||
attestation。因此 v0.10 **不根据持久化 receipt 自动复用旧终端**。每次自动派发都
|
||||
重新执行 plan,并用 expected fingerprint 启动 fresh worker;持久化 receipt 只作
|
||||
审计和 dispatch 关联。
|
||||
|
||||
`launchFingerprint` 只证明完整计划没有漂移,不是一次性授权或幂等键;同一计划重复
|
||||
执行仍会创建新的 fresh terminal。成功后不得重放,结果不确定时必须先 reconcile。
|
||||
自动防重放要依赖后端原子 idempotency/claim 或项目外可信 launch intent,不能由
|
||||
checksum 伪装提供。
|
||||
|
||||
任务板里的 dispatch 关联也不是宽松的历史索引。`dispatch.<role>` 显式记录
|
||||
`profileId`、`receiptId` 和 `attemptId`,校验器要求 receipt 的 `createdFor` 精确绑定
|
||||
当前 ACK `tasks[].id`、同一角色和同一 attempt,并要求 profile 一致。该约束阻止把
|
||||
旧任务、旧角色或旧轮次 receipt 改挂到当前 dispatch,但不会把无密钥 receipt 升级成
|
||||
可信 attestation。
|
||||
|
||||
同样,receipt 证明的是 launcher 请求和本地 CLI/runtime 绑定,不证明模型供应商最终
|
||||
执行的模型。开放自动复用需要 Orca/ACP 提供启动参数 attestation,或项目外可信签发
|
||||
与校验通道。开放 full-access 还需要不可由项目文本伪造的用户授权、期限、撤销和外层
|
||||
隔离。这两项均留待后续版本。
|
||||
|
||||
威胁模型也明确到 Coordinator 账户边界:v0.10 防止任务板、任务内容、普通环境和
|
||||
受限 worker 把数据变成第二个启动命令入口;不抵御已经完全控制 Coordinator OS
|
||||
账户、Orca runtime、受信 Agent/Orca 可执行文件或用户级 Agent 配置/插件/MCP 的
|
||||
攻击者。`permissionMode` 也不是模型供应商或外部工具能力 attestation;后者必须依赖
|
||||
独立 OS 身份、受控 Agent 配置或平台能力。
|
||||
|
||||
## 后续版本提案:ACP 与可替换编排后端
|
||||
|
||||
### 当前决策
|
||||
|
||||
ACK 当前版本继续以 **Orca** 作为唯一自动编排后端。闭环语义保持工具无关,但
|
||||
worker 创建、终端身份绑定、dispatch 和 wait 的可执行适配仍由 Orca 完成。
|
||||
|
||||
ACP(Agent Client Protocol)作为后续版本的优先候选,当前只记录方案,不加入运行
|
||||
时依赖、不新增半成品 adapter,也不让现有流程在 Orca 与 ACP 之间自动猜测。
|
||||
|
||||
| 能力 | 当前版本 | 后续方向 |
|
||||
|------|----------|----------|
|
||||
| 自动创建并监督 worker | Orca | ACP capability negotiation 后可增加 ACP adapter |
|
||||
| 结构化模型与权限 profile | ACK 自己定义,Orca 只承载启动 | 保持为 ACK 稳定契约,不交给后端自由解释 |
|
||||
| dispatch / wait / terminal identity | Orca runtime handle | 映射到 ACP session / request / event identity |
|
||||
| 无自动后端时运行闭环 | 手动模式 | 可增加 PTY / Zellij 类低层 fallback,但不冒充语义协议 |
|
||||
| 后端选择 | 项目显式配置 | 未来仍需显式配置,不按已安装命令静默切换 |
|
||||
|
||||
### 后端无关的最小语义
|
||||
|
||||
未来 adapter 只应实现以下能力,不应接管 ACK 的角色、状态机或安全策略:
|
||||
|
||||
1. `createWorker(profile, worktree)`:按 ACK 已验证的结构化 profile 创建 worker。
|
||||
2. `inspectWorker(identity)`:返回可绑定的 runtime、session/incarnation 和活性。
|
||||
3. `dispatch(identity, taskContext)`:把一个明确任务投递给已验证 worker。
|
||||
4. `wait(identity, eventTypes)`:等待完成、复测、升级或 decision gate 事件。
|
||||
5. `closeWorker(identity)`:只关闭本次创建且身份仍匹配的 worker。
|
||||
|
||||
模型、reasoning effort、权限模式、worktree 和 receipt hash 仍由 ACK 校验。adapter
|
||||
不能重新开放自由 shell、任意 argv、任意环境变量或“后端默认模型”作为旁路。
|
||||
|
||||
### ACP 采用门槛
|
||||
|
||||
满足以下条件后再实现 ACP adapter:
|
||||
|
||||
- 目标 Agent CLI 对 ACP 的启动、会话身份、取消和事件语义足够稳定。
|
||||
- 能把 Developer/Test 的独立身份、worktree 和结构化 profile 映射到可核对字段。
|
||||
- 能区分“已请求的模型/权限”和“运行时可观测事实”,不把客户端请求冒充供应商证明。
|
||||
- 超时、重连、重复 dispatch 和 runtime 重启具有明确的幂等或恢复语义。
|
||||
- 与 Orca adapter 使用同一组 ACK conformance tests,且不会降低安全默认。
|
||||
|
||||
在这些门槛满足前,ACP 保持 **Deferred**。PTY 或 Zellij 一类方案只能作为较低层的
|
||||
进程/终端承载,不提供 session 语义、模型证明或任务状态机;如果以后加入,也必须
|
||||
经过独立 adapter,不能散落为文档中的自由命令。
|
||||
|
||||
### 参考依据
|
||||
|
||||
- [GitHub 仓库自定义指令](https://docs.github.com/en/copilot/how-tos/copilot-on-github/customize-copilot/add-custom-instructions/add-repository-instructions)
|
||||
|
||||
+58
-3
@@ -54,7 +54,7 @@ skills/ack/
|
||||
├── references/ # 三角色规范、闭环流程和初始化说明
|
||||
├── templates/ # project.md、tasks.yaml、knowledge.yaml 模板和 schema
|
||||
├── examples/ # 完整示例
|
||||
└── scripts/ # 状态校验、知识选择、安全验证执行与 worker 命令工具
|
||||
└── scripts/ # 状态校验、知识选择、安全验证执行与结构化 worker launcher
|
||||
```
|
||||
|
||||
`SKILL.md` 是 Agent 的工作流入口。`references/` 是按需读取的稳定规范;
|
||||
@@ -115,6 +115,60 @@ python3 <ack-skill-dir>/scripts/run_verification.py \
|
||||
执行;关键约束应继续下沉到测试、lint、CI 或正式规范。ACK 不自动修改项目的
|
||||
`AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
||||
|
||||
## 启动 Worker
|
||||
|
||||
worker 的机器配置位于 `tasks.yaml.project.orchestration`:项目显式维护模型
|
||||
allowlist、结构化 profile、默认 profile 和允许的 worktree。不得在 `project.md`
|
||||
或任务里保存完整启动命令、额外 argv 或环境变量。
|
||||
|
||||
先审阅计划,不产生终端:
|
||||
|
||||
```bash
|
||||
python3 <ack-skill-dir>/scripts/launch_worker.py plan \
|
||||
--project-root <project-root> \
|
||||
--task-id <task-id> \
|
||||
--attempt-id <task-id>-A<round> \
|
||||
--role developer \
|
||||
--profile-id codex-dev-standard \
|
||||
--worktree <allowed-worktree>
|
||||
```
|
||||
|
||||
确认后执行 `launch`,并显式绑定刚审阅的 fingerprint:
|
||||
|
||||
```bash
|
||||
python3 <ack-skill-dir>/scripts/launch_worker.py launch \
|
||||
--project-root <project-root> \
|
||||
--task-id <task-id> \
|
||||
--attempt-id <task-id>-A<round> \
|
||||
--role developer \
|
||||
--profile-id codex-dev-standard \
|
||||
--worktree <allowed-worktree> \
|
||||
--expected-launch-fingerprint <plan 中的 sha256:...>
|
||||
```
|
||||
|
||||
launcher 是自动创建 worker 的唯一入口:它从权威任务板重读 profile,构造固定
|
||||
argv,忽略调用者 PATH、使用环境 allowlist,并验证真实 Git worktree 注册;然后
|
||||
通过仓库外的单次启动记录、terminal-bound nonce/proof 和受限 bootstrap 调用 Orca。
|
||||
返回的 receipt 含 `runtimeId`、handle、incarnation、profile hash、slot 和 worktree
|
||||
identity。Coordinator 将 receipt 追加到顶层 `workerReceipts`,再把 receipt ID
|
||||
写入任务的 `dispatch.developer` 或 `dispatch.test`,并同步写入本轮 `attemptId`。
|
||||
校验器要求 receipt 与当前 ACK task、角色、profile 和 attempt 完全一致;历史 receipt
|
||||
不能跨任务或跨轮次改挂。
|
||||
|
||||
v0.10 自动 launcher 仅支持 `read-only` 与 `workspace-write`。full-access、
|
||||
Codex bypass、Cursor YOLO/force 和关闭 sandbox 都会 fail closed;在有可信平台
|
||||
审批或独立签发通道之前,不用项目文件伪装成用户授权。旧任务板没有结构化
|
||||
`project.orchestration` 时仍可读取和手动协作,但不得自动创建 worker。
|
||||
|
||||
持久化 `receiptHash` 是无密钥 checksum,不是 launcher 身份证明。由于 Orca 当前
|
||||
不能证明旧终端的原始 argv/模型/权限,v0.10 不自动复用既有 worker;每次自动派发
|
||||
都重新 `plan` 并用 expected fingerprint 启动 fresh worker。
|
||||
fingerprint 只校验完整计划没有漂移,不是一次性令牌;成功后不得用同一 fingerprint
|
||||
重复启动,结果不确定时必须先 reconcile。
|
||||
若创建或关闭回执不完整,或外部 launch record 状态无法可靠持久化,launcher 会返回
|
||||
`indeterminate/reconcile-required`;必须先核对 record 与 Orca live state,不能
|
||||
直接重试。
|
||||
|
||||
## 开始一个需求
|
||||
|
||||
初始化完成后可以直接说:
|
||||
@@ -128,5 +182,6 @@ Coordinator 会先读取项目状态和 `references/kickoff.md`,生成产品
|
||||
|
||||
## 版本
|
||||
|
||||
当前 Skill 版本见 `VERSION`。新项目在 `tasks.yaml` 中记录 `ackVersion`。旧项目的
|
||||
`kitVersion` 可以继续读取,但建议迁移为 `ackVersion`。
|
||||
当前 Skill 版本见 `VERSION`。新项目在 `tasks.yaml` 中以合法 SemVer 记录
|
||||
`ackVersion`。从 `0.10.0` 起,`project.orchestration` 与顶层 `workerReceipts` 必须
|
||||
同时存在;旧项目的 `kitVersion` 可以继续读取,但建议迁移为 `ackVersion`。
|
||||
|
||||
+28
-7
@@ -75,7 +75,9 @@ description: >-
|
||||
- `docs/ack/tasks.yaml`
|
||||
- `docs/ack/knowledge.yaml`
|
||||
2. 读取 `<ack-skill-dir>/VERSION`,对比 `tasks.yaml` 的 `ackVersion`。旧项目只有
|
||||
`kitVersion` 时仍可读取,但建议迁移为 `ackVersion`。
|
||||
`kitVersion` 时仍可读取,但建议迁移为 `ackVersion`。`ackVersion` 必须是合法
|
||||
SemVer;从 `0.10.0` 起 `project.orchestration` 与顶层 `workerReceipts` 必须同时
|
||||
存在。
|
||||
3. 查找未替换占位符,并核对项目路径、覆盖层路径、Developer 白盒命令、Test
|
||||
黑盒命令和 Base URL。
|
||||
4. 使用 `<ack-skill-dir>/scripts/validate_tasks.py` 校验任务板,使用
|
||||
@@ -84,7 +86,12 @@ description: >-
|
||||
字段缺失而宣称失败。
|
||||
5. 检查知识引用能解析到固定 revision,candidate 仍留在任务证据中,且
|
||||
`stale`、`superseded` 和 `archived` 不会被当作可派发的 `active` 知识。
|
||||
6. 检查不会自动修复或覆盖现有配置;用户明确要求修复后再修改。
|
||||
6. 若存在 `project.orchestration`,检查 profile、model allowlist、默认 profile、
|
||||
允许 worktree、顶层 `workerReceipts` 与 `dispatch.developer/test` 的引用;receipt
|
||||
必须绑定当前 ACK task、同一 role/profile/attempt,`receiptId` 与 `attemptId`
|
||||
必须同时为空或同时填写。
|
||||
缺少结构化路由的旧任务板只能使用手动模式,不能自动创建 worker。
|
||||
7. 检查不会自动修复或覆盖现有配置;用户明确要求修复后再修改。
|
||||
|
||||
## 工作
|
||||
|
||||
@@ -97,22 +104,30 @@ description: >-
|
||||
- `<ack-skill-dir>/references/kickoff.md`
|
||||
- kickoff 指定且与当前任务相关的 references 文件
|
||||
3. 当前会话担任 Coordinator,遵守项目覆盖层中的命令、路径权限、模型路由和
|
||||
worker 复用规则。项目覆盖层优先于通用示例命令。按 scope 推荐相关 `active`
|
||||
worker 启动规则。项目覆盖层优先于通用示例命令。按 scope 推荐相关 `active`
|
||||
知识,经确认后把固定 revision 的显式 `knowledgeRefs` 写入当前任务上下文;
|
||||
不全量注入知识库。
|
||||
4. 新需求先写产品文档、任务拆分与可观测验收信号,更新 `tasks.yaml` 并校验,
|
||||
然后交给用户确认;确认前不派发实现。
|
||||
5. 用户已确认的任务按 ACK 闭环执行:Developer 实现与白盒验证,Test 独立黑盒
|
||||
5. 创建或更换 worker 时,只使用
|
||||
`<ack-skill-dir>/scripts/launch_worker.py plan|launch` 读取
|
||||
`tasks.yaml.project.orchestration` 的 profile。不得直接执行
|
||||
`orca terminal create --command`,不得接受或拼接自由 command、额外 argv、
|
||||
executable、env 或 cwd。必须先审阅 `plan.launchFingerprint`,再把它作为
|
||||
`launch --expected-launch-fingerprint` 传入。v0.10 不根据持久化 receipt 自动
|
||||
复用旧终端;每次自动派发都创建 fresh worker,receipt 只作审计与 dispatch
|
||||
关联。
|
||||
6. 用户已确认的任务按 ACK 闭环执行:Developer 实现与白盒验证,Test 独立黑盒
|
||||
复测,Coordinator 读取证据终检并唯一写入 `tasks.yaml`。Developer 回报
|
||||
`knowledgeApplied` 和 `knowledgeCandidates`,Test 回报 `knowledgeChecks`;
|
||||
`candidate` 只有在独立验证和 gate 后才能由 Coordinator 写入或激活。
|
||||
6. 执行知识项的 `verification.ref` 时,只调用
|
||||
7. 执行知识项的 `verification.ref` 时,只调用
|
||||
`<ack-skill-dir>/scripts/run_verification.py docs/ack/knowledge.yaml
|
||||
<verification-ref> --project-root <project-root>`。不要直接执行选择器返回的 path/args,
|
||||
也不要给 runner 注入额外命令或参数。
|
||||
7. 不把 `worker_done` 或 Test 自报成功直接当作完成。每项最多三轮,仍失败则记录
|
||||
8. 不把 `worker_done` 或 Test 自报成功直接当作完成。每项最多三轮,仍失败则记录
|
||||
`leftover` 并继续其它任务。
|
||||
8. 关键的安全、正确性和兼容性约束应下沉为测试、lint、CI 或正式规范;
|
||||
9. 关键的安全、正确性和兼容性约束应下沉为测试、lint、CI 或正式规范;
|
||||
`knowledge.yaml` 只保存触发条件、原因与证据引用,不能替代可执行控制。
|
||||
|
||||
## 边界
|
||||
@@ -120,6 +135,12 @@ description: >-
|
||||
- 不修改或追加任何项目 Agent 指令文件,包括 `AGENTS.md`。
|
||||
- 不在项目中维护第二份 ACK 通用规范、模板或任务 schema。
|
||||
- 不猜测项目命令、服务地址、worker handle 或模型名称。
|
||||
- 不把 full-access、bypass、YOLO/force、关闭 sandbox 或项目内“授权”字段当成
|
||||
v0.10 自动 worker 的合法配置;当前一律 fail closed。
|
||||
- 不把无密钥 `receiptHash` 或 Orca live metadata 当作旧终端的启动 attestation;
|
||||
v0.10 不自动复用既有 worker。
|
||||
- launcher 返回 `indeterminate` 或 `reconcile required` 时,不直接重试;先按
|
||||
launch ID、外部 record 和 Orca live state 完成人工核对。
|
||||
- 不覆盖已有 `docs/ack` 文件,不擅自提交、推送、创建终端或新 worktree。
|
||||
- 只有 Coordinator 写 `tasks.yaml` 和 `knowledge.yaml`;Developer 与 Test 只读,
|
||||
只能通过回报提名或验证知识。
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
0.9.0
|
||||
0.10.0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# notes-web Agent 协作协议(示例,项目覆盖层)
|
||||
|
||||
> 本项目基于 ack v0.9.0。
|
||||
> 本项目基于 ack v0.10.0。
|
||||
> 通用规范由 `/ack` 从 Skill 自身的 `references/` 读取,本文件只填项目差异。
|
||||
> 覆盖层文件放在 `docs/ack/project.md`,不占用 `AGENTS.md`。
|
||||
> ACK 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
||||
@@ -26,13 +26,18 @@
|
||||
- 派发 prompt 模板:`references/prompt-templates.md`
|
||||
- Orca 编排命令:`references/orca-adapter.md`
|
||||
|
||||
## 模型档位
|
||||
## Worker 路由
|
||||
|
||||
| 角色 | 默认档位 | 本项目实际 |
|
||||
|------|----------|------------|
|
||||
| Coordinator (PM) | 强模型 | claude-sonnet-5-thinking-high |
|
||||
| Test | 中低模型 | 默认(中低) |
|
||||
| Developer | 中低模型 | 默认(中低),架构任务临时升级 |
|
||||
结构化配置位于 `docs/ack/tasks.yaml` 的 `project.orchestration`,启动记录位于顶层
|
||||
`workerReceipts`。本项目默认使用:
|
||||
|
||||
| 角色 | profile ID | 档位 |
|
||||
|------|------------|------|
|
||||
| Developer | `codex-dev-standard` | standard |
|
||||
| Test | `codex-test-standard` | standard |
|
||||
| Developer 升级 | `codex-dev-strong` | strong |
|
||||
|
||||
本文件不保存或拼接 worker shell 命令。
|
||||
|
||||
## 路径权限
|
||||
|
||||
@@ -77,6 +82,11 @@ path/args。
|
||||
|
||||
- 三角色独立:Coordinator 只编排、Test 只验证、Developer 只实现。
|
||||
- 模型分层:Coordinator 强模型不跑测试,Test/Developer 中低模型(见 references/model-routing.md)。
|
||||
- 自动 worker 只通过 ACK 的 `scripts/launch_worker.py` 启动;不直接执行自由
|
||||
`orca --command`。v0.10 的自动 profile 只允许 `read-only` 或
|
||||
`workspace-write`,不允许 full-access、bypass、YOLO/force 或关闭 sandbox。
|
||||
- 每次自动派发都先审阅 plan,再用 `--expected-launch-fingerprint` 启动 fresh
|
||||
worker;持久化 receipt 只作审计,不授权自动复用旧终端。
|
||||
- `worker_done` 与复测报告都不等于完成,必须 Test 独立复测 + Coordinator 终检后才能 `verified`。
|
||||
- 只有 Coordinator 写 `tasks.yaml` 和 `knowledge.yaml`;Test 与 Developer 只读。
|
||||
- Coordinator 只派发按 scope 命中并显式写入 `knowledgeRefs` 的 `active` 知识;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# 填好的最小示例(虚构的 notes-web 项目)。可用 scripts/validate_tasks.py 校验通过。
|
||||
# 填好的手动闭环示例(虚构的 notes-web 项目)。历史轮次没有自动 launcher receipt;
|
||||
# 因此使用 manual 模式。可用 scripts/validate_tasks.py 校验通过。
|
||||
version: 1
|
||||
updatedAt: "2026-07-06T09:40:00+08:00"
|
||||
source: "Coordinator (PM) Agent"
|
||||
ackVersion: "0.9.0"
|
||||
ackVersion: "0.10.0"
|
||||
project:
|
||||
name: "notes-web"
|
||||
repoPath: "/home/dev/notes-web"
|
||||
@@ -10,6 +11,65 @@ project:
|
||||
devWorktree: "/home/dev/notes-web-wt/fix-preview"
|
||||
overlayFile: "docs/ack/project.md"
|
||||
knowledgeFile: "docs/ack/knowledge.yaml"
|
||||
orchestration:
|
||||
profileVersion: 1
|
||||
mode: "manual"
|
||||
allowedWorktrees:
|
||||
- "/home/dev/notes-web-wt/fix-preview"
|
||||
modelAllowlist:
|
||||
codex:
|
||||
developer:
|
||||
standard: ["gpt-5.6-terra"]
|
||||
strong: ["gpt-5.6-sol"]
|
||||
test:
|
||||
standard: ["gpt-5.6-luna"]
|
||||
cursor-agent:
|
||||
developer:
|
||||
standard: ["auto"]
|
||||
test:
|
||||
standard: ["auto"]
|
||||
profiles:
|
||||
codex-dev-standard:
|
||||
role: "developer"
|
||||
cli: "codex"
|
||||
tier: "standard"
|
||||
model: "gpt-5.6-terra"
|
||||
reasoningEffort: "medium"
|
||||
permissionMode: "workspace-write"
|
||||
codex-test-standard:
|
||||
role: "test"
|
||||
cli: "codex"
|
||||
tier: "standard"
|
||||
model: "gpt-5.6-luna"
|
||||
reasoningEffort: "low"
|
||||
permissionMode: "workspace-write"
|
||||
codex-dev-strong:
|
||||
role: "developer"
|
||||
cli: "codex"
|
||||
tier: "strong"
|
||||
model: "gpt-5.6-sol"
|
||||
reasoningEffort: "high"
|
||||
permissionMode: "workspace-write"
|
||||
cursor-dev-standard:
|
||||
role: "developer"
|
||||
cli: "cursor-agent"
|
||||
tier: "standard"
|
||||
model: "auto"
|
||||
reasoningEffort: null
|
||||
permissionMode: "workspace-write"
|
||||
cursor-test-standard:
|
||||
role: "test"
|
||||
cli: "cursor-agent"
|
||||
tier: "standard"
|
||||
model: "auto"
|
||||
reasoningEffort: null
|
||||
permissionMode: "workspace-write"
|
||||
defaults:
|
||||
developer: "codex-dev-standard"
|
||||
test: "codex-test-standard"
|
||||
developerUpgraded: "codex-dev-strong"
|
||||
|
||||
workerReceipts: []
|
||||
|
||||
summary:
|
||||
verified: ["BUG-002"]
|
||||
@@ -65,9 +125,18 @@ tasks:
|
||||
checks:
|
||||
- "预览后出现 4 行 diff:title/before/after/coverChanged"
|
||||
dispatch:
|
||||
taskId: "orca-task-88"
|
||||
dispatchId: "orca-disp-91"
|
||||
worker: "dev-worker-1"
|
||||
developer:
|
||||
profileId: "codex-dev-standard"
|
||||
receiptId: null
|
||||
attemptId: null
|
||||
taskId: null
|
||||
dispatchId: null
|
||||
test:
|
||||
profileId: "codex-test-standard"
|
||||
receiptId: null
|
||||
attemptId: null
|
||||
taskId: null
|
||||
dispatchId: null
|
||||
rounds:
|
||||
- round: 1
|
||||
attemptId: "BUG-002-A1"
|
||||
@@ -130,9 +199,18 @@ tasks:
|
||||
checks:
|
||||
- "并发保存时后写方收到冲突提示"
|
||||
dispatch:
|
||||
taskId: "orca-task-90"
|
||||
dispatchId: "orca-disp-95"
|
||||
worker: "dev-worker-1"
|
||||
developer:
|
||||
profileId: "codex-dev-standard"
|
||||
receiptId: null
|
||||
attemptId: null
|
||||
taskId: null
|
||||
dispatchId: null
|
||||
test:
|
||||
profileId: "codex-test-standard"
|
||||
receiptId: null
|
||||
attemptId: null
|
||||
taskId: null
|
||||
dispatchId: null
|
||||
rounds:
|
||||
- round: 1
|
||||
attemptId: "BUG-003-A1"
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
`docs/ack/knowledge.yaml`。
|
||||
- [ ] 技术栈、运行、构建、单测和集成测试命令均来自项目证据。
|
||||
- [ ] Coordinator、Developer、Test 的模型档位和升级规则已明确。
|
||||
- [ ] `project.orchestration` 使用受支持的 profileVersion,模型都命中项目
|
||||
allowlist,默认 profile 与角色/档位一致。
|
||||
- [ ] `allowedWorktrees` 只列出同一 Git common-dir 下已经核对的绝对 worktree。
|
||||
|
||||
## 路径权限
|
||||
|
||||
@@ -56,8 +59,17 @@
|
||||
## 编排
|
||||
|
||||
- [ ] 已选择 Orca 或手动模式。
|
||||
- [ ] 派发前优先复用同 worktree、同角色、同配置的空闲 worker。
|
||||
- [ ] Developer 与 Test 的启动命令通过校验。
|
||||
- [ ] 自动创建只调用 `launch_worker.py plan|launch`,没有直接拼接
|
||||
`orca terminal create --command` 或自由 worker 命令。
|
||||
- [ ] `launch` 使用刚审阅的 `plan.launchFingerprint` 作为
|
||||
`--expected-launch-fingerprint`;漂移时重新 plan。
|
||||
- [ ] Developer/Test 只使用 `read-only` 或 `workspace-write` profile;
|
||||
v0.10 没有 full-access、bypass、YOLO/force 或关闭 sandbox。
|
||||
- [ ] v0.10 每次自动派发都启动 fresh worker;没有把历史 receipt checksum 或
|
||||
Orca live metadata 当作自动复用授权。
|
||||
- [ ] launcher receipt 已追加到顶层 `workerReceipts`,任务分别引用
|
||||
`dispatch.developer` 与 `dispatch.test`;每个引用的 task/role/profile/attempt
|
||||
都与当前任务、本轮 receipt 完全一致,没有用单一 handle 混淆两个角色。
|
||||
- [ ] 多 worktree 场景只有一个权威 `tasks.yaml` 和 `knowledge.yaml`。
|
||||
- [ ] Test 使用的服务来自正确 worktree。
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
本文件定义**与具体编排工具无关**的三角色协作闭环。运行时调度可以用 Orca(见 `orca-adapter.md`),也可以手动跑(见下方「手动模式」)。
|
||||
|
||||
原则:调度消息只是运行时载体,任务结论必须回写到 `tasks.yaml`;跨任务复用、已经
|
||||
验证的项目经验必须由 Coordinator 回写到 `knowledge.yaml`。不要把消息当最终记录。
|
||||
原则:调度消息只是运行时载体,任务结论必须回写到 `tasks.yaml`;已经验证且可跨
|
||||
任务使用的项目经验必须由 Coordinator 回写到 `knowledge.yaml`。不要把消息当最终记录。
|
||||
角色定义见 `roles-and-permissions.md`:Coordinator 只编排、Test 只验证、
|
||||
Developer 只实现。
|
||||
|
||||
@@ -34,10 +34,13 @@ Coordinator 发现或读取 open 任务
|
||||
-> 从 knowledge.yaml 按 scope 推荐 active 知识,Coordinator 确认固定 revision 的 knowledgeRefs
|
||||
-> 为新轮次生成稳定 attemptId(<task-id>-A<round>),Developer 与 Test 共用
|
||||
-> 决定 worktree:当前 worktree 起子 agent,还是新建隔离 worktree(见下节「子任务放哪」)
|
||||
-> 解析 worker:先复用同 worktree、同角色的空闲 worker;没有可复用项时才校验命令并新建(见 orca-adapter.md)
|
||||
-> 解析安全 profile:机器配置只读 tasks.yaml.project.orchestration
|
||||
-> 运行 launcher plan 并审阅 launchFingerprint
|
||||
-> 带 expected fingerprint 启动 fresh worker,把 receipt 写回 tasks.yaml(见 orca-adapter.md)
|
||||
-> dispatch 给 Developer(--to <worker handle>)
|
||||
-> wait:Developer 的 worker_done / escalation(含 knowledgeApplied / knowledgeCandidates)
|
||||
-> writeback fixed_by_dev
|
||||
-> 为 Test 独立解析安全 profile,重新 plan/launch fresh worker 并写回 receipt
|
||||
-> dispatch 给 Test(retesting)
|
||||
-> wait:Test 的 retest_result(含 knowledgeChecks 和 candidate 独立证据)
|
||||
-> Test 通过:gate(Coordinator 读证据对齐意图)
|
||||
@@ -48,7 +51,9 @@ Coordinator 发现或读取 open 任务
|
||||
```
|
||||
|
||||
一次派发只修一个明确问题(细则见 `optimization-method.md` §「每轮派发只修一个明确问题」)。
|
||||
「决定 worktree」「解析 worker」两步的决策见下节与 `model-routing.md` / `orca-adapter.md`。
|
||||
「决定 worktree」「解析 profile/receipt」几步的决策见下节与 `model-routing.md` /
|
||||
`orca-adapter.md`。v0.10 不自动复用历史 receipt 或既有终端;标题、preview、分支名、
|
||||
worker 自报和无密钥 receipt checksum 都不能代替可信启动 attestation。
|
||||
Coordinator 默认给新逻辑轮次生成 `<task-id>-A<round>`,并在记录轮次结果时写入
|
||||
`tasks[].dispatch.rounds[].attemptId`。它独立于编排工具产生的 `taskId` 和
|
||||
`dispatchId`:前者用于知识来源追溯,后两者只用于查询运行时。0.9.0 之前的历史
|
||||
@@ -101,23 +106,48 @@ Test 对显式引用回报 `not_applicable` 时,说明 Coordinator 的选择
|
||||
|
||||
Coordinator 派发前先决定 Developer/Test 在哪工作。两种方式:
|
||||
|
||||
**方式 1:当前 worktree 复用或起子 agent(终端)**
|
||||
**方式 1:当前 worktree 起 fresh 子 agent(终端)**
|
||||
|
||||
```bash
|
||||
# 先按 orca-adapter.md 的 worker 复用流程查找空闲终端;没有时才创建
|
||||
orca terminal create --worktree active --command "cursor-agent --yolo --model auto" --title "ACK-DEV-CURSOR-AUTO-1" --json
|
||||
# v0.10 不自动复用既有终端;先审阅 plan:
|
||||
python3 <ack-skill-dir>/scripts/launch_worker.py plan \
|
||||
--project-root <project-root> --task-id <task-id> \
|
||||
--attempt-id <task-id>-A<round> --role <developer-or-test> \
|
||||
--profile-id <profile-id> --worktree <absolute-current-worktree> --slot <1..99>
|
||||
python3 <ack-skill-dir>/scripts/launch_worker.py launch \
|
||||
--project-root <project-root> --task-id <task-id> \
|
||||
--attempt-id <task-id>-A<round> --role <developer-or-test> \
|
||||
--profile-id <profile-id> --worktree <absolute-current-worktree> --slot <1..99> \
|
||||
--expected-launch-fingerprint <plan 中的 sha256:...>
|
||||
```
|
||||
|
||||
- 适合:串行闭环、一次一个 Developer 任务、小改动、Test 要复测的正是 Developer 改的那棵树。
|
||||
- 优点:单一服务实例、无跨 worktree 对齐困扰、开销小。
|
||||
- 风险:多个 agent 同时改同一棵树会互相踩(半构建状态、git 冲突)。
|
||||
- 记录:`launch` 成功后把 receipt 追加到权威 `tasks.yaml.workerReceipts`,并把
|
||||
`receipt.id` 写入 `dispatch.<role>.receiptId`、把 `profileId` 写入
|
||||
`dispatch.<role>.profileId`、把本轮稳定 ID 写入 `dispatch.<role>.attemptId`;
|
||||
receipt 的 task/role/profile/attempt 必须与当前 dispatch 完全一致,不能只记录
|
||||
一个裸 handle,也不能引用旧任务或旧轮次 receipt。
|
||||
|
||||
**方式 2:新建隔离 worktree,再在其中起子 agent**
|
||||
|
||||
创建成功后,先把 Orca 返回的绝对路径加入权威任务板的
|
||||
`project.orchestration.allowedWorktrees` 并重新运行 `validate_tasks.py`;未进入
|
||||
allowlist 的路径不能交给 launcher。
|
||||
|
||||
```bash
|
||||
orca worktree create --name <feature> --base-branch <base> --json
|
||||
# 然后先查该 worktree 的同角色空闲终端;没有时才创建
|
||||
orca terminal create --worktree path:<new> --command "cursor-agent --yolo --model auto" --title "ACK-DEV-CURSOR-AUTO-1"
|
||||
# 对返回的绝对路径调用 ACK launcher
|
||||
python3 <ack-skill-dir>/scripts/launch_worker.py plan \
|
||||
--project-root <project-root> --task-id <task-id> \
|
||||
--attempt-id <task-id>-A<round> --role <developer-or-test> \
|
||||
--profile-id <profile-id> --worktree <absolute-new-worktree> --slot <1..99>
|
||||
python3 <ack-skill-dir>/scripts/launch_worker.py launch \
|
||||
--project-root <project-root> --task-id <task-id> \
|
||||
--attempt-id <task-id>-A<round> --role <developer-or-test> \
|
||||
--profile-id <profile-id> --worktree <absolute-new-worktree> --slot <1..99> \
|
||||
--expected-launch-fingerprint <plan 中的 sha256:...>
|
||||
```
|
||||
|
||||
- 适合:并行多个互不依赖的子任务、大/高风险/实验性改动、要保持基线分支干净(如 prod 不动、feature 走独立分支)、要独立 build 或跑独立服务实例、best-of-N 尝试。
|
||||
@@ -136,8 +166,13 @@ orca terminal create --worktree path:<new> --command "cursor-agent --yolo --mode
|
||||
|
||||
**项目状态(SSOT)只落一处**:无论开几个 worktree,`tasks.yaml` 和
|
||||
`knowledge.yaml` 都只认一个权威副本(通常在基线/协调所在 worktree),由
|
||||
Coordinator 单写。不要每个 worktree 各留一份会分叉的项目状态。模型固定方式见
|
||||
`model-routing.md` 与 `orca-adapter.md`。
|
||||
Coordinator 单写。`project.orchestration`、顶层 `workerReceipts` 和任务 dispatch
|
||||
也只写入这个副本;不要每个 worktree 各留一份会分叉的项目状态。profile 解析、
|
||||
launcher 与 receipt 规则见 `model-routing.md` 和 `orca-adapter.md`。
|
||||
|
||||
两种 worktree 方式都只允许 profile 中的 `read-only` / `workspace-write` 安全权限。
|
||||
v0.10 的 full-access 授权与撤销机制仍是 Deferred;launcher 遇到 full-access 或等价
|
||||
的 sandbox 绕过请求必须 fail closed。
|
||||
|
||||
---
|
||||
|
||||
@@ -158,6 +193,10 @@ Coordinator 单写。不要每个 worktree 各留一份会分叉的项目状态
|
||||
手动模式下同样遵守:worker_done / 复测报告都不等于最终结论、只有 Coordinator
|
||||
写 `tasks.yaml` 和 `knowledge.yaml`、三轮失败留档。
|
||||
|
||||
手动交给用户已打开的会话不等于产生 ACK receipt。该会话可以完成本次显式 handoff,
|
||||
但不能因此进入 Orca 自动派发信任路径;后续自动派发仍需重新通过 ACK launcher
|
||||
创建 fresh worker。
|
||||
|
||||
---
|
||||
|
||||
## Test 复测(编排无关)
|
||||
|
||||
@@ -68,10 +68,14 @@ docs/ack/
|
||||
|
||||
编辑 `docs/ack/tasks.yaml`:
|
||||
|
||||
- `ackVersion` 使用 ACK Skill 的 `VERSION`。
|
||||
- `ackVersion` 使用 ACK Skill 的合法 SemVer `VERSION`;从 `0.10.0` 起
|
||||
`project.orchestration` 与顶层 `workerReceipts` 必须同时存在。
|
||||
- `updatedAt` 使用当前带时区时间。
|
||||
- `project.name`、`repoPath`、`devWorktree`、`overlayFile` 和 `knowledgeFile` 使用
|
||||
真实值。
|
||||
- `project.orchestration.allowedWorktrees` 使用已核对的绝对 worktree;模型
|
||||
allowlist、profiles 和 defaults 使用项目实际允许值。不要把完整启动命令、
|
||||
`extraArgs`、`env` 或任意 executable 写进任务板。
|
||||
- 非服务项目的 `baseUrl` 写为 `n/a`。
|
||||
- 没有真实任务时使用 `tasks: []`,不要保留或虚构示例任务。
|
||||
|
||||
@@ -109,6 +113,11 @@ python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml --
|
||||
- `project.overlayFile` 指向真实文件。
|
||||
- `project.knowledgeFile` 指向 `docs/ack/knowledge.yaml`。
|
||||
- Developer 与 Test 的验证命令可执行。
|
||||
- `project.orchestration` 的 profile/allowlist/defaults 通过校验,自动模式只允许
|
||||
`read-only` 或 `workspace-write`;旧任务板未迁移时保持手动模式。
|
||||
- 顶层 `workerReceipts` 和 `dispatch.developer/test` 的 task/role/profile/attempt
|
||||
引用一致;`receiptId` 与 `attemptId` 同时为空或同时填写。持久 receipt 只作审计,
|
||||
v0.10 不自动复用旧终端。
|
||||
- 网站或 API 项目写清服务启动、重启和 Base URL。
|
||||
- 任务中的固定 revision `knowledgeRefs` 都能解析,非 `active` 条目没有被派发。
|
||||
|
||||
|
||||
@@ -6,7 +6,11 @@
|
||||
|
||||
## 前提:谁是 Coordinator
|
||||
|
||||
**你(发起编排的强模型会话)就是 Coordinator (PM) / 产品。** 你负责写文档、拆任务、编排、终检,**不亲自写代码、不亲自跑测试**。开发和测试是另起的 worker agent;具体 CLI、模型、reasoning effort 和执行模式必须先读项目覆盖层 `docs/ack/project.md`,禁止照抄未核对的默认命令。
|
||||
**你(发起编排的强模型会话)就是 Coordinator (PM) / 产品。** 你负责写文档、拆任务、
|
||||
编排、终检,**不亲自写代码、不亲自跑测试**。开发和测试是另起的 worker agent;
|
||||
具体 CLI、模型、reasoning effort 和执行模式的机器事实源是
|
||||
`docs/ack/tasks.yaml` 的 `project.orchestration`。`docs/ack/project.md` 只解释项目
|
||||
差异,不能提供另一套启动命令。
|
||||
|
||||
---
|
||||
|
||||
@@ -16,7 +20,7 @@
|
||||
我要做一个新需求:<一句话需求>。
|
||||
你作为 ack 的 Coordinator(PM),按 ACK Skill 的 references 规范执行:
|
||||
|
||||
1. 先读 docs/ack/project.md、docs/ack/tasks.yaml,校验 docs/ack/knowledge.yaml 并
|
||||
1. 先读 docs/ack/project.md、docs/ack/tasks.yaml(包括 project.orchestration),校验 docs/ack/knowledge.yaml 并
|
||||
用 `scripts/select_knowledge.py` 只读取当前任务相关的 active 条目,再读
|
||||
references/roles-and-permissions.md、closed-loop.md、optimization-method.md。
|
||||
2. 写产品文档到 docs/(PRD / 交互 / 验收),把需求拆成任务,每个任务的验收写成可观测信号(可见文本 / API 结果 / 交互结果)。
|
||||
@@ -24,7 +28,8 @@
|
||||
knowledgeRefs 写入任务;不要派发 candidate 或全量知识库。
|
||||
4. 把任务写进 docs/ack/tasks.yaml(只有你写),校验 tasks.yaml 和 knowledge.yaml。
|
||||
5. 先把「产品文档 + 任务拆分 + 验收信号 + 适用知识引用」给我确认,不要急着派发。
|
||||
6. 我确认后,按 ack 闭环循环:先按 docs/ack/project.md 校验 Developer/Test worker 启动命令,
|
||||
6. 我确认后,按 ack 闭环循环:先用 `scripts/launch_worker.py` 校验结构化
|
||||
Developer/Test profile,审阅 plan 后用 expected fingerprint 创建 fresh worker,
|
||||
dispatch 开发 → worker_done → dispatch 测试独立复测 → 你读证据终检 → 回写 tasks.yaml;
|
||||
每个任务最多三轮,三轮不过记 leftover 并升级我复盘。
|
||||
```
|
||||
@@ -45,6 +50,9 @@ python3 <ack-skill-dir>/scripts/validate_tasks.py docs/ack/tasks.yaml
|
||||
python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml --tasks docs/ack/tasks.yaml
|
||||
```
|
||||
|
||||
`project.orchestration` 是 worker profile 的机器 SSOT;未知字段、非 allowlist 模型、
|
||||
不匹配的角色/档位和非安全权限必须在派发前 fail closed。
|
||||
|
||||
5. **停下来给人确认**:这是强模型该花时间的地方,不要跳过。
|
||||
|
||||
---
|
||||
@@ -58,26 +66,58 @@ python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml --
|
||||
|
||||
---
|
||||
|
||||
## 第 3 步:复用或启动 worker
|
||||
## 第 3 步:审阅并启动 worker
|
||||
|
||||
先读 `docs/ack/project.md` 的项目覆盖规则,再按 `orca-adapter.md` §「解析并复用 worker」执行:先列出现有终端和活跃任务,优先复用同 worktree、同角色、配置兼容且空闲的 worker。只有没有可复用项时才创建新终端;如果项目提供 `validate_worker_command.py`,校验未通过时不得创建。
|
||||
先读 `tasks.yaml.project.orchestration` 的结构化 profiles。v0.10 不把历史 receipt
|
||||
或既有 Orca terminal 当作自动复用授权;每次自动派发都创建 fresh worker。原因和
|
||||
边界见 `model-routing.md` §「Receipt、审计与复用边界」。
|
||||
|
||||
先查看目标 profile hash,确认本次结构化配置。这个 hash 只用于审计和漂移比较,
|
||||
不能用于匹配或复用旧 receipt / 既有终端:
|
||||
|
||||
```bash
|
||||
# Codex 示例(模型与执行模式以项目覆盖层为准)
|
||||
DEV_CMD='codex --dangerously-bypass-approvals-and-sandbox -m gpt-5.6-terra -c model_reasoning_effort=medium'
|
||||
TEST_CMD='codex --dangerously-bypass-approvals-and-sandbox -m gpt-5.6-luna -c model_reasoning_effort=low'
|
||||
python3 <ack-skill-dir>/scripts/validate_worker_command.py --role developer --command "$DEV_CMD"
|
||||
python3 <ack-skill-dir>/scripts/validate_worker_command.py --role test --command "$TEST_CMD"
|
||||
orca terminal create --worktree active --command "$DEV_CMD" --title "ACK-DEV-CODEX-TERRA-1" --json
|
||||
orca terminal create --worktree active --command "$TEST_CMD" --title "ACK-TEST-CODEX-LUNA-1" --json
|
||||
|
||||
# Cursor 示例
|
||||
CURSOR_CMD='cursor-agent --yolo --model auto'
|
||||
python3 <ack-skill-dir>/scripts/validate_worker_command.py --role developer --command "$CURSOR_CMD"
|
||||
orca terminal create --worktree active --command "$CURSOR_CMD" --title "ACK-DEV-CURSOR-AUTO-1" --json
|
||||
python3 <ack-skill-dir>/scripts/launch_worker.py profile-hash \
|
||||
--project-root <project-root> --profile-id <profile-id>
|
||||
```
|
||||
|
||||
上面的 `terminal create` 都是“没有空闲兼容 worker”时的兜底,不是每次 kickoff 的固定动作。新 worktree 时先 `orca worktree create --name <feature> --base-branch <base>`,再解析该 worktree 的 worker。选型与升级见 `model-routing.md`。新建终端后、dispatch 前必须读取启动信息,确认实际 CLI、模型和 reasoning effort;派发后把实际 handle 写入对应任务的 `dispatch.worker`。
|
||||
生成只读计划:
|
||||
|
||||
```bash
|
||||
python3 <ack-skill-dir>/scripts/launch_worker.py plan \
|
||||
--project-root <project-root> --task-id <task-id> \
|
||||
--attempt-id <task-id>-A<round> --role <developer-or-test> \
|
||||
--profile-id <profile-id> --worktree <absolute-worktree> --slot <1..99>
|
||||
```
|
||||
|
||||
审阅计划后执行唯一创建入口:
|
||||
|
||||
```bash
|
||||
python3 <ack-skill-dir>/scripts/launch_worker.py launch \
|
||||
--project-root <project-root> --task-id <task-id> \
|
||||
--attempt-id <task-id>-A<round> --role <developer-or-test> \
|
||||
--profile-id <profile-id> --worktree <absolute-worktree> --slot <1..99> \
|
||||
--expected-launch-fingerprint <plan 中的 sha256:...>
|
||||
```
|
||||
|
||||
`launch` 的 fingerprint 必须来自刚才审阅的 plan;若中间任何关键事实漂移,重新
|
||||
plan,不得覆盖检查。标准输出是单一 JSON。Coordinator 校验其中的 receipt 后,把
|
||||
receipt 原样追加到 `tasks.yaml.workerReceipts`,把 `receipt.id` 写入该任务
|
||||
`dispatch.<role>.receiptId`、把 `profileId` 写入 `dispatch.<role>.profileId`,并把
|
||||
本轮稳定 ID 写入 `dispatch.<role>.attemptId`。三者必须与 receipt 的当前
|
||||
task/role/profile/attempt 完全绑定;不得把旧任务或旧轮次的 receipt 改挂到当前
|
||||
dispatch。后续
|
||||
紧随其后的本次 Orca dispatch 只使用这次 fresh receipt binding 中的 handle;持久化
|
||||
receipt 仅用于审计,不能在后续轮次自动复用。不要自行拼装 Agent CLI 或 Orca 的
|
||||
底层创建命令。
|
||||
|
||||
新 worktree 时可先运行
|
||||
`orca worktree create --name <feature> --base-branch <base> --json`,再对返回的绝对
|
||||
worktree 走同一套 `plan` -> 带 expected fingerprint 的 `launch`。在调用 launcher
|
||||
前,先把新
|
||||
路径加入 `project.orchestration.allowedWorktrees` 并重新运行任务板校验。profile
|
||||
只允许 `read-only` 或 `workspace-write`;v0.10 的 full-access 授权通道尚未实现,
|
||||
任何 bypass、YOLO/force 或关闭 sandbox 的请求都必须失败,不能手写命令兜底。
|
||||
选型与升级见 `model-routing.md`。
|
||||
|
||||
---
|
||||
|
||||
@@ -85,6 +125,7 @@ orca terminal create --worktree active --command "$CURSOR_CMD" --title "ACK-DEV-
|
||||
|
||||
```text
|
||||
task-create → dispatch 给 DEV → 等 worker_done
|
||||
→ 每个角色都通过 plan + expected fingerprint launch fresh worker 并写回 receipt
|
||||
→ 每轮使用 Coordinator 分配的稳定 <task-id>-A<round>
|
||||
→ 回写 fixed_by_dev → dispatch 给 TEST 复测 → 等 retest_result
|
||||
→ Developer 回 knowledgeApplied / knowledgeCandidates,Test 回 knowledgeChecks
|
||||
@@ -110,6 +151,8 @@ Coordinator 只内联本轮 `knowledgeRefs` 指向的少量知识,不要求 wo
|
||||
|
||||
## 一句话
|
||||
|
||||
产品文档 + 验收信号写在前(你,强模型)→ 确认显式 `knowledgeRefs` → 按项目
|
||||
覆盖层校验并启动 DEV/TEST → 核对实际模型 → dispatch / 复测 / 终检循环 → 任务
|
||||
结论落 `tasks.yaml`,验证后的跨任务知识由 Coordinator 落 `knowledge.yaml`。
|
||||
产品文档 + 验收信号写在前(你,强模型)→ 确认显式 `knowledgeRefs` → 从
|
||||
`tasks.yaml.project.orchestration` 解析安全 profile → 审阅 plan 并用 expected
|
||||
fingerprint 启动 fresh DEV/TEST → dispatch / 复测 / 终检循环 → 任务结论落
|
||||
`tasks.yaml`,验证后的
|
||||
跨任务知识由 Coordinator 落 `knowledge.yaml`。
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
# 模型路由(稳定核心)
|
||||
|
||||
本文件是**三角色默认模型档位**和**升级规则**的单一事实源(SSOT)。目标:在不牺牲质量的前提下降低 token 和模型成本——把昂贵的强模型留给需要判断的工作,把机械执行交给较弱模型。
|
||||
本文件是**三角色默认模型档位、升级规则和 worker profile 选择规则**的单一规范源。
|
||||
目标是在不牺牲质量的前提下降低 token 和模型成本:把昂贵的强模型留给需要判断的
|
||||
工作,把机械执行交给较弱模型。
|
||||
|
||||
角色定义见 `roles-and-permissions.md`(Coordinator 编排 / Test 验证 / Developer 实现)。本文件只补一层正交的「用哪个档位的模型」。
|
||||
角色定义见 `roles-and-permissions.md`(Coordinator 编排 / Test 验证 / Developer
|
||||
实现)。本文件只补一层正交的「用哪个档位、以什么安全 profile 启动 worker」。
|
||||
|
||||
---
|
||||
|
||||
@@ -14,7 +17,9 @@
|
||||
| Test | 中低模型 | 按既定验收信号执行浏览器/API/脚本,主要做观察、记录、逐条 pass/fail |
|
||||
| Developer | 中低模型(按任务升级) | 多数实现可照规格执行;跨系统、数据迁移、重复失败时再升级 |
|
||||
|
||||
关键点:Coordinator 用强模型但**不亲自跑测试**(测试由 Test 承担),所以强模型的 token 花在思考和终检上,而不是反复点击页面、跑 smoke、复制日志。这一分工天然省 token,同时保持「验证者 ≠ 实现者」。
|
||||
Coordinator 用强模型但**不亲自跑测试**(测试由 Test 承担),所以强模型的 token
|
||||
花在思考和终检上,而不是反复点击页面、跑 smoke、复制日志。这一分工天然省 token,
|
||||
同时保持「验证者 != 实现者」。
|
||||
|
||||
---
|
||||
|
||||
@@ -51,67 +56,183 @@
|
||||
- 需要设计新的抽象。
|
||||
- 低档位反复产出表面修复。
|
||||
|
||||
升级动作本身由 Coordinator 判断并记录(可写进 `tasks.yaml` 的 `dispatch` 备注或 `resolution`)。
|
||||
升级由 Coordinator 判断并记录原因。升级不是修改一个正在运行的终端:必须选择
|
||||
对应强档 profile,重新计算 `profileHash`,再通过 launcher 创建新的 worker。
|
||||
v0.10 不自动复用持久化 receipt 指向的旧终端,Test 也不得使用 Developer 的强档
|
||||
worker。
|
||||
|
||||
---
|
||||
|
||||
## 如何给 worker 指定模型(编排相关)
|
||||
## 结构化 worker profile
|
||||
|
||||
**模型不由编排层设置。** Orca 的 `orchestration task-create` / `dispatch` 没有 `--model` 参数——`dispatch` 只是把任务投递给一个已存在的终端 handle。**模型在创建 worker 终端、启动 agent CLI 时用 CLI 自带的 flag 固定**,之后该终端的所有 dispatch 都用这个模型。
|
||||
### 机器事实源
|
||||
|
||||
**默认不跨 Agent CLI 创建 worker。** Coordinator 应按自己所在的运行环境选择同类 worker:Cursor 会话创建 `cursor-agent` worker,Codex 会话创建 `codex` worker。不要依赖 Coordinator 凭模型回答来猜测运行环境或精确模型名;以实际 CLI / 终端环境为准。只有项目 overlay 或用户明确指定跨 Agent 时,才混用不同 CLI,并记录原因。
|
||||
worker 路由的机器可读事实只保存在 `docs/ack/tasks.yaml` 的
|
||||
`project.orchestration`。`project.md` 可以解释项目为何选某个档位,但不能另写一份
|
||||
完整启动命令或覆盖机器配置。字段结构以 `templates/tasks.schema.json` 为准。
|
||||
|
||||
因此「档位 → 具体模型」的映射是 **agent 相关** 的,落地方式见 `orca-adapter.md` §「给 worker 终端固定模型」。常见 CLI:
|
||||
每个 profile 明确声明:
|
||||
|
||||
| Agent CLI | 指定模型的方式 |
|
||||
|-----------|----------------|
|
||||
| Cursor (`cursor-agent`) | `cursor-agent --model <model>`;`--model auto` 让 Cursor 自动选(推荐给 Test/Developer worker) |
|
||||
| Codex (`codex`) | `codex -m <model> -c model_reasoning_effort=<effort>` |
|
||||
| 其它(opencode 等) | 用各自 CLI 的模型参数或配置 |
|
||||
- `role`:`developer` 或 `test`;
|
||||
- `cli`:受支持的 Agent CLI;
|
||||
- `tier`:角色模型档位;
|
||||
- `model`:项目 allowlist 中的精确模型 ID;
|
||||
- `reasoningEffort`:CLI 支持时显式声明;
|
||||
- `permissionMode`:只允许 `read-only` 或 `workspace-write`。
|
||||
|
||||
执行模式也必须在创建 worker 时固定,并服从项目覆盖层或用户的明确要求。Cursor 的 YOLO 参数是 `--yolo`;Codex 的等价参数是 `--dangerously-bypass-approvals-and-sandbox`。不要把 Cursor 的 `--yolo` 原样传给 Codex,也不要把裸启动 `codex` 当作“自动选择了正确角色模型”。如果项目提供 worker 命令校验脚本,校验通过是 `terminal create` 的前置条件。
|
||||
`project.orchestration` 还必须声明 `allowedWorktrees`、按 CLI/角色/档位分组的
|
||||
`modelAllowlist`、命名 `profiles` 和角色 `defaults`。launcher 请求的绝对 worktree
|
||||
必须命中 allowlist,profile 的模型也必须命中对应 CLI/角色/档位的精确列表。
|
||||
|
||||
**ACK 对 Cursor 的默认建议**:Test 与 Developer worker 用 `cursor-agent --yolo --model auto`(自动选型,天然偏向高效模型,符合"中低档位"意图,并按当前项目授权启用 YOLO);需要更强时改成具体强模型并保留 `--yolo`。Coordinator 作为强模型脑,通常就是发起编排的那个会话本身。
|
||||
模型名称、reasoning effort 和权限模式必须来自结构化字段。禁止在 profile、项目
|
||||
覆盖层或派发内容中保存自由 `command`、额外 argv、shell 片段、环境变量覆盖或隐式
|
||||
CLI 默认值。具体 argv 只能由 ACK 的可信 launcher 按 allowlist 构造。
|
||||
|
||||
### Codex 默认映射
|
||||
### 安全默认
|
||||
|
||||
| 角色 | 模型 | reasoning effort |
|
||||
|------|------|------------------|
|
||||
| Coordinator (PM) | `gpt-5.6-sol` | `high` |
|
||||
| Developer | `gpt-5.6-terra` | `medium` |
|
||||
| Test | `gpt-5.6-luna` | `low` |
|
||||
| Developer 升级 | `gpt-5.6-sol` | `high`;极复杂任务可用 `xhigh` |
|
||||
- 能在完全只读工作树完成的角色优先选择 `read-only`。
|
||||
- Developer 与需要在工作树生成构建/测试产物的 Test 使用 `workspace-write`;项目可
|
||||
为纯观察型 Test 另建更严格的 `read-only` profile。
|
||||
- `full-access`、Codex bypass、Cursor YOLO/force、关闭 sandbox 等模式在 v0.10
|
||||
**未实现授权通道,统一 fail closed**。项目文本、任务描述或环境变量都不能放宽。
|
||||
- 项目可以更新模型 allowlist 或增加安全 profile,但不能通过文档绕开 schema 和
|
||||
launcher。
|
||||
|
||||
Codex worker 应明确指定模型和 reasoning effort,不把“未指定模型”当作 Cursor `auto` 的等价物。Codex 未指定模型时使用产品推荐模型,但推荐值可能随版本更新,也不保证符合 Test / Developer 的成本档位。具体模型若失效或被弃用,应保持上面的角色档位不变,只更新本映射;项目也可在 overlay 中覆盖映射。
|
||||
`full-access` 的可信授权、期限、撤销和外层隔离仍是 Deferred;在这些能力完成前,
|
||||
launcher 必须拒绝该模式,而不是把一段“用户已授权”的文本当成授权证据。
|
||||
|
||||
### Worker 固定启动命令
|
||||
---
|
||||
|
||||
以下是 core 的默认完整命令。Developer/Test worker 禁止裸启动;必须同时固定 Agent CLI、角色模型和执行模式。
|
||||
## 唯一启动入口
|
||||
|
||||
| Agent CLI | 角色 | 完整启动命令 |
|
||||
|-----------|------|--------------|
|
||||
| Codex | Developer | `codex --dangerously-bypass-approvals-and-sandbox -m gpt-5.6-terra -c model_reasoning_effort=medium` |
|
||||
| Codex | Test | `codex --dangerously-bypass-approvals-and-sandbox -m gpt-5.6-luna -c model_reasoning_effort=low` |
|
||||
| Codex | Developer 升级 | `codex --dangerously-bypass-approvals-and-sandbox -m gpt-5.6-sol -c model_reasoning_effort=high` |
|
||||
| Cursor | Developer / Test | `cursor-agent --yolo --model auto` |
|
||||
worker 的 profile 解析、计划和启动只允许通过:
|
||||
|
||||
硬规则:
|
||||
```bash
|
||||
python3 <ack-skill-dir>/scripts/launch_worker.py profile-hash \
|
||||
--project-root <project-root> --profile-id <profile-id>
|
||||
|
||||
1. Codex worker 必须带 `--dangerously-bypass-approvals-and-sandbox`;Cursor worker 必须带 `--yolo`。
|
||||
2. 禁止用裸 `codex`、裸 `cursor-agent` 或只指定 CLI 而不指定角色模型。
|
||||
3. 创建终端前先运行项目提供的 worker 命令校验器;校验失败时不得执行 `orca terminal create`。
|
||||
4. 创建终端后、dispatch 前读取启动信息,确认实际 CLI、模型和 reasoning effort 与命令一致。
|
||||
5. Developer 升级到强模型必须由 Coordinator 记录原因;Test 不得复用 Developer 的强模型终端。
|
||||
6. YOLO 会跳过确认和沙箱,只能在用户已明确授权、外层运行环境受控时启用;项目覆盖层可以收紧执行模式,不能静默放宽。
|
||||
python3 <ack-skill-dir>/scripts/launch_worker.py plan \
|
||||
--project-root <project-root> --task-id <task-id> \
|
||||
--attempt-id <task-id>-A<round> --role <developer-or-test> \
|
||||
--profile-id <profile-id> --worktree <absolute-worktree> [--slot <1..99>]
|
||||
|
||||
项目可以覆盖模型名称或收紧权限,但必须在 `project.md` 中写出完整替代命令,不能只写“auto”“默认模型”或依赖当前 CLI 的隐式默认值。
|
||||
python3 <ack-skill-dir>/scripts/launch_worker.py launch \
|
||||
--project-root <project-root> --task-id <task-id> \
|
||||
--attempt-id <task-id>-A<round> --role <developer-or-test> \
|
||||
--profile-id <profile-id> --worktree <absolute-worktree> [--slot <1..99>] \
|
||||
--expected-launch-fingerprint <plan 中的 sha256:...>
|
||||
```
|
||||
|
||||
`--project-root` 始终指向保存权威 `docs/ack/tasks.yaml` 的项目根;`--worktree` 是本次
|
||||
worker 实际工作的绝对路径,两者可以不同。后者仍必须属于同一项目并命中
|
||||
`project.orchestration.allowedWorktrees`。
|
||||
|
||||
- `profile-hash` 读取 `tasks.yaml` 中的 profile,输出确定性 hash。
|
||||
- `plan` 只校验并展示将使用的结构化计划,不创建终端。
|
||||
- `launch` 是唯一允许创建 worker 的入口;它要求传入刚审阅的
|
||||
`plan.launchFingerprint`,当前 profile、任务/attempt、worktree、CLI 或 argv
|
||||
任一事实漂移都会在创建前失败。它内部固定 Orca 调用和 Agent argv,标准输出只有
|
||||
一份 JSON 结果。
|
||||
- launcher 的内部 bootstrap 属于私有协议,Coordinator 不直接调用、不复制到项目
|
||||
文档,也不自行拼装 Orca 的底层终端创建文本。
|
||||
|
||||
Coordinator 在执行 `launch` 前必须先审阅 `plan`。任何校验失败、字段未知、路径不
|
||||
匹配、CLI 不可用或权限模式不支持,都停止创建并回报,不回退到裸 Agent 命令。
|
||||
|
||||
---
|
||||
|
||||
## 可执行信任边界
|
||||
|
||||
v0.10 的 launcher 还执行以下约束:
|
||||
|
||||
- 不读取调用者传入的 `PATH` 来寻找 `git`、Orca 或 Agent CLI,只在固定的系统与
|
||||
当前用户工具目录中解析受支持的可执行文件;候选目标必须由 root/当前用户拥有、
|
||||
不是 group/other writable,并在 plan 与 bootstrap 间保持相同 device/inode。
|
||||
- Git、Orca 和 CLI version probe 使用不含供应商凭据的控制环境;worker 使用
|
||||
`per-cli-allowlist-v1`,Codex 只取得 Codex 所需凭据,Cursor 只取得 Cursor 凭据,
|
||||
不跨 CLI 透传。`DBUS_*`、`DISPLAY`、`WAYLAND_DISPLAY`、`XDG_RUNTIME_DIR`、
|
||||
`LD_*`、`PYTHON*`、`NODE_OPTIONS`、`CODEX_HOME`、`GIT_*` 和调用者 PATH 均不会
|
||||
透传给 worker。
|
||||
- worktree 除了命中项目 allowlist、属于同一 Git common-dir,还必须精确出现在
|
||||
`git worktree list --porcelain -z` 中;`.git` symlink 冒充的目录会失败。
|
||||
- Orca 的 `--command` 只含固定 bootstrap 与随机 launch ID。终端创建后,父进程先
|
||||
绑定 runtime/handle/incarnation/worktree,再通过该终端 stdin 发送一次性 nonce;
|
||||
bootstrap 校验 nonce 后才允许启动 Agent CLI,并把 child PID 与 nonce/launch
|
||||
fingerprint 绑定的 proof 原子写入 ready 状态;父进程看到并核对 proof 后才把
|
||||
worker 视为可派发。
|
||||
- `orca terminal create` 一经发出,在取得确定 handle 前遇到超时、transport/解码
|
||||
异常、进程中断、非零退出、畸形 JSON 或缺字段,一律标记为 `indeterminate`,
|
||||
禁止自动重试。
|
||||
- 取得 handle 后若任一绑定或握手检查失败,launcher 会先用
|
||||
`orca terminal close --tab --json` 请求持久关闭,并严格核对 runtime、handle、
|
||||
tab 和 close mode。只有关闭回执完整匹配才记为普通 `failed`;关闭无法确认时保持
|
||||
`indeterminate/reconcile-required`,调用方必须先人工核对,不能直接重试。
|
||||
- bootstrap 在真正启动 Agent 前,会在同一 launch-record 锁内再次确认状态仍为
|
||||
`awaiting-authorization` 且没有 cleanup;父进程一旦先进入 cleanup/reconcile,
|
||||
bootstrap 必须拒绝 `Popen`,不能让取消后的 workspace-write worker 继续启动。
|
||||
- 异常路径的 launch record 写入失败不能阻断上述关闭动作,也不能遮蔽原始
|
||||
`indeterminate`。即使关闭已确认,只要最终状态无法可靠持久化,也保守返回
|
||||
`indeterminate/reconcile-required`。
|
||||
|
||||
这些约束防止任务板、任务文本和普通环境变量变成第二个启动命令入口。它们不抵御已经
|
||||
完全控制 Coordinator 用户账户、Orca 运行时、受信 Agent/Orca 可执行文件或
|
||||
Coordinator 用户级 Agent 配置/插件/MCP 的攻击者;该场景需要独立 OS 身份、受控
|
||||
Agent 配置或平台签发能力。`permissionMode` 约束的是 Agent CLI 的 shell/文件系统
|
||||
执行模式,不是模型供应商或外部工具能力的 attestation。
|
||||
|
||||
---
|
||||
|
||||
## Receipt、审计与复用边界
|
||||
|
||||
成功启动返回的 JSON 含一个可校验 receipt。Coordinator 把 receipt 追加到
|
||||
`tasks.yaml` 顶层 `workerReceipts`,并把它的 `id` 写入当前任务
|
||||
`dispatch.<role>.receiptId`、把 `profileId` 写入 `dispatch.<role>.profileId`,同时把
|
||||
本次 `<task-id>-A<round>` 写入 `dispatch.<role>.attemptId`。
|
||||
|
||||
这三个字段不是可任意组合的历史索引。任务板校验器要求被引用 receipt 的
|
||||
`createdFor.taskId` 等于当前 ACK `tasks[].id`,`createdFor.role` 等于 dispatch
|
||||
角色,`profileId` 完全相同,且 `createdFor.attemptId` 等于
|
||||
`dispatch.<role>.attemptId`。`receiptId: null` 时 `attemptId` 必须同为 `null`;
|
||||
`receiptId` 非空时 `attemptId` 必须存在并命中本轮。`dispatch.<role>.taskId` 是 Orca
|
||||
运行时 task ID,不能拿来替代 ACK task ID 或稳定 attempt ID。
|
||||
|
||||
receipt 至少把以下事实绑定在一起:
|
||||
|
||||
- `profileHash` 与请求的角色、模型、effort、权限和精确 argv;
|
||||
- `slot`,确保 fingerprint 也绑定审阅过的 worker 槽位和派生标题;
|
||||
- 任务、attempt、绝对 worktree 及 path/device/inode/git common-dir 等稳定身份;
|
||||
- Orca `runtimeId`、terminal `handle` 和 `incarnationId`;
|
||||
- 创建时间、launcher 指纹和 `receiptHash`。
|
||||
|
||||
`receiptHash` 是无密钥的规范 JSON checksum,只能发现意外漂移或未同步修改,**不是
|
||||
launcher 身份证明,也不是复用授权**。项目内有写权限的一方可以修改 receipt 后重算
|
||||
hash;而当前 Orca metadata 又不能证明终端最初执行的命令、模型和权限。因此 v0.10
|
||||
明确禁止根据持久化 receipt 自动复用既有终端:每次需要自动派发 worker,都重新走
|
||||
`plan` → 带 expected fingerprint 的 `launch`,只使用该次 launcher 标准输出中的
|
||||
fresh handle 完成本次派发。
|
||||
|
||||
`launchFingerprint` 是确定性的完整计划漂移校验,不是一次性授权或幂等键。同一份
|
||||
计划重复执行 `launch` 会创建新的 fresh terminal;成功后不得用同一 fingerprint
|
||||
再次启动。若结果不确定,必须先 reconcile,不能把重跑当作恢复方式。未来需要自动
|
||||
防重放时,应由 Orca/ACP 的原子 idempotency/claim 能力或项目外可信 launch intent
|
||||
提供,而不是把 checksum 冒充成一次性令牌。
|
||||
|
||||
持久化 receipt 仍用于审计、dispatch 关联和检测配置漂移;标题、preview、分支名、
|
||||
worker 自报或单独的 Orca live metadata 都不能把旧终端提升为可信 worker。未来只有
|
||||
在 Orca/ACP 提供启动参数 attestation,或存在项目外可信签发与校验通道后,才开放
|
||||
自动复用。CLI / 模型变更仍需更新 allowlist 并重新生成 receipt。
|
||||
|
||||
`ackVersion` 必须使用合法 SemVer。`0.10.0` 及以后版本的任务板必须同时存在
|
||||
`project.orchestration` 与顶层 `workerReceipts`;其中任一字段出现,另一个也必须
|
||||
出现,避免 schema-only 校验把半迁移任务板误判为可自动派发。
|
||||
|
||||
---
|
||||
|
||||
## 成本原则
|
||||
|
||||
强模型产出高密度、可复用的产物:需求、架构决策、验收信号、任务拆分、失败复盘。
|
||||
中低模型消费这些产物,产出可核对的执行证据:测试结果、快照、API 响应、构建日志、改动文件清单。
|
||||
中低模型消费这些产物,产出可核对的执行证据:测试结果、快照、API 响应、构建日志、
|
||||
改动文件清单。
|
||||
|
||||
这样把昂贵推理挡在重复执行之外。
|
||||
|
||||
@@ -119,4 +240,6 @@ Codex worker 应明确指定模型和 reasoning effort,不把“未指定模
|
||||
|
||||
## 一句话
|
||||
|
||||
Coordinator 是脑,Test 是眼,Developer 是手。脑用最强的模型且不做机械测试,眼和手用便宜模型,只有常规闭环卡住时才升级。
|
||||
Coordinator 是脑,Test 是眼,Developer 是手。脑用强模型且不做机械测试,眼和手
|
||||
默认用安全、较低成本的结构化 profile;只有常规闭环卡住时才升级,并且所有 worker
|
||||
都必须经 launcher 产生可核对的 receipt。
|
||||
|
||||
@@ -23,79 +23,83 @@ orca orchestration task-list --json
|
||||
orca orchestration inbox --limit 20 --json
|
||||
```
|
||||
|
||||
确认:Orca runtime 可达;Coordinator、Developer、Test 三个终端都存在;Developer 在正确 worktree;当前没有冲突的活跃编排任务。
|
||||
确认:Orca runtime 可达并取得本次 `runtimeId`;Coordinator 终端存在;当前没有冲突
|
||||
的活跃编排任务。Developer/Test worker 必须通过本次 launcher 新建,不要求预先存在。
|
||||
|
||||
---
|
||||
|
||||
## 解析并复用 worker
|
||||
## 既有 worker 的处理
|
||||
|
||||
**默认复用,创建是兜底。** 每次向 Developer 或 Test 派发前都先解析 worker,不能直接照后文示例执行 `terminal create`:
|
||||
|
||||
1. 运行 `orca terminal list --json`,筛选 `connected=true`、`writable=true`、目标 `worktreePath` 一致、标题角色和配置一致的终端。稳定标题使用 `ACK-<ROLE>-<CLI>-<TIER>-<N>`,例如 `ACK-DEV-CODEX-TERRA-1`、`ACK-TEST-CODEX-LUNA-1`、`ACK-DEV-CURSOR-AUTO-1`;强档示例为 `ACK-DEV-CODEX-SOL-1`。
|
||||
2. 运行 `orca orchestration task-list --status dispatched --json`。若候选 handle 出现在活跃任务的 `assignee_handle`,视为 busy,不复用;不要仅凭终端预览文本或最近输出时间猜忙闲。
|
||||
3. 对空闲候选运行 `orca terminal show --terminal <handle> --json`,确认仍存活且 worktree、角色和 Agent CLI / 模型档位符合本次要求。符合就直接把该 handle 作为 `dispatch --to` 的目标。
|
||||
4. 只有不存在兼容的空闲候选时才创建。若同角色同配置已有 busy worker,使用下一个未占用序号,例如 `ACK-DEV-CODEX-TERRA-2`;不要创建另一个同名终端。
|
||||
5. dispatch 成功后,把实际 handle 写入对应 `tasks.yaml` 条目的 `dispatch.worker`。下一轮先尝试该 handle;若它已消失、断开、不可写、worktree/角色/档位不兼容或正忙,再回到第 1 步选择其它 worker。
|
||||
|
||||
`tasks.yaml.dispatch.worker` 记录“这个任务实际用了谁”,便于续跑和审计;`orca terminal list` 记录“谁现在还活着”,是运行时存活状态的事实源。不要另建一份永久 worker 池,因为终端关闭后其中的 handle 会过期。
|
||||
|
||||
复用决策:
|
||||
v0.10 **不自动复用任何既有终端**。`receiptHash` 是无密钥 checksum,Orca 当前的
|
||||
terminal metadata 也不包含原始 Agent argv、模型或权限 attestation;两者即使完全
|
||||
匹配,仍不足以证明旧终端由可信 launcher 以目标 profile 创建。标题、preview、分支
|
||||
名或 worker 自报更不能作为证明。
|
||||
|
||||
| 情况 | 动作 |
|
||||
|------|------|
|
||||
| 任务已记录 worker,且该 handle 存活、兼容、空闲 | 优先复用原 handle |
|
||||
| 有其它同 worktree、同角色、兼容的空闲 worker | 复用该 handle |
|
||||
| 同角色 worker 存在但都 busy | 创建下一编号的临时并发 worker |
|
||||
| worker 已关闭、断开或不存在 | 创建新 worker |
|
||||
| 模型升级或 worktree 不同 | 不复用不兼容 worker,创建对应档位/路径的新 worker |
|
||||
| 历史 receipt 与 live metadata 看似完全匹配 | 只作审计,不自动复用;重新 launch |
|
||||
| 没有 receipt,或只有同名终端 | 不复用;重新 launch |
|
||||
| 需要并发 worker | 使用下一 `slot` 重新 launch |
|
||||
| Developer 升级模型档位 | 选择强档安全 profile,重新 plan/launch |
|
||||
|
||||
只有刚刚成功返回的 `launch` 标准输出中的 fresh handle,才可用于紧随其后的本次
|
||||
dispatch。未来若 Orca/ACP 增加启动参数 attestation,或 ACK 接入项目外可信签发
|
||||
通道,再单独设计自动复用;不能用项目文档声明跳过这条限制。
|
||||
|
||||
---
|
||||
|
||||
## 给 worker 终端固定模型
|
||||
## 通过 launcher 创建 worker
|
||||
|
||||
**编排层不设模型。** `task-create` / `dispatch` 都没有 `--model`;`dispatch --to <handle>` 只是把任务投给一个已存在的终端,用的是那个终端里 agent 会话启动时的模型。要固定模型,就在 **创建 worker 终端** 时用 agent CLI 的模型参数:
|
||||
**模型不由 Orca dispatch 设置。** `task-create` / `dispatch` 只投递任务;Agent CLI、
|
||||
模型、reasoning effort 和权限由 `tasks.yaml.project.orchestration` 的结构化 profile
|
||||
决定,并且只能由 ACK launcher 构造。
|
||||
|
||||
默认沿用 Coordinator 当前所在的 Agent CLI:Cursor Coordinator 创建 Cursor worker,Codex Coordinator 创建 Codex worker。不要通过询问模型来猜运行环境;以当前 CLI / 终端环境为准。除非项目 overlay 或用户明确指定,否则不跨 Agent CLI 创建 worker。
|
||||
先只读生成计划:
|
||||
|
||||
```bash
|
||||
# Cursor:Test / Developer worker 用 auto 模型,并按项目要求启用 YOLO
|
||||
orca terminal create --worktree path:<dev_worktree> \
|
||||
--command "cursor-agent --yolo --model auto" --title "ACK-DEV-CURSOR-AUTO-1" --json
|
||||
|
||||
# 需要更强模型时改成具体模型
|
||||
orca terminal create --worktree path:<dev_worktree> \
|
||||
--command "cursor-agent --yolo --model claude-opus-4-8-thinking-high" --title "ACK-DEV-CURSOR-STRONG-1" --json
|
||||
|
||||
# Codex:Developer worker
|
||||
orca terminal create --worktree path:<dev_worktree> \
|
||||
--command "codex --dangerously-bypass-approvals-and-sandbox -m gpt-5.6-terra -c model_reasoning_effort=medium" \
|
||||
--title "ACK-DEV-CODEX-TERRA-1" --json
|
||||
|
||||
# Codex:Test worker
|
||||
orca terminal create --worktree path:<test_worktree> \
|
||||
--command "codex --dangerously-bypass-approvals-and-sandbox -m gpt-5.6-luna -c model_reasoning_effort=low" \
|
||||
--title "ACK-TEST-CODEX-LUNA-1" --json
|
||||
|
||||
# Codex:复杂 Developer 任务升级
|
||||
orca terminal create --worktree path:<dev_worktree> \
|
||||
--command "codex --dangerously-bypass-approvals-and-sandbox -m gpt-5.6-sol -c model_reasoning_effort=high" \
|
||||
--title "ACK-DEV-CODEX-SOL-1" --json
|
||||
python3 <ack-skill-dir>/scripts/launch_worker.py plan \
|
||||
--project-root <project-root> --task-id <task-id> \
|
||||
--attempt-id <task-id>-A<round> --role <developer-or-test> \
|
||||
--profile-id <profile-id> --worktree <absolute-worktree> --slot <1..99>
|
||||
```
|
||||
|
||||
这些创建命令只在复用流程找不到空闲兼容 worker 时执行。拿到返回的 handle 后再 `task-create` + `dispatch --to <handle>`,并把 handle 写入 `tasks.yaml` 的 `dispatch.worker`。模型档位与选型策略见 `model-routing.md`。
|
||||
|
||||
需要隔离/并行时,先建新 worktree 再在其中起 worker(是否新建见 `closed-loop.md` §「子任务放哪」):
|
||||
确认计划中的任务、attempt、角色、profile hash、绝对 worktree 和安全权限模式后,再
|
||||
执行唯一启动入口:
|
||||
|
||||
```bash
|
||||
orca worktree create --name <feature> --base-branch <base> --json
|
||||
orca terminal create --worktree path:<new_worktree> --command "cursor-agent --yolo --model auto" --title "ACK-DEV-CURSOR-AUTO-1" --json
|
||||
python3 <ack-skill-dir>/scripts/launch_worker.py launch \
|
||||
--project-root <project-root> --task-id <task-id> \
|
||||
--attempt-id <task-id>-A<round> --role <developer-or-test> \
|
||||
--profile-id <profile-id> --worktree <absolute-worktree> --slot <1..99> \
|
||||
--expected-launch-fingerprint <plan 中的 sha256:...>
|
||||
```
|
||||
|
||||
- `cursor-agent --list-models` 可列出合法模型;`auto` 表示由 Cursor 自动选型。
|
||||
- Codex 用 `-m / --model` 指定模型,用 `-c model_reasoning_effort=<effort>` 固定推理档位;不要把省略 `-m` 当作 Cursor `auto` 的等价物。
|
||||
- 本项目要求 Cursor worker 带 `--yolo`,Codex worker 带 `--dangerously-bypass-approvals-and-sandbox`;创建前先运行项目覆盖层指定的校验器。
|
||||
- 其它 CLI(opencode 等)用各自的模型参数或配置,`--command` 相应替换。
|
||||
- 若 worker 是已在跑的会话(用 `--inject` 投递),模型已由该会话启动时决定,无法在 dispatch 时改;要换模型需新建终端。
|
||||
`launch` 必须携带刚才审阅的 `plan.launchFingerprint`;若 profile、任务/attempt、
|
||||
worktree、CLI identity/version 或精确 argv 漂移,创建前就失败。它内部调用当前
|
||||
Orca 后端并完成终端绑定;Coordinator 不自行执行或拼装 Orca 的底层终端创建文本,
|
||||
也不直接调用 launcher 的私有 bootstrap。
|
||||
若 launcher 报告 `indeterminate` 或 `reconcile required`,说明终端可能已经创建且
|
||||
持久关闭尚未得到可信确认;此时先按返回的 launch ID/record 和 Orca live state
|
||||
人工核对,禁止直接重复执行 `launch`。
|
||||
标准输出是单一 JSON。成功后:
|
||||
|
||||
1. 校验返回 receipt 的 `receiptHash`;
|
||||
2. 把 receipt 原样追加到 `tasks.yaml.workerReceipts`;
|
||||
3. 把 `receipt.id` 写入当前任务 `dispatch.<role>.receiptId`,把 `profileId` 写入
|
||||
`dispatch.<role>.profileId`,把本轮 `<task-id>-A<round>` 写入
|
||||
`dispatch.<role>.attemptId`;校验器会要求 receipt 的 task/role/profile/attempt
|
||||
与当前 ACK 任务完全一致;
|
||||
4. 再用 receipt binding 中的 handle 创建/派发 Orca 子任务。
|
||||
|
||||
profile 只允许 `read-only` 或 `workspace-write`。v0.10 不提供可信的 full-access 授权
|
||||
通道;bypass、YOLO/force、关闭 sandbox 及其它 full-access 请求必须由 schema 和
|
||||
launcher fail closed,不能改用手写命令兜底。模型档位与升级规则见
|
||||
`model-routing.md`。
|
||||
|
||||
需要隔离/并行时,可以先用 Orca 创建 worktree。Coordinator 必须先把返回的绝对
|
||||
路径加入 `project.orchestration.allowedWorktrees` 并重新校验 `tasks.yaml`,然后才在
|
||||
该路径上走 `plan` -> 带 expected fingerprint 的 `launch`,并把 receipt 留作审计。
|
||||
既有会话可以由用户直接操作,但不能进入 ACK v0.10 的自动派发信任路径。
|
||||
|
||||
---
|
||||
|
||||
|
||||
Executable
+1636
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,7 @@ from yaml_subset import (
|
||||
load_yaml_subset,
|
||||
make_unique_pyyaml_loader,
|
||||
)
|
||||
from worker_profiles import validate_routing_document
|
||||
|
||||
STATUS_ENUM = {
|
||||
"open",
|
||||
@@ -46,7 +47,16 @@ MAX_ROUNDS = 3
|
||||
KNOWLEDGE_KINDS = {"guardrail", "pitfall", "verification"}
|
||||
KNOWLEDGE_CHECK_RESULTS = {"passed", "failed", "not_applicable"}
|
||||
KNOWLEDGE_REF_RE = re.compile(r"^K-[A-Z0-9][A-Z0-9-]*@[1-9][0-9]*$")
|
||||
TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
ATTEMPT_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*-A[1-9][0-9]*$")
|
||||
SEMVER_RE = re.compile(
|
||||
r"^(0|[1-9][0-9]*)\."
|
||||
r"(0|[1-9][0-9]*)\."
|
||||
r"(0|[1-9][0-9]*)"
|
||||
r"(?:-(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)"
|
||||
r"(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?"
|
||||
r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$"
|
||||
)
|
||||
KNOWLEDGE_SCOPE_FIELDS = {
|
||||
"components",
|
||||
"paths",
|
||||
@@ -76,6 +86,14 @@ KNOWLEDGE_CHECK_FIELDS = {
|
||||
"checkedBy",
|
||||
"checkedAt",
|
||||
}
|
||||
DISPATCH_FIELDS = {
|
||||
"taskId",
|
||||
"dispatchId",
|
||||
"worker",
|
||||
"developer",
|
||||
"test",
|
||||
"rounds",
|
||||
}
|
||||
KNOWLEDGE_CANDIDATE_REQUIRED_FIELDS = {
|
||||
"kind",
|
||||
"title",
|
||||
@@ -461,6 +479,22 @@ def validate_builtin(data: dict) -> list[str]:
|
||||
"project.knowledgeFile 必须固定为 docs/ack/knowledge.yaml"
|
||||
)
|
||||
|
||||
ack_version = data.get("ackVersion")
|
||||
version_match = SEMVER_RE.fullmatch(ack_version) if isinstance(ack_version, str) else None
|
||||
if "ackVersion" in data and version_match is None:
|
||||
errors.append("ackVersion 必须是合法 SemVer(例如 0.10.0)")
|
||||
routing_required = (
|
||||
(isinstance(project, dict) and "orchestration" in project)
|
||||
or "workerReceipts" in data
|
||||
or (
|
||||
version_match is not None
|
||||
and (int(version_match.group(1)), int(version_match.group(2)))
|
||||
>= (0, 10)
|
||||
)
|
||||
)
|
||||
if routing_required:
|
||||
errors.extend(validate_routing_document(data))
|
||||
|
||||
if "summary" in data:
|
||||
summary = data["summary"]
|
||||
if not isinstance(summary, dict):
|
||||
@@ -494,6 +528,10 @@ def validate_builtin(data: dict) -> list[str]:
|
||||
errors.append(f"{where}: id 必须是非空字符串")
|
||||
else:
|
||||
where = f"tasks[{i}] {tid}"
|
||||
if routing_required and TASK_ID_RE.fullmatch(tid) is None:
|
||||
errors.append(
|
||||
f"{where}: v0.10 自动路由 id 只允许字母、数字、点、下划线和连字符"
|
||||
)
|
||||
if tid in seen_ids:
|
||||
errors.append(f"{where}: id 重复")
|
||||
seen_ids.add(tid)
|
||||
@@ -533,6 +571,12 @@ def validate_builtin(data: dict) -> list[str]:
|
||||
dispatch = {}
|
||||
else:
|
||||
dispatch = task["dispatch"]
|
||||
reject_unknown_fields(
|
||||
dispatch,
|
||||
DISPATCH_FIELDS,
|
||||
f"{where}.dispatch",
|
||||
errors,
|
||||
)
|
||||
validate_string_fields(
|
||||
dispatch,
|
||||
{"taskId", "dispatchId", "worker"},
|
||||
|
||||
@@ -1,171 +1,30 @@
|
||||
#!/usr/bin/env python3
|
||||
"""校验 Music Pilot 的 Developer/Test worker 启动命令。
|
||||
"""Fail-closed migration shim for ACK's removed free-form command validator.
|
||||
|
||||
用法:
|
||||
python3 validate_worker_command.py --role developer --command '<command>'
|
||||
python3 validate_worker_command.py --role test --command '<command>'
|
||||
python3 validate_worker_command.py --role developer --upgraded --command '<command>'
|
||||
python3 validate_worker_command.py --self-test
|
||||
|
||||
退出码:0 通过 / 1 规则不通过 / 2 用法或命令解析错误。
|
||||
ACK v0.10 no longer accepts a shell command as worker configuration. Keep this
|
||||
file only so older documentation or automation fails with a precise migration
|
||||
message instead of silently retaining the unsafe path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shlex
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
CODEX_YOLO = "--dangerously-bypass-approvals-and-sandbox"
|
||||
CURSOR_YOLO = "--yolo"
|
||||
|
||||
|
||||
def option_value(tokens: list[str], *names: str) -> str | None:
|
||||
for index, token in enumerate(tokens):
|
||||
for name in names:
|
||||
if token == name and index + 1 < len(tokens):
|
||||
return tokens[index + 1]
|
||||
prefix = f"{name}="
|
||||
if token.startswith(prefix):
|
||||
return token[len(prefix) :]
|
||||
return None
|
||||
|
||||
|
||||
def codex_effort(tokens: list[str]) -> str | None:
|
||||
configs: list[str] = []
|
||||
for index, token in enumerate(tokens):
|
||||
if token in {"-c", "--config"} and index + 1 < len(tokens):
|
||||
configs.append(tokens[index + 1])
|
||||
elif token.startswith("--config="):
|
||||
configs.append(token.split("=", 1)[1])
|
||||
for config in configs:
|
||||
if config.startswith("model_reasoning_effort="):
|
||||
return config.split("=", 1)[1].strip('"\'')
|
||||
return None
|
||||
|
||||
|
||||
def validate(role: str, command: str, upgraded: bool = False) -> list[str]:
|
||||
try:
|
||||
tokens = shlex.split(command)
|
||||
except ValueError as exc:
|
||||
return [f"命令无法解析:{exc}"]
|
||||
if not tokens:
|
||||
return ["启动命令不能为空"]
|
||||
|
||||
executable = Path(tokens[0]).name
|
||||
errors: list[str] = []
|
||||
|
||||
if upgraded and role != "developer":
|
||||
errors.append("只有 Developer 可以使用 --upgraded")
|
||||
|
||||
if executable == "codex":
|
||||
if CODEX_YOLO not in tokens:
|
||||
errors.append(f"Codex worker 必须包含 {CODEX_YOLO}")
|
||||
|
||||
model = option_value(tokens, "-m", "--model")
|
||||
effort = codex_effort(tokens)
|
||||
if upgraded:
|
||||
expected_model = "gpt-5.6-sol"
|
||||
allowed_efforts = {"high", "xhigh"}
|
||||
elif role == "developer":
|
||||
expected_model = "gpt-5.6-terra"
|
||||
allowed_efforts = {"medium"}
|
||||
else:
|
||||
expected_model = "gpt-5.6-luna"
|
||||
allowed_efforts = {"low"}
|
||||
|
||||
if model != expected_model:
|
||||
errors.append(
|
||||
f"Codex {role} 模型应为 {expected_model},实际为 {model or '未指定'}"
|
||||
MIGRATION_MESSAGE = (
|
||||
"ACK v0.10 已停用自由 worker command 校验器;请在 "
|
||||
"docs/ack/tasks.yaml 的 project.orchestration 中声明 profile,并仅调用 "
|
||||
"scripts/launch_worker.py profile-hash|plan|launch。"
|
||||
)
|
||||
if effort not in allowed_efforts:
|
||||
expected = "/".join(sorted(allowed_efforts))
|
||||
errors.append(
|
||||
f"Codex {role} reasoning effort 应为 {expected},实际为 {effort or '未指定'}"
|
||||
)
|
||||
elif executable == "cursor-agent":
|
||||
if upgraded:
|
||||
errors.append("Cursor worker 不使用 Codex --upgraded 映射")
|
||||
if CURSOR_YOLO not in tokens:
|
||||
errors.append(f"Cursor worker 必须显式包含 {CURSOR_YOLO}")
|
||||
model = option_value(tokens, "--model")
|
||||
if model != "auto":
|
||||
errors.append(f"Cursor {role} 模型应为 auto,实际为 {model or '未指定'}")
|
||||
else:
|
||||
errors.append(f"不支持的 worker CLI:{executable};只允许 codex 或 cursor-agent")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def run_self_test() -> int:
|
||||
cases = [
|
||||
(
|
||||
"codex developer",
|
||||
"developer",
|
||||
f"codex {CODEX_YOLO} -m gpt-5.6-terra -c model_reasoning_effort=medium",
|
||||
False,
|
||||
True,
|
||||
),
|
||||
(
|
||||
"codex test",
|
||||
"test",
|
||||
f"codex {CODEX_YOLO} -m gpt-5.6-luna -c model_reasoning_effort=low",
|
||||
False,
|
||||
True,
|
||||
),
|
||||
(
|
||||
"codex upgraded developer",
|
||||
"developer",
|
||||
f"codex {CODEX_YOLO} -m gpt-5.6-sol -c model_reasoning_effort=high",
|
||||
True,
|
||||
True,
|
||||
),
|
||||
("cursor worker", "test", "cursor-agent --yolo --model auto", False, True),
|
||||
("naked codex", "developer", "codex", False, False),
|
||||
(
|
||||
"wrong codex role model",
|
||||
"test",
|
||||
f"codex {CODEX_YOLO} -m gpt-5.6-terra -c model_reasoning_effort=medium",
|
||||
False,
|
||||
False,
|
||||
),
|
||||
("cursor without yolo", "developer", "cursor-agent --model auto", False, False),
|
||||
]
|
||||
failures: list[str] = []
|
||||
for name, role, command, upgraded, expected_pass in cases:
|
||||
passed = not validate(role, command, upgraded)
|
||||
if passed != expected_pass:
|
||||
failures.append(name)
|
||||
if failures:
|
||||
sys.stderr.write("worker 命令校验器自测失败:" + ", ".join(failures) + "\n")
|
||||
return 1
|
||||
print(f"worker 命令校验器自测通过:{len(cases)} 项")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="校验 Developer/Test worker 启动命令")
|
||||
parser.add_argument("--role", choices=("developer", "test"))
|
||||
parser.add_argument("--command")
|
||||
parser.add_argument("--upgraded", action="store_true", help="校验升级后的 Codex Developer")
|
||||
parser.add_argument("--self-test", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.self_test:
|
||||
return run_self_test()
|
||||
if not args.role or not args.command:
|
||||
parser.error("非自测模式必须同时提供 --role 和 --command")
|
||||
|
||||
errors = validate(args.role, args.command, args.upgraded)
|
||||
if errors:
|
||||
sys.stderr.write("worker 启动命令校验失败:\n")
|
||||
for error in errors:
|
||||
sys.stderr.write(f" - {error}\n")
|
||||
return 1
|
||||
print(f"worker 启动命令校验通过:role={args.role}")
|
||||
arguments = list(sys.argv[1:] if argv is None else argv)
|
||||
if arguments == ["--self-test"]:
|
||||
print("legacy worker command 路径已 fail-closed")
|
||||
return 0
|
||||
sys.stderr.write(MIGRATION_MESSAGE + "\n")
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,992 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Strict, zero-dependency primitives for ACK worker routing.
|
||||
|
||||
This module deliberately validates structured data rather than accepting a
|
||||
shell command. It does not invoke Orca, resolve executables, inspect Git, or
|
||||
write ``tasks.yaml``. A launcher can use the validated profile and the exact
|
||||
argv renderer below, then persist its externally observed facts as a receipt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
PROFILE_VERSION = 1
|
||||
RECEIPT_VERSION = 1
|
||||
LAUNCH_PROTOCOL_VERSION = 1
|
||||
MAX_ROUNDS = 3
|
||||
|
||||
ROLES = frozenset({"developer", "test"})
|
||||
CLIS = frozenset({"codex", "cursor-agent"})
|
||||
TIERS = frozenset({"standard", "strong"})
|
||||
REASONING_EFFORTS = frozenset({"low", "medium", "high", "xhigh"})
|
||||
PERMISSION_MODES = frozenset({"read-only", "workspace-write"})
|
||||
ORCHESTRATION_MODES = frozenset({"orca", "manual"})
|
||||
DEFAULT_KEYS = frozenset({"developer", "test", "developerUpgraded"})
|
||||
|
||||
ORCHESTRATION_FIELDS = frozenset(
|
||||
{
|
||||
"profileVersion",
|
||||
"mode",
|
||||
"allowedWorktrees",
|
||||
"modelAllowlist",
|
||||
"profiles",
|
||||
"defaults",
|
||||
}
|
||||
)
|
||||
PROFILE_FIELDS = frozenset(
|
||||
{
|
||||
"role",
|
||||
"cli",
|
||||
"tier",
|
||||
"model",
|
||||
"reasoningEffort",
|
||||
"permissionMode",
|
||||
}
|
||||
)
|
||||
RECEIPT_FIELDS = frozenset(
|
||||
{
|
||||
"receiptVersion",
|
||||
"id",
|
||||
"launchId",
|
||||
"profileId",
|
||||
"profileHash",
|
||||
"launchFingerprint",
|
||||
"slot",
|
||||
"createdFor",
|
||||
"worktree",
|
||||
"requested",
|
||||
"binding",
|
||||
"createdAt",
|
||||
"receiptHash",
|
||||
}
|
||||
)
|
||||
CREATED_FOR_FIELDS = frozenset({"taskId", "attemptId", "role"})
|
||||
WORKTREE_FIELDS = frozenset(
|
||||
{
|
||||
"path",
|
||||
"device",
|
||||
"inode",
|
||||
"gitCommonDir",
|
||||
"gitCommonDevice",
|
||||
"gitCommonInode",
|
||||
}
|
||||
)
|
||||
REQUESTED_FIELDS = frozenset(
|
||||
{
|
||||
"cli",
|
||||
"tier",
|
||||
"model",
|
||||
"reasoningEffort",
|
||||
"permissionMode",
|
||||
"executable",
|
||||
"executableDevice",
|
||||
"executableInode",
|
||||
"cliVersion",
|
||||
"argv",
|
||||
"argvHash",
|
||||
"environmentPolicy",
|
||||
}
|
||||
)
|
||||
BINDING_FIELDS = frozenset(
|
||||
{
|
||||
"orchestrator",
|
||||
"runtimeId",
|
||||
"handle",
|
||||
"incarnationId",
|
||||
"observedWorktreePath",
|
||||
"connected",
|
||||
"writable",
|
||||
"boundAt",
|
||||
}
|
||||
)
|
||||
ROLE_DISPATCH_FIELDS = frozenset(
|
||||
{"profileId", "receiptId", "attemptId", "taskId", "dispatchId"}
|
||||
)
|
||||
|
||||
PROFILE_ID_RE = re.compile(r"^[a-z][a-z0-9-]{1,63}$")
|
||||
MODEL_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/+@-]{0,127}$")
|
||||
RECEIPT_ID_RE = re.compile(r"^WR-[0-9a-f]{64}$")
|
||||
LAUNCH_ID_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
ATTEMPT_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*-A[1-3]$")
|
||||
SHA256_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||
|
||||
|
||||
def _canonical_json(value: Any) -> str:
|
||||
"""Return the one JSON representation used by all hashes in this module."""
|
||||
|
||||
try:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
)
|
||||
except (RecursionError, TypeError, ValueError) as exc:
|
||||
raise ValueError(f"value is not canonical JSON data: {exc}") from exc
|
||||
|
||||
|
||||
def canonical_sha256(value: Any) -> str:
|
||||
"""Hash canonical UTF-8 JSON and return ``sha256:<lowercase hex>``."""
|
||||
|
||||
digest = hashlib.sha256(_canonical_json(value).encode("utf-8")).hexdigest()
|
||||
return f"sha256:{digest}"
|
||||
|
||||
|
||||
def _unknown_fields(value: dict[str, Any], allowed: frozenset[str], where: str) -> list[str]:
|
||||
unknown = set(value) - allowed
|
||||
return [
|
||||
f"{where}: unknown field {field!r}"
|
||||
for field in sorted(unknown, key=repr)
|
||||
]
|
||||
|
||||
|
||||
def _missing_fields(value: dict[str, Any], required: frozenset[str], where: str) -> list[str]:
|
||||
return [f"{where}: missing field {field!r}" for field in sorted(required - set(value))]
|
||||
|
||||
|
||||
def _is_nonempty_text(value: Any, *, max_length: int = 1024) -> bool:
|
||||
return (
|
||||
isinstance(value, str)
|
||||
and bool(value.strip())
|
||||
and len(value) <= max_length
|
||||
and all(ord(character) >= 32 and ord(character) != 127 for character in value)
|
||||
)
|
||||
|
||||
|
||||
def _is_absolute_safe_path(value: Any) -> bool:
|
||||
if not _is_nonempty_text(value, max_length=4096):
|
||||
return False
|
||||
path = Path(value)
|
||||
return (
|
||||
path.is_absolute()
|
||||
and value != os.path.sep
|
||||
and not value.startswith("//")
|
||||
and ".." not in path.parts
|
||||
and os.path.normpath(value) == value
|
||||
)
|
||||
|
||||
|
||||
def _is_nonnegative_int(value: Any) -> bool:
|
||||
return isinstance(value, int) and not isinstance(value, bool) and value >= 0
|
||||
|
||||
|
||||
def _is_positive_int(value: Any) -> bool:
|
||||
return isinstance(value, int) and not isinstance(value, bool) and value > 0
|
||||
|
||||
|
||||
def _is_timestamp(value: Any) -> bool:
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return False
|
||||
return parsed.tzinfo is not None
|
||||
|
||||
|
||||
def validate_profile(profile: Any, *, where: str = "profile") -> list[str]:
|
||||
"""Validate one strict worker profile without consulting its allowlist."""
|
||||
|
||||
if not isinstance(profile, dict):
|
||||
return [f"{where}: must be an object"]
|
||||
|
||||
errors = _unknown_fields(profile, PROFILE_FIELDS, where)
|
||||
errors.extend(_missing_fields(profile, PROFILE_FIELDS, where))
|
||||
|
||||
role = profile.get("role")
|
||||
cli = profile.get("cli")
|
||||
tier = profile.get("tier")
|
||||
model = profile.get("model")
|
||||
effort = profile.get("reasoningEffort")
|
||||
permission = profile.get("permissionMode")
|
||||
|
||||
if not isinstance(role, str) or role not in ROLES:
|
||||
errors.append(f"{where}.role: must be developer/test")
|
||||
if not isinstance(cli, str) or cli not in CLIS:
|
||||
errors.append(f"{where}.cli: must be codex/cursor-agent")
|
||||
if not isinstance(tier, str) or tier not in TIERS:
|
||||
errors.append(f"{where}.tier: must be standard/strong")
|
||||
if not isinstance(model, str) or MODEL_ID_RE.fullmatch(model) is None:
|
||||
errors.append(f"{where}.model: must be a safe model ID")
|
||||
if not isinstance(permission, str) or permission not in PERMISSION_MODES:
|
||||
errors.append(
|
||||
f"{where}.permissionMode: must be read-only/workspace-write"
|
||||
)
|
||||
|
||||
if cli == "codex":
|
||||
if not isinstance(effort, str) or effort not in REASONING_EFFORTS:
|
||||
errors.append(
|
||||
f"{where}.reasoningEffort: Codex requires low/medium/high/xhigh"
|
||||
)
|
||||
elif cli == "cursor-agent" and effort is not None:
|
||||
errors.append(f"{where}.reasoningEffort: Cursor requires null")
|
||||
|
||||
if role == "test" and tier != "standard":
|
||||
errors.append(f"{where}.tier: Test may only use standard")
|
||||
if tier == "strong" and role != "developer":
|
||||
errors.append(f"{where}.tier: strong may only be used by Developer")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def _validate_model_allowlist(value: Any, where: str) -> list[str]:
|
||||
if not isinstance(value, dict):
|
||||
return [f"{where}: must be an object"]
|
||||
|
||||
errors: list[str] = []
|
||||
for cli in sorted(set(value) - CLIS, key=repr):
|
||||
errors.append(f"{where}: unknown CLI {cli!r}")
|
||||
for cli, roles in value.items():
|
||||
cli_where = f"{where}.{cli}"
|
||||
if cli not in CLIS:
|
||||
continue
|
||||
if not isinstance(roles, dict):
|
||||
errors.append(f"{cli_where}: must be an object")
|
||||
continue
|
||||
if not roles:
|
||||
errors.append(f"{cli_where}: must not be empty")
|
||||
for role in sorted(set(roles) - ROLES, key=repr):
|
||||
errors.append(f"{cli_where}: unknown role {role!r}")
|
||||
for role, tiers in roles.items():
|
||||
role_where = f"{cli_where}.{role}"
|
||||
if role not in ROLES:
|
||||
continue
|
||||
if not isinstance(tiers, dict):
|
||||
errors.append(f"{role_where}: must be an object")
|
||||
continue
|
||||
if not tiers:
|
||||
errors.append(f"{role_where}: must not be empty")
|
||||
for tier in sorted(set(tiers) - TIERS, key=repr):
|
||||
errors.append(f"{role_where}: unknown tier {tier!r}")
|
||||
if role == "test" and "strong" in tiers:
|
||||
errors.append(f"{role_where}: Test cannot define a strong allowlist")
|
||||
for tier, models in tiers.items():
|
||||
tier_where = f"{role_where}.{tier}"
|
||||
if tier not in TIERS:
|
||||
continue
|
||||
if not isinstance(models, list) or not models:
|
||||
errors.append(f"{tier_where}: must be a non-empty model list")
|
||||
continue
|
||||
seen: set[str] = set()
|
||||
for index, model in enumerate(models):
|
||||
item_where = f"{tier_where}[{index}]"
|
||||
if not isinstance(model, str) or MODEL_ID_RE.fullmatch(model) is None:
|
||||
errors.append(f"{item_where}: must be a safe model ID")
|
||||
elif model in seen:
|
||||
errors.append(f"{item_where}: duplicate model {model!r}")
|
||||
else:
|
||||
seen.add(model)
|
||||
return errors
|
||||
|
||||
|
||||
def _allowed_models(
|
||||
allowlist: Any,
|
||||
cli: Any,
|
||||
role: Any,
|
||||
tier: Any,
|
||||
) -> list[str] | None:
|
||||
if not isinstance(allowlist, dict):
|
||||
return None
|
||||
roles = allowlist.get(cli)
|
||||
if not isinstance(roles, dict):
|
||||
return None
|
||||
tiers = roles.get(role)
|
||||
if not isinstance(tiers, dict):
|
||||
return None
|
||||
models = tiers.get(tier)
|
||||
return models if isinstance(models, list) else None
|
||||
|
||||
|
||||
def validate_orchestration(
|
||||
orchestration: Any,
|
||||
*,
|
||||
where: str = "project.orchestration",
|
||||
) -> list[str]:
|
||||
"""Validate the complete strict routing configuration."""
|
||||
|
||||
if not isinstance(orchestration, dict):
|
||||
return [f"{where}: must be an object"]
|
||||
|
||||
errors = _unknown_fields(orchestration, ORCHESTRATION_FIELDS, where)
|
||||
errors.extend(_missing_fields(orchestration, ORCHESTRATION_FIELDS, where))
|
||||
|
||||
profile_version = orchestration.get("profileVersion")
|
||||
mode = orchestration.get("mode")
|
||||
allowed_worktrees = orchestration.get("allowedWorktrees")
|
||||
allowlist = orchestration.get("modelAllowlist")
|
||||
profiles = orchestration.get("profiles")
|
||||
defaults = orchestration.get("defaults")
|
||||
|
||||
if profile_version != PROFILE_VERSION or isinstance(profile_version, bool):
|
||||
errors.append(f"{where}.profileVersion: must be {PROFILE_VERSION}")
|
||||
if not isinstance(mode, str) or mode not in ORCHESTRATION_MODES:
|
||||
errors.append(f"{where}.mode: must be orca/manual")
|
||||
|
||||
if not isinstance(allowed_worktrees, list):
|
||||
errors.append(f"{where}.allowedWorktrees: must be a list")
|
||||
else:
|
||||
if mode == "orca" and not allowed_worktrees:
|
||||
errors.append(f"{where}.allowedWorktrees: Orca mode requires at least one path")
|
||||
seen_worktrees: set[str] = set()
|
||||
for index, worktree in enumerate(allowed_worktrees):
|
||||
item_where = f"{where}.allowedWorktrees[{index}]"
|
||||
if not _is_absolute_safe_path(worktree):
|
||||
errors.append(f"{item_where}: must be a safe absolute path other than root")
|
||||
elif worktree in seen_worktrees:
|
||||
errors.append(f"{item_where}: duplicate worktree {worktree!r}")
|
||||
else:
|
||||
seen_worktrees.add(worktree)
|
||||
|
||||
errors.extend(_validate_model_allowlist(allowlist, f"{where}.modelAllowlist"))
|
||||
|
||||
valid_profiles: dict[str, dict[str, Any]] = {}
|
||||
if not isinstance(profiles, dict):
|
||||
errors.append(f"{where}.profiles: must be an object")
|
||||
else:
|
||||
if mode == "orca" and not profiles:
|
||||
errors.append(f"{where}.profiles: Orca mode requires profiles")
|
||||
for profile_id, profile in profiles.items():
|
||||
profile_where = f"{where}.profiles.{profile_id}"
|
||||
if not isinstance(profile_id, str) or PROFILE_ID_RE.fullmatch(profile_id) is None:
|
||||
errors.append(f"{where}.profiles: invalid profile ID {profile_id!r}")
|
||||
continue
|
||||
profile_errors = validate_profile(profile, where=profile_where)
|
||||
errors.extend(profile_errors)
|
||||
if profile_errors or not isinstance(profile, dict):
|
||||
continue
|
||||
valid_profiles[profile_id] = profile
|
||||
models = _allowed_models(
|
||||
allowlist,
|
||||
profile.get("cli"),
|
||||
profile.get("role"),
|
||||
profile.get("tier"),
|
||||
)
|
||||
if profile.get("model") not in (models or []):
|
||||
errors.append(
|
||||
f"{profile_where}.model: {profile.get('model')!r} is not allowed "
|
||||
"for its cli/role/tier"
|
||||
)
|
||||
|
||||
if not isinstance(defaults, dict):
|
||||
errors.append(f"{where}.defaults: must be an object")
|
||||
else:
|
||||
for default_key in sorted(set(defaults) - DEFAULT_KEYS, key=repr):
|
||||
errors.append(f"{where}.defaults: unknown key {default_key!r}")
|
||||
if mode == "orca":
|
||||
for role in sorted(ROLES - set(defaults)):
|
||||
errors.append(f"{where}.defaults: missing role {role!r}")
|
||||
for default_key, profile_id in defaults.items():
|
||||
default_where = f"{where}.defaults.{default_key}"
|
||||
if default_key not in DEFAULT_KEYS:
|
||||
continue
|
||||
if not isinstance(profile_id, str) or PROFILE_ID_RE.fullmatch(profile_id) is None:
|
||||
errors.append(f"{default_where}: must be a profile ID")
|
||||
continue
|
||||
profile = valid_profiles.get(profile_id)
|
||||
if profile is None:
|
||||
errors.append(f"{default_where}: unknown or invalid profile {profile_id!r}")
|
||||
continue
|
||||
expected_role = "developer" if default_key == "developerUpgraded" else default_key
|
||||
expected_tier = "strong" if default_key == "developerUpgraded" else "standard"
|
||||
if profile.get("role") != expected_role:
|
||||
errors.append(f"{default_where}: profile role must be {expected_role}")
|
||||
if profile.get("tier") != expected_tier:
|
||||
errors.append(
|
||||
f"{default_where}: default profile must use {expected_tier} tier"
|
||||
)
|
||||
if profile.get("permissionMode") not in PERMISSION_MODES:
|
||||
errors.append(f"{default_where}: default profile has unsafe permissions")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def profile_hash(
|
||||
profile: dict[str, Any],
|
||||
*,
|
||||
profile_version: int = PROFILE_VERSION,
|
||||
) -> str:
|
||||
"""Return the canonical hash of a valid strict profile."""
|
||||
|
||||
errors = validate_profile(profile)
|
||||
if errors:
|
||||
raise ValueError("invalid profile: " + "; ".join(errors))
|
||||
if (
|
||||
not isinstance(profile_version, int)
|
||||
or isinstance(profile_version, bool)
|
||||
or profile_version < 1
|
||||
):
|
||||
raise ValueError("profile_version must be a positive integer")
|
||||
return canonical_sha256(
|
||||
{
|
||||
"profileVersion": profile_version,
|
||||
"profile": profile,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def render_worker_argv(
|
||||
profile: dict[str, Any],
|
||||
executable: str,
|
||||
worktree: str,
|
||||
) -> list[str]:
|
||||
"""Render the only argv shapes allowed by routing profile version 1."""
|
||||
|
||||
errors = validate_profile(profile)
|
||||
if errors:
|
||||
raise ValueError("invalid profile: " + "; ".join(errors))
|
||||
if not _is_absolute_safe_path(executable):
|
||||
raise ValueError("executable must be a safe absolute path other than root")
|
||||
if Path(executable).name != profile["cli"]:
|
||||
raise ValueError("executable basename must match profile.cli")
|
||||
if not _is_absolute_safe_path(worktree):
|
||||
raise ValueError("worktree must be a safe absolute path other than root")
|
||||
|
||||
model = profile["model"]
|
||||
permission = profile["permissionMode"]
|
||||
if profile["cli"] == "codex":
|
||||
return [
|
||||
executable,
|
||||
"--strict-config",
|
||||
"--model",
|
||||
model,
|
||||
"--config",
|
||||
f"model_reasoning_effort={profile['reasoningEffort']}",
|
||||
"--sandbox",
|
||||
permission,
|
||||
"--ask-for-approval",
|
||||
"never",
|
||||
"--cd",
|
||||
worktree,
|
||||
]
|
||||
|
||||
argv = [executable, "--model", model]
|
||||
if permission == "read-only":
|
||||
argv.extend(["--mode", "plan"])
|
||||
else:
|
||||
argv.append("--auto-review")
|
||||
argv.extend(["--sandbox", "enabled", "--workspace", worktree])
|
||||
return argv
|
||||
|
||||
|
||||
def receipt_hash(receipt: dict[str, Any]) -> str:
|
||||
"""Hash every receipt field except the self-referential ``receiptHash``."""
|
||||
|
||||
if not isinstance(receipt, dict):
|
||||
raise ValueError("receipt must be an object")
|
||||
payload = {key: value for key, value in receipt.items() if key != "receiptHash"}
|
||||
return canonical_sha256(payload)
|
||||
|
||||
|
||||
def _validate_created_for(value: Any, where: str) -> list[str]:
|
||||
if not isinstance(value, dict):
|
||||
return [f"{where}: must be an object"]
|
||||
errors = _unknown_fields(value, CREATED_FOR_FIELDS, where)
|
||||
errors.extend(_missing_fields(value, CREATED_FOR_FIELDS, where))
|
||||
task_id = value.get("taskId")
|
||||
attempt_id = value.get("attemptId")
|
||||
if not isinstance(task_id, str) or TASK_ID_RE.fullmatch(task_id) is None:
|
||||
errors.append(f"{where}.taskId: must be a safe task ID")
|
||||
if not isinstance(attempt_id, str) or ATTEMPT_ID_RE.fullmatch(attempt_id) is None:
|
||||
errors.append(f"{where}.attemptId: must use <task-id>-A1..A3")
|
||||
elif isinstance(task_id, str) and not attempt_id.startswith(f"{task_id}-A"):
|
||||
errors.append(f"{where}.attemptId: must belong to taskId")
|
||||
role = value.get("role")
|
||||
if not isinstance(role, str) or role not in ROLES:
|
||||
errors.append(f"{where}.role: must be developer/test")
|
||||
return errors
|
||||
|
||||
|
||||
def _validate_worktree_snapshot(value: Any, where: str) -> list[str]:
|
||||
if not isinstance(value, dict):
|
||||
return [f"{where}: must be an object"]
|
||||
errors = _unknown_fields(value, WORKTREE_FIELDS, where)
|
||||
errors.extend(_missing_fields(value, WORKTREE_FIELDS, where))
|
||||
for field in ("path", "gitCommonDir"):
|
||||
if not _is_absolute_safe_path(value.get(field)):
|
||||
errors.append(f"{where}.{field}: must be a safe absolute path other than root")
|
||||
for field in ("device", "gitCommonDevice"):
|
||||
if not _is_nonnegative_int(value.get(field)):
|
||||
errors.append(f"{where}.{field}: must be a non-negative integer")
|
||||
for field in ("inode", "gitCommonInode"):
|
||||
if not _is_positive_int(value.get(field)):
|
||||
errors.append(f"{where}.{field}: must be a positive integer")
|
||||
return errors
|
||||
|
||||
|
||||
def _validate_requested(value: Any, where: str) -> list[str]:
|
||||
if not isinstance(value, dict):
|
||||
return [f"{where}: must be an object"]
|
||||
errors = _unknown_fields(value, REQUESTED_FIELDS, where)
|
||||
errors.extend(_missing_fields(value, REQUESTED_FIELDS, where))
|
||||
cli = value.get("cli")
|
||||
tier = value.get("tier")
|
||||
permission = value.get("permissionMode")
|
||||
if not isinstance(cli, str) or cli not in CLIS:
|
||||
errors.append(f"{where}.cli: must be codex/cursor-agent")
|
||||
if not isinstance(tier, str) or tier not in TIERS:
|
||||
errors.append(f"{where}.tier: must be standard/strong")
|
||||
model = value.get("model")
|
||||
if not isinstance(model, str) or MODEL_ID_RE.fullmatch(model) is None:
|
||||
errors.append(f"{where}.model: must be a safe model ID")
|
||||
effort = value.get("reasoningEffort")
|
||||
if cli == "codex" and (
|
||||
not isinstance(effort, str) or effort not in REASONING_EFFORTS
|
||||
):
|
||||
errors.append(f"{where}.reasoningEffort: invalid Codex effort")
|
||||
if cli == "cursor-agent" and effort is not None:
|
||||
errors.append(f"{where}.reasoningEffort: Cursor requires null")
|
||||
if not isinstance(permission, str) or permission not in PERMISSION_MODES:
|
||||
errors.append(f"{where}.permissionMode: must be read-only/workspace-write")
|
||||
executable = value.get("executable")
|
||||
if not _is_absolute_safe_path(executable):
|
||||
errors.append(f"{where}.executable: must be a safe absolute path")
|
||||
elif isinstance(cli, str) and cli in CLIS and Path(executable).name != cli:
|
||||
errors.append(f"{where}.executable: basename must match cli")
|
||||
if not _is_nonnegative_int(value.get("executableDevice")):
|
||||
errors.append(f"{where}.executableDevice: must be a non-negative integer")
|
||||
if not _is_positive_int(value.get("executableInode")):
|
||||
errors.append(f"{where}.executableInode: must be a positive integer")
|
||||
if not _is_nonempty_text(value.get("cliVersion"), max_length=256):
|
||||
errors.append(f"{where}.cliVersion: must be non-empty single-line text")
|
||||
argv = value.get("argv")
|
||||
if (
|
||||
not isinstance(argv, list)
|
||||
or len(argv) < 2
|
||||
or any(not isinstance(arg, str) for arg in argv)
|
||||
):
|
||||
errors.append(f"{where}.argv: must be a string array with at least 2 items")
|
||||
argv_hash = value.get("argvHash")
|
||||
if not isinstance(argv_hash, str) or SHA256_RE.fullmatch(argv_hash) is None:
|
||||
errors.append(f"{where}.argvHash: must be a canonical sha256 hex digest")
|
||||
elif isinstance(argv, list) and all(isinstance(arg, str) for arg in argv):
|
||||
if argv_hash != canonical_sha256(argv):
|
||||
errors.append(f"{where}.argvHash: does not match argv")
|
||||
if value.get("environmentPolicy") != "per-cli-allowlist-v1":
|
||||
errors.append(
|
||||
f"{where}.environmentPolicy: must be 'per-cli-allowlist-v1'"
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
def _validate_binding(value: Any, where: str) -> list[str]:
|
||||
if not isinstance(value, dict):
|
||||
return [f"{where}: must be an object"]
|
||||
errors = _unknown_fields(value, BINDING_FIELDS, where)
|
||||
errors.extend(_missing_fields(value, BINDING_FIELDS, where))
|
||||
if value.get("orchestrator") != "orca":
|
||||
errors.append(f"{where}.orchestrator: must be orca")
|
||||
for field in ("runtimeId", "handle", "incarnationId"):
|
||||
if not _is_nonempty_text(value.get(field), max_length=512):
|
||||
errors.append(f"{where}.{field}: must be non-empty single-line text")
|
||||
if not _is_absolute_safe_path(value.get("observedWorktreePath")):
|
||||
errors.append(f"{where}.observedWorktreePath: must be a safe absolute path")
|
||||
for field in ("connected", "writable"):
|
||||
if value.get(field) is not True:
|
||||
errors.append(f"{where}.{field}: must be true")
|
||||
if not _is_timestamp(value.get("boundAt")):
|
||||
errors.append(f"{where}.boundAt: must be a timezone-aware ISO 8601 timestamp")
|
||||
return errors
|
||||
|
||||
|
||||
def validate_worker_receipt(
|
||||
receipt: Any,
|
||||
*,
|
||||
orchestration: dict[str, Any] | None = None,
|
||||
task_ids: set[str] | None = None,
|
||||
where: str = "workerReceipt",
|
||||
) -> list[str]:
|
||||
"""Validate one audit receipt's structure/checksums and routing references.
|
||||
|
||||
This is not origin authentication: ``receiptHash`` is unkeyed, so callers
|
||||
must never use this result alone to authorize reuse of an old terminal.
|
||||
"""
|
||||
|
||||
if not isinstance(receipt, dict):
|
||||
return [f"{where}: must be an object"]
|
||||
errors = _unknown_fields(receipt, RECEIPT_FIELDS, where)
|
||||
errors.extend(_missing_fields(receipt, RECEIPT_FIELDS, where))
|
||||
|
||||
version = receipt.get("receiptVersion")
|
||||
if version != RECEIPT_VERSION or isinstance(version, bool):
|
||||
errors.append(f"{where}.receiptVersion: must be {RECEIPT_VERSION}")
|
||||
for field in ("id", "launchId", "profileId"):
|
||||
value = receipt.get(field)
|
||||
matcher = {
|
||||
"id": RECEIPT_ID_RE,
|
||||
"launchId": LAUNCH_ID_RE,
|
||||
"profileId": PROFILE_ID_RE,
|
||||
}[field]
|
||||
if not isinstance(value, str) or matcher.fullmatch(value) is None:
|
||||
errors.append(f"{where}.{field}: invalid identifier")
|
||||
for field in ("profileHash", "launchFingerprint", "receiptHash"):
|
||||
value = receipt.get(field)
|
||||
if not isinstance(value, str) or SHA256_RE.fullmatch(value) is None:
|
||||
errors.append(f"{where}.{field}: must be a canonical sha256 hex digest")
|
||||
slot = receipt.get("slot")
|
||||
if not isinstance(slot, int) or isinstance(slot, bool) or not 1 <= slot <= 99:
|
||||
errors.append(f"{where}.slot: must be an integer from 1 to 99")
|
||||
receipt_id = receipt.get("id")
|
||||
launch_id = receipt.get("launchId")
|
||||
if (
|
||||
isinstance(receipt_id, str)
|
||||
and RECEIPT_ID_RE.fullmatch(receipt_id)
|
||||
and isinstance(launch_id, str)
|
||||
and LAUNCH_ID_RE.fullmatch(launch_id)
|
||||
and receipt_id != f"WR-{launch_id}"
|
||||
):
|
||||
errors.append(f"{where}.id: must equal 'WR-' + launchId")
|
||||
|
||||
created_for = receipt.get("createdFor")
|
||||
worktree = receipt.get("worktree")
|
||||
requested = receipt.get("requested")
|
||||
binding = receipt.get("binding")
|
||||
errors.extend(_validate_created_for(created_for, f"{where}.createdFor"))
|
||||
errors.extend(_validate_worktree_snapshot(worktree, f"{where}.worktree"))
|
||||
errors.extend(_validate_requested(requested, f"{where}.requested"))
|
||||
errors.extend(_validate_binding(binding, f"{where}.binding"))
|
||||
if not _is_timestamp(receipt.get("createdAt")):
|
||||
errors.append(f"{where}.createdAt: must be a timezone-aware ISO 8601 timestamp")
|
||||
|
||||
if isinstance(created_for, dict) and task_ids is not None:
|
||||
task_id = created_for.get("taskId")
|
||||
if isinstance(task_id, str) and task_id not in task_ids:
|
||||
errors.append(f"{where}.createdFor.taskId: unknown task {task_id!r}")
|
||||
|
||||
profile: dict[str, Any] | None = None
|
||||
if orchestration is not None and not isinstance(orchestration, dict):
|
||||
errors.append(f"{where}: orchestration must be an object")
|
||||
elif isinstance(orchestration, dict):
|
||||
profiles = orchestration.get("profiles")
|
||||
profile_id = receipt.get("profileId")
|
||||
candidate = profiles.get(profile_id) if (
|
||||
isinstance(profiles, dict) and isinstance(profile_id, str)
|
||||
) else None
|
||||
if isinstance(candidate, dict):
|
||||
profile = candidate
|
||||
else:
|
||||
errors.append(f"{where}.profileId: unknown profile {profile_id!r}")
|
||||
allowed = orchestration.get("allowedWorktrees")
|
||||
if isinstance(worktree, dict) and isinstance(allowed, list):
|
||||
if worktree.get("path") not in allowed:
|
||||
errors.append(f"{where}.worktree.path: is not in allowedWorktrees")
|
||||
|
||||
if profile is not None:
|
||||
try:
|
||||
expected_profile_hash = profile_hash(
|
||||
profile,
|
||||
profile_version=orchestration.get(
|
||||
"profileVersion",
|
||||
PROFILE_VERSION,
|
||||
),
|
||||
)
|
||||
except ValueError:
|
||||
errors.append(f"{where}.profileId: referenced profile is invalid")
|
||||
else:
|
||||
if receipt.get("profileHash") != expected_profile_hash:
|
||||
errors.append(f"{where}.profileHash: does not match profile")
|
||||
|
||||
if isinstance(created_for, dict) and created_for.get("role") != profile.get("role"):
|
||||
errors.append(f"{where}.createdFor.role: does not match profile")
|
||||
if isinstance(requested, dict):
|
||||
for field in (
|
||||
"cli",
|
||||
"tier",
|
||||
"model",
|
||||
"reasoningEffort",
|
||||
"permissionMode",
|
||||
):
|
||||
if requested.get(field) != profile.get(field):
|
||||
errors.append(f"{where}.requested.{field}: does not match profile")
|
||||
executable = requested.get("executable")
|
||||
worktree_path = worktree.get("path") if isinstance(worktree, dict) else None
|
||||
if isinstance(executable, str) and isinstance(worktree_path, str):
|
||||
try:
|
||||
expected_argv = render_worker_argv(profile, executable, worktree_path)
|
||||
except ValueError:
|
||||
errors.append(f"{where}.requested.argv: cannot render referenced profile")
|
||||
else:
|
||||
if requested.get("argv") != expected_argv:
|
||||
errors.append(f"{where}.requested.argv: does not match exact renderer")
|
||||
|
||||
if isinstance(binding, dict) and isinstance(worktree, dict):
|
||||
if binding.get("observedWorktreePath") != worktree.get("path"):
|
||||
errors.append(f"{where}.binding.observedWorktreePath: does not match worktree.path")
|
||||
|
||||
if (
|
||||
isinstance(created_for, dict)
|
||||
and isinstance(worktree, dict)
|
||||
and isinstance(requested, dict)
|
||||
):
|
||||
try:
|
||||
expected_fingerprint = canonical_sha256(
|
||||
{
|
||||
"protocolVersion": LAUNCH_PROTOCOL_VERSION,
|
||||
"backend": "orca",
|
||||
"profileId": receipt.get("profileId"),
|
||||
"profileHash": receipt.get("profileHash"),
|
||||
"createdFor": created_for,
|
||||
"worktree": worktree,
|
||||
"requested": requested,
|
||||
"slot": slot,
|
||||
}
|
||||
)
|
||||
except ValueError:
|
||||
errors.append(f"{where}.launchFingerprint: cannot hash launch facts")
|
||||
else:
|
||||
if receipt.get("launchFingerprint") != expected_fingerprint:
|
||||
errors.append(
|
||||
f"{where}.launchFingerprint: does not match launch facts"
|
||||
)
|
||||
|
||||
try:
|
||||
expected_receipt_hash = receipt_hash(receipt)
|
||||
except ValueError:
|
||||
errors.append(f"{where}: must contain canonical JSON data")
|
||||
else:
|
||||
if receipt.get("receiptHash") != expected_receipt_hash:
|
||||
errors.append(f"{where}.receiptHash: does not match receipt")
|
||||
return errors
|
||||
|
||||
|
||||
def validate_worker_receipts(
|
||||
receipts: Any,
|
||||
orchestration: dict[str, Any] | None = None,
|
||||
*,
|
||||
task_ids: set[str] | None = None,
|
||||
where: str = "workerReceipts",
|
||||
) -> list[str]:
|
||||
"""Validate the top-level receipt list and reject duplicate identities."""
|
||||
|
||||
if not isinstance(receipts, list):
|
||||
return [f"{where}: must be a list"]
|
||||
errors: list[str] = []
|
||||
seen_ids: set[str] = set()
|
||||
seen_launch_ids: set[str] = set()
|
||||
for index, receipt in enumerate(receipts):
|
||||
receipt_where = f"{where}[{index}]"
|
||||
errors.extend(
|
||||
validate_worker_receipt(
|
||||
receipt,
|
||||
orchestration=orchestration,
|
||||
task_ids=task_ids,
|
||||
where=receipt_where,
|
||||
)
|
||||
)
|
||||
if not isinstance(receipt, dict):
|
||||
continue
|
||||
receipt_id = receipt.get("id")
|
||||
if isinstance(receipt_id, str):
|
||||
if receipt_id in seen_ids:
|
||||
errors.append(f"{receipt_where}.id: duplicate receipt ID {receipt_id!r}")
|
||||
seen_ids.add(receipt_id)
|
||||
launch_id = receipt.get("launchId")
|
||||
if isinstance(launch_id, str):
|
||||
if launch_id in seen_launch_ids:
|
||||
errors.append(f"{receipt_where}.launchId: duplicate launch ID {launch_id!r}")
|
||||
seen_launch_ids.add(launch_id)
|
||||
return errors
|
||||
|
||||
|
||||
def _validate_dispatch_links(
|
||||
tasks: Any,
|
||||
receipts: Any,
|
||||
orchestration: Any,
|
||||
) -> list[str]:
|
||||
"""Cross-link role dispatch records to persisted audit receipts."""
|
||||
|
||||
if not isinstance(tasks, list):
|
||||
return []
|
||||
receipt_by_id: dict[str, dict[str, Any]] = {}
|
||||
if isinstance(receipts, list):
|
||||
for receipt in receipts:
|
||||
if not isinstance(receipt, dict):
|
||||
continue
|
||||
receipt_id = receipt.get("id")
|
||||
if isinstance(receipt_id, str) and receipt_id not in receipt_by_id:
|
||||
receipt_by_id[receipt_id] = receipt
|
||||
profiles = orchestration.get("profiles") if isinstance(orchestration, dict) else None
|
||||
|
||||
errors: list[str] = []
|
||||
for task_index, task in enumerate(tasks):
|
||||
if not isinstance(task, dict):
|
||||
continue
|
||||
ack_task_id = task.get("id")
|
||||
dispatch = task.get("dispatch")
|
||||
if not isinstance(dispatch, dict):
|
||||
continue
|
||||
for role in sorted(ROLES):
|
||||
if role not in dispatch:
|
||||
continue
|
||||
role_dispatch = dispatch.get(role)
|
||||
where = f"tasks[{task_index}].dispatch.{role}"
|
||||
if not isinstance(role_dispatch, dict):
|
||||
errors.append(f"{where}: must be an object")
|
||||
continue
|
||||
errors.extend(_unknown_fields(role_dispatch, ROLE_DISPATCH_FIELDS, where))
|
||||
errors.extend(_missing_fields(role_dispatch, ROLE_DISPATCH_FIELDS, where))
|
||||
|
||||
profile_id = role_dispatch.get("profileId")
|
||||
if profile_id is not None:
|
||||
if not isinstance(profile_id, str) or PROFILE_ID_RE.fullmatch(profile_id) is None:
|
||||
errors.append(f"{where}.profileId: must be null or a profile ID")
|
||||
else:
|
||||
profile = profiles.get(profile_id) if isinstance(profiles, dict) else None
|
||||
if not isinstance(profile, dict):
|
||||
errors.append(f"{where}.profileId: unknown profile {profile_id!r}")
|
||||
elif profile.get("role") != role:
|
||||
errors.append(f"{where}.profileId: profile role must be {role}")
|
||||
|
||||
runtime_values: list[Any] = []
|
||||
for field in ("taskId", "dispatchId"):
|
||||
runtime_value = role_dispatch.get(field)
|
||||
runtime_values.append(runtime_value)
|
||||
if runtime_value is not None and not _is_nonempty_text(
|
||||
runtime_value,
|
||||
max_length=512,
|
||||
):
|
||||
errors.append(
|
||||
f"{where}.{field}: must be null or non-empty single-line text"
|
||||
)
|
||||
if (runtime_values[0] is None) != (runtime_values[1] is None):
|
||||
errors.append(
|
||||
f"{where}: taskId and dispatchId must both be null or both be set"
|
||||
)
|
||||
|
||||
receipt_id = role_dispatch.get("receiptId")
|
||||
attempt_id = role_dispatch.get("attemptId")
|
||||
current_task_attempts = {
|
||||
f"{ack_task_id}-A{round_number}" for round_number in range(1, 4)
|
||||
}
|
||||
if attempt_id is not None:
|
||||
if (
|
||||
not isinstance(attempt_id, str)
|
||||
or ATTEMPT_ID_RE.fullmatch(attempt_id) is None
|
||||
):
|
||||
errors.append(
|
||||
f"{where}.attemptId: must be null or use <task-id>-A1..A3"
|
||||
)
|
||||
elif (
|
||||
isinstance(ack_task_id, str)
|
||||
and attempt_id not in current_task_attempts
|
||||
):
|
||||
errors.append(
|
||||
f"{where}.attemptId: must belong to current ACK task {ack_task_id!r}"
|
||||
)
|
||||
if receipt_id is None:
|
||||
if attempt_id is not None:
|
||||
errors.append(
|
||||
f"{where}.attemptId: must be null when receiptId is null"
|
||||
)
|
||||
if any(value is not None for value in runtime_values):
|
||||
errors.append(
|
||||
f"{where}.receiptId: is required before runtime dispatch IDs"
|
||||
)
|
||||
continue
|
||||
if attempt_id is None:
|
||||
errors.append(
|
||||
f"{where}.attemptId: is required when receiptId is set"
|
||||
)
|
||||
if not isinstance(receipt_id, str) or RECEIPT_ID_RE.fullmatch(receipt_id) is None:
|
||||
errors.append(f"{where}.receiptId: must be null or a receipt ID")
|
||||
continue
|
||||
receipt = receipt_by_id.get(receipt_id)
|
||||
if receipt is None:
|
||||
errors.append(f"{where}.receiptId: unknown receipt {receipt_id!r}")
|
||||
continue
|
||||
if receipt.get("profileId") != profile_id:
|
||||
errors.append(f"{where}.profileId: does not match referenced receipt")
|
||||
created_for = receipt.get("createdFor")
|
||||
receipt_task_id = (
|
||||
created_for.get("taskId") if isinstance(created_for, dict) else None
|
||||
)
|
||||
receipt_role = created_for.get("role") if isinstance(created_for, dict) else None
|
||||
receipt_attempt_id = (
|
||||
created_for.get("attemptId") if isinstance(created_for, dict) else None
|
||||
)
|
||||
if receipt_task_id != ack_task_id:
|
||||
errors.append(
|
||||
f"{where}.receiptId: referenced receipt task must be current "
|
||||
f"ACK task {ack_task_id!r}"
|
||||
)
|
||||
if receipt_role != role:
|
||||
errors.append(f"{where}.receiptId: referenced receipt role must be {role}")
|
||||
if receipt_attempt_id != attempt_id:
|
||||
errors.append(
|
||||
f"{where}.attemptId: does not match referenced receipt"
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
def validate_routing_document(data: Any) -> list[str]:
|
||||
"""Validate routing and receipts embedded in an ACK tasks document."""
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return ["<root>: must be an object"]
|
||||
project = data.get("project")
|
||||
if not isinstance(project, dict):
|
||||
return ["project: must be an object"]
|
||||
if "orchestration" not in project:
|
||||
return ["project.orchestration: is required"]
|
||||
orchestration = project.get("orchestration")
|
||||
errors = validate_orchestration(orchestration)
|
||||
|
||||
task_ids: set[str] = set()
|
||||
tasks = data.get("tasks")
|
||||
if isinstance(tasks, list):
|
||||
for task in tasks:
|
||||
if isinstance(task, dict) and isinstance(task.get("id"), str):
|
||||
task_ids.add(task["id"])
|
||||
|
||||
receipts = data.get("workerReceipts")
|
||||
if "workerReceipts" not in data:
|
||||
errors.append("workerReceipts: is required")
|
||||
else:
|
||||
errors.extend(
|
||||
validate_worker_receipts(
|
||||
receipts,
|
||||
orchestration if isinstance(orchestration, dict) else None,
|
||||
task_ids=task_ids,
|
||||
)
|
||||
)
|
||||
errors.extend(_validate_dispatch_links(tasks, receipts, orchestration))
|
||||
if isinstance(orchestration, dict) and orchestration.get("mode") == "manual":
|
||||
receipts = data.get("workerReceipts")
|
||||
if isinstance(receipts, list) and receipts:
|
||||
errors.append("workerReceipts: manual orchestration requires an empty list")
|
||||
return errors
|
||||
|
||||
|
||||
# Short aliases for callers that treat this module as a builtin validator.
|
||||
validate_routing = validate_orchestration
|
||||
validate_receipts = validate_worker_receipts
|
||||
validate_builtin = validate_routing_document
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PROFILE_VERSION",
|
||||
"RECEIPT_VERSION",
|
||||
"LAUNCH_PROTOCOL_VERSION",
|
||||
"MAX_ROUNDS",
|
||||
"canonical_sha256",
|
||||
"profile_hash",
|
||||
"receipt_hash",
|
||||
"render_worker_argv",
|
||||
"validate_profile",
|
||||
"validate_orchestration",
|
||||
"validate_worker_receipt",
|
||||
"validate_worker_receipts",
|
||||
"validate_routing_document",
|
||||
"validate_routing",
|
||||
"validate_receipts",
|
||||
"validate_builtin",
|
||||
]
|
||||
@@ -31,13 +31,25 @@
|
||||
- 派发 prompt 模板:`references/prompt-templates.md`
|
||||
- Orca 编排命令(可选):`references/orca-adapter.md`
|
||||
|
||||
## 模型档位(项目可覆盖,默认见 references/model-routing.md)
|
||||
## Worker 路由
|
||||
|
||||
| 角色 | 默认档位 | 本项目实际 |
|
||||
|------|----------|------------|
|
||||
| Coordinator (PM) | 强模型 | `<model_or_default>` |
|
||||
| Test | 中低模型 | `<model_or_default>` |
|
||||
| Developer | 中低模型 | `<model_or_default>` |
|
||||
机器可校验的模型、reasoning effort、权限模式、默认 profile、允许 worktree 和启动
|
||||
receipt 全部以 `docs/ack/tasks.yaml` 的 `project.orchestration` 与顶层
|
||||
`workerReceipts` 为准。本文件不保存可执行 worker 命令。
|
||||
|
||||
默认 profile:
|
||||
|
||||
| 角色 | profile ID | 档位 |
|
||||
|------|------------|------|
|
||||
| Developer | `codex-dev-standard` | standard |
|
||||
| Test | `codex-test-standard` | standard |
|
||||
| Developer 升级 | `codex-dev-strong` | strong |
|
||||
|
||||
项目如改用 Cursor,应修改结构化 profile、allowlist 和 defaults,再运行任务板
|
||||
校验;不能在这里粘贴 `orca --command`、Agent CLI 参数或自由 shell。
|
||||
每次自动派发必须先审阅 launcher plan,再把其中的 `launchFingerprint` 作为
|
||||
`--expected-launch-fingerprint` 启动 fresh worker。v0.10 的 `receiptHash` 只作
|
||||
审计 checksum,不授权自动复用旧终端。
|
||||
|
||||
## 路径权限(项目覆盖层,必须填实际路径)
|
||||
|
||||
@@ -86,6 +98,12 @@ Skill 的 `scripts/run_verification.py` 执行,不直接拼接 path/args。检
|
||||
|
||||
- 三角色独立:Coordinator 只编排、Test 只验证、Developer 只实现(验证者 ≠ 实现者)。
|
||||
- 模型分层:Coordinator 用强模型且不亲自跑测试,Test/Developer 用中低模型,必要时升级(见 references/model-routing.md)。
|
||||
- 自动 worker 只能由 ACK 的 `scripts/launch_worker.py` 按结构化 profile 启动;
|
||||
禁止直接拼 `orca terminal create --command`,禁止 `command`、`extraArgs`、`env`
|
||||
等第二配置面。
|
||||
- v0.10 自动 launcher 只支持 `read-only` 和 `workspace-write`。full-access、
|
||||
bypass、YOLO/force 与关闭 sandbox 均 fail closed;需要可信平台审批通道后再开放。
|
||||
- v0.10 不自动复用持久化 receipt 指向的旧终端;每次自动派发都重新 plan/launch。
|
||||
- `worker_done` 与复测报告都不等于完成。必须 Test 独立复测 + Coordinator 终检后才能 `verified`。
|
||||
- 只有 Coordinator 写 `tasks.yaml` 和 `knowledge.yaml`;Developer 与 Test 都只读,
|
||||
通过消息回报。
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"source": { "type": "string" },
|
||||
"ackVersion": {
|
||||
"type": "string",
|
||||
"pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$",
|
||||
"description": "接入时所基于的 ack 版本,便于日后 diff 升级"
|
||||
},
|
||||
"kitVersion": {
|
||||
@@ -35,6 +36,9 @@
|
||||
"type": "string",
|
||||
"const": "docs/ack/knowledge.yaml",
|
||||
"description": "项目知识护栏库的唯一权威路径"
|
||||
},
|
||||
"orchestration": {
|
||||
"$ref": "#/definitions/orchestration"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -49,12 +53,538 @@
|
||||
}
|
||||
},
|
||||
"statusReference": { "type": "object" },
|
||||
"workerReceipts": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/definitions/workerReceipt" }
|
||||
},
|
||||
"tasks": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/definitions/task" }
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"ackVersion": {
|
||||
"type": "string",
|
||||
"pattern": "^(?:0\\.[1-9][0-9]+\\.[0-9]+|[1-9][0-9]*\\.[0-9]+\\.[0-9]+)(?:-|\\+|$)"
|
||||
}
|
||||
},
|
||||
"required": ["ackVersion"]
|
||||
},
|
||||
"then": {
|
||||
"required": ["workerReceipts"],
|
||||
"properties": {
|
||||
"workerReceipts": {},
|
||||
"tasks": { "$ref": "#/definitions/launchableTasks" },
|
||||
"project": {
|
||||
"type": "object",
|
||||
"required": ["orchestration"],
|
||||
"properties": {
|
||||
"orchestration": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"project": {
|
||||
"type": "object",
|
||||
"required": ["orchestration"],
|
||||
"properties": {
|
||||
"orchestration": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["project"]
|
||||
},
|
||||
"then": {
|
||||
"required": ["workerReceipts"],
|
||||
"properties": {
|
||||
"workerReceipts": {},
|
||||
"tasks": { "$ref": "#/definitions/launchableTasks" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"required": ["workerReceipts"],
|
||||
"properties": {
|
||||
"workerReceipts": {}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"tasks": { "$ref": "#/definitions/launchableTasks" },
|
||||
"project": {
|
||||
"type": "object",
|
||||
"required": ["orchestration"],
|
||||
"properties": {
|
||||
"orchestration": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"definitions": {
|
||||
"sha256": {
|
||||
"type": "string",
|
||||
"pattern": "^sha256:[0-9a-f]{64}$"
|
||||
},
|
||||
"profileId": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]{1,63}$"
|
||||
},
|
||||
"launchableTasks": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"modelList": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:/+@-]{0,127}$"
|
||||
}
|
||||
},
|
||||
"modelTierAllowlist": {
|
||||
"type": "object",
|
||||
"minProperties": 1,
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"standard": { "$ref": "#/definitions/modelList" },
|
||||
"strong": { "$ref": "#/definitions/modelList" }
|
||||
}
|
||||
},
|
||||
"modelRoleAllowlist": {
|
||||
"type": "object",
|
||||
"minProperties": 1,
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"developer": { "$ref": "#/definitions/modelTierAllowlist" },
|
||||
"test": { "$ref": "#/definitions/modelTierAllowlist" }
|
||||
}
|
||||
},
|
||||
"modelAllowlist": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"codex": { "$ref": "#/definitions/modelRoleAllowlist" },
|
||||
"cursor-agent": { "$ref": "#/definitions/modelRoleAllowlist" }
|
||||
}
|
||||
},
|
||||
"workerProfile": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"role",
|
||||
"cli",
|
||||
"tier",
|
||||
"model",
|
||||
"reasoningEffort",
|
||||
"permissionMode"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"role": {
|
||||
"type": "string",
|
||||
"enum": ["developer", "test"]
|
||||
},
|
||||
"cli": {
|
||||
"type": "string",
|
||||
"enum": ["codex", "cursor-agent"]
|
||||
},
|
||||
"tier": {
|
||||
"type": "string",
|
||||
"enum": ["standard", "strong"]
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:/+@-]{0,127}$"
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"type": ["string", "null"],
|
||||
"enum": ["low", "medium", "high", "xhigh", null]
|
||||
},
|
||||
"permissionMode": {
|
||||
"type": "string",
|
||||
"enum": ["read-only", "workspace-write"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"workerDefaults": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"developer": { "$ref": "#/definitions/profileId" },
|
||||
"test": { "$ref": "#/definitions/profileId" },
|
||||
"developerUpgraded": { "$ref": "#/definitions/profileId" }
|
||||
}
|
||||
},
|
||||
"orchestration": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"profileVersion",
|
||||
"mode",
|
||||
"allowedWorktrees",
|
||||
"modelAllowlist",
|
||||
"profiles",
|
||||
"defaults"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"profileVersion": {
|
||||
"type": "integer",
|
||||
"const": 1
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["orca", "manual"]
|
||||
},
|
||||
"allowedWorktrees": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^/"
|
||||
}
|
||||
},
|
||||
"modelAllowlist": {
|
||||
"$ref": "#/definitions/modelAllowlist"
|
||||
},
|
||||
"profiles": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"$ref": "#/definitions/profileId"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/workerProfile"
|
||||
}
|
||||
},
|
||||
"defaults": {
|
||||
"$ref": "#/definitions/workerDefaults"
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"mode": { "const": "orca" }
|
||||
},
|
||||
"required": ["mode"]
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"allowedWorktrees": { "type": "array", "minItems": 1 },
|
||||
"modelAllowlist": { "type": "object", "minProperties": 1 },
|
||||
"profiles": { "type": "object", "minProperties": 1 },
|
||||
"defaults": {
|
||||
"type": "object",
|
||||
"required": ["developer", "test"],
|
||||
"properties": {
|
||||
"developer": {},
|
||||
"test": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"worktreeIdentity": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"path",
|
||||
"device",
|
||||
"inode",
|
||||
"gitCommonDir",
|
||||
"gitCommonDevice",
|
||||
"gitCommonInode"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"pattern": "^/"
|
||||
},
|
||||
"device": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"inode": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"gitCommonDir": {
|
||||
"type": "string",
|
||||
"pattern": "^/"
|
||||
},
|
||||
"gitCommonDevice": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"gitCommonInode": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"receiptRequested": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"cli",
|
||||
"tier",
|
||||
"model",
|
||||
"reasoningEffort",
|
||||
"permissionMode",
|
||||
"executable",
|
||||
"executableDevice",
|
||||
"executableInode",
|
||||
"cliVersion",
|
||||
"argv",
|
||||
"argvHash",
|
||||
"environmentPolicy"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"cli": {
|
||||
"type": "string",
|
||||
"enum": ["codex", "cursor-agent"]
|
||||
},
|
||||
"tier": {
|
||||
"type": "string",
|
||||
"enum": ["standard", "strong"]
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:/+@-]{0,127}$"
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"type": ["string", "null"],
|
||||
"enum": ["low", "medium", "high", "xhigh", null]
|
||||
},
|
||||
"permissionMode": {
|
||||
"type": "string",
|
||||
"enum": ["read-only", "workspace-write"]
|
||||
},
|
||||
"executable": {
|
||||
"type": "string",
|
||||
"pattern": "^/"
|
||||
},
|
||||
"executableDevice": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"executableInode": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"cliVersion": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"argv": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"argvHash": {
|
||||
"$ref": "#/definitions/sha256"
|
||||
},
|
||||
"environmentPolicy": {
|
||||
"type": "string",
|
||||
"const": "per-cli-allowlist-v1"
|
||||
}
|
||||
}
|
||||
},
|
||||
"receiptBinding": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"orchestrator",
|
||||
"runtimeId",
|
||||
"handle",
|
||||
"incarnationId",
|
||||
"observedWorktreePath",
|
||||
"connected",
|
||||
"writable",
|
||||
"boundAt"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"orchestrator": {
|
||||
"type": "string",
|
||||
"const": "orca"
|
||||
},
|
||||
"runtimeId": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"handle": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"incarnationId": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"observedWorktreePath": {
|
||||
"type": "string"
|
||||
},
|
||||
"connected": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"writable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"boundAt": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"workerReceipt": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"receiptVersion",
|
||||
"id",
|
||||
"launchId",
|
||||
"profileId",
|
||||
"profileHash",
|
||||
"launchFingerprint",
|
||||
"slot",
|
||||
"createdFor",
|
||||
"worktree",
|
||||
"requested",
|
||||
"binding",
|
||||
"createdAt",
|
||||
"receiptHash"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"receiptVersion": {
|
||||
"type": "integer",
|
||||
"const": 1
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^WR-[0-9a-f]{64}$"
|
||||
},
|
||||
"launchId": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{64}$"
|
||||
},
|
||||
"profileId": {
|
||||
"$ref": "#/definitions/profileId"
|
||||
},
|
||||
"profileHash": {
|
||||
"$ref": "#/definitions/sha256"
|
||||
},
|
||||
"launchFingerprint": {
|
||||
"$ref": "#/definitions/sha256"
|
||||
},
|
||||
"slot": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 99
|
||||
},
|
||||
"createdFor": {
|
||||
"type": "object",
|
||||
"required": ["taskId", "attemptId", "role"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"taskId": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"attemptId": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*-A[1-3]$"
|
||||
},
|
||||
"role": {
|
||||
"type": "string",
|
||||
"enum": ["developer", "test"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"worktree": {
|
||||
"$ref": "#/definitions/worktreeIdentity"
|
||||
},
|
||||
"requested": {
|
||||
"$ref": "#/definitions/receiptRequested"
|
||||
},
|
||||
"binding": {
|
||||
"$ref": "#/definitions/receiptBinding"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"receiptHash": {
|
||||
"$ref": "#/definitions/sha256"
|
||||
}
|
||||
}
|
||||
},
|
||||
"roleDispatch": {
|
||||
"type": "object",
|
||||
"required": ["profileId", "receiptId", "attemptId", "taskId", "dispatchId"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"profileId": {
|
||||
"type": ["string", "null"],
|
||||
"pattern": "^[a-z][a-z0-9-]{1,63}$"
|
||||
},
|
||||
"receiptId": {
|
||||
"type": ["string", "null"],
|
||||
"pattern": "^WR-[0-9a-f]{64}$"
|
||||
},
|
||||
"attemptId": {
|
||||
"type": ["string", "null"],
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*-A[1-3]$"
|
||||
},
|
||||
"taskId": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"dispatchId": {
|
||||
"type": ["string", "null"]
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"receiptId": { "type": "null" }
|
||||
},
|
||||
"required": ["receiptId"]
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"attemptId": { "type": "null" }
|
||||
}
|
||||
},
|
||||
"else": {
|
||||
"properties": {
|
||||
"profileId": { "type": "string" },
|
||||
"attemptId": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -76,7 +606,7 @@
|
||||
"round": { "type": "integer", "minimum": 1, "maximum": 3 },
|
||||
"attemptId": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*-A[1-9][0-9]*$",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*-A[1-3]$",
|
||||
"description": "稳定逻辑轮次 ID,应为 <task-id>-A<round>;旧轮次可缺省,但作为知识来源前必须补齐"
|
||||
},
|
||||
"result": { "type": "string", "enum": ["passed", "failed"] },
|
||||
@@ -230,11 +760,13 @@
|
||||
"verification": { "type": "object" },
|
||||
"dispatch": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"taskId": { "type": ["string", "null"] },
|
||||
"dispatchId": { "type": ["string", "null"] },
|
||||
"worker": { "type": ["string", "null"] },
|
||||
"developer": { "$ref": "#/definitions/roleDispatch" },
|
||||
"test": { "$ref": "#/definitions/roleDispatch" },
|
||||
"rounds": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/definitions/round" }
|
||||
|
||||
@@ -10,6 +10,65 @@ project:
|
||||
devWorktree: "<dev_worktree>"
|
||||
overlayFile: "docs/ack/project.md"
|
||||
knowledgeFile: "docs/ack/knowledge.yaml"
|
||||
orchestration:
|
||||
profileVersion: 1
|
||||
mode: "orca"
|
||||
allowedWorktrees:
|
||||
- "<dev_worktree>"
|
||||
modelAllowlist:
|
||||
codex:
|
||||
developer:
|
||||
standard: ["gpt-5.6-terra"]
|
||||
strong: ["gpt-5.6-sol"]
|
||||
test:
|
||||
standard: ["gpt-5.6-luna"]
|
||||
cursor-agent:
|
||||
developer:
|
||||
standard: ["auto"]
|
||||
test:
|
||||
standard: ["auto"]
|
||||
profiles:
|
||||
codex-dev-standard:
|
||||
role: "developer"
|
||||
cli: "codex"
|
||||
tier: "standard"
|
||||
model: "gpt-5.6-terra"
|
||||
reasoningEffort: "medium"
|
||||
permissionMode: "workspace-write"
|
||||
codex-test-standard:
|
||||
role: "test"
|
||||
cli: "codex"
|
||||
tier: "standard"
|
||||
model: "gpt-5.6-luna"
|
||||
reasoningEffort: "low"
|
||||
permissionMode: "workspace-write"
|
||||
codex-dev-strong:
|
||||
role: "developer"
|
||||
cli: "codex"
|
||||
tier: "strong"
|
||||
model: "gpt-5.6-sol"
|
||||
reasoningEffort: "high"
|
||||
permissionMode: "workspace-write"
|
||||
cursor-dev-standard:
|
||||
role: "developer"
|
||||
cli: "cursor-agent"
|
||||
tier: "standard"
|
||||
model: "auto"
|
||||
reasoningEffort: null
|
||||
permissionMode: "workspace-write"
|
||||
cursor-test-standard:
|
||||
role: "test"
|
||||
cli: "cursor-agent"
|
||||
tier: "standard"
|
||||
model: "auto"
|
||||
reasoningEffort: null
|
||||
permissionMode: "workspace-write"
|
||||
defaults:
|
||||
developer: "codex-dev-standard"
|
||||
test: "codex-test-standard"
|
||||
developerUpgraded: "codex-dev-strong"
|
||||
|
||||
workerReceipts: []
|
||||
|
||||
summary:
|
||||
verified: []
|
||||
@@ -62,9 +121,18 @@ tasks:
|
||||
- "<visible text or interaction expected>"
|
||||
|
||||
dispatch:
|
||||
developer:
|
||||
profileId: "codex-dev-standard"
|
||||
receiptId: null
|
||||
attemptId: null
|
||||
taskId: null
|
||||
dispatchId: null
|
||||
test:
|
||||
profileId: "codex-test-standard"
|
||||
receiptId: null
|
||||
attemptId: null
|
||||
taskId: null
|
||||
dispatchId: null
|
||||
worker: null
|
||||
rounds: []
|
||||
|
||||
resolution:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+19
-1
@@ -34,6 +34,16 @@ class AckSkillContentTests(unittest.TestCase):
|
||||
self.assertIn('display_name: "ACK"', metadata)
|
||||
self.assertIn("allow_implicit_invocation: false", metadata)
|
||||
|
||||
def test_model_routing_is_fingerprint_bound_and_does_not_trust_receipt_reuse(self) -> None:
|
||||
content = (
|
||||
REPO_ROOT / "skills" / "ack" / "references" / "model-routing.md"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn("--expected-launch-fingerprint", content)
|
||||
self.assertIn("allowlist-v1", content)
|
||||
self.assertIn("禁止根据持久化 receipt 自动复用", content)
|
||||
self.assertIn("launcher 身份证明", content)
|
||||
|
||||
def test_ack_knowledge_resources_and_version_are_present(self) -> None:
|
||||
ack_dir = REPO_ROOT / "skills" / "ack"
|
||||
|
||||
@@ -44,9 +54,17 @@ class AckSkillContentTests(unittest.TestCase):
|
||||
"scripts/validate_knowledge.py",
|
||||
"scripts/select_knowledge.py",
|
||||
"scripts/run_verification.py",
|
||||
"scripts/worker_profiles.py",
|
||||
"scripts/launch_worker.py",
|
||||
):
|
||||
self.assertTrue((ack_dir / relative_path).is_file(), relative_path)
|
||||
self.assertEqual((ack_dir / "VERSION").read_text(encoding="utf-8").strip(), "0.9.0")
|
||||
self.assertEqual((ack_dir / "VERSION").read_text(encoding="utf-8").strip(), "0.10.0")
|
||||
self.assertIn(
|
||||
'ackVersion: "<接入时的 ack skill 版本>"',
|
||||
(ack_dir / "templates" / "tasks.template.yaml").read_text(
|
||||
encoding="utf-8"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -12,6 +15,7 @@ from pathlib import Path
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
VALIDATOR = REPO_ROOT / "skills" / "ack" / "scripts" / "validate_tasks.py"
|
||||
EXAMPLE = REPO_ROOT / "skills" / "ack" / "examples" / "tasks.example.yaml"
|
||||
SCHEMA = REPO_ROOT / "skills" / "ack" / "templates" / "tasks.schema.json"
|
||||
|
||||
|
||||
def valid_knowledge_board() -> dict:
|
||||
@@ -59,6 +63,21 @@ def valid_knowledge_board() -> dict:
|
||||
}
|
||||
|
||||
|
||||
def valid_manual_routing_board() -> dict:
|
||||
board = valid_knowledge_board()
|
||||
board["ackVersion"] = "0.10.0"
|
||||
board["project"]["orchestration"] = {
|
||||
"profileVersion": 1,
|
||||
"mode": "manual",
|
||||
"allowedWorktrees": [],
|
||||
"modelAllowlist": {},
|
||||
"profiles": {},
|
||||
"defaults": {},
|
||||
}
|
||||
board["workerReceipts"] = []
|
||||
return board
|
||||
|
||||
|
||||
class AckTaskValidationTests(unittest.TestCase):
|
||||
def run_validator(
|
||||
self,
|
||||
@@ -129,6 +148,160 @@ class AckTaskValidationTests(unittest.TestCase):
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("任务板校验通过", result.stdout)
|
||||
|
||||
def test_v010_requires_structured_routing_but_v009_remains_readable(self) -> None:
|
||||
current = valid_knowledge_board()
|
||||
current["ackVersion"] = "0.10.0"
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
current,
|
||||
"project.orchestration: is required",
|
||||
)
|
||||
|
||||
legacy = valid_knowledge_board()
|
||||
legacy["ackVersion"] = "0.9.0"
|
||||
self.assert_board_accepted_in_all_modes(legacy)
|
||||
|
||||
def test_builtin_rejects_invalid_ack_semver_in_all_modes(self) -> None:
|
||||
for invalid in ("0.10", "00.10.0", "0.10.01", "v0.10.0", "0.10.0-"):
|
||||
with self.subTest(ack_version=invalid):
|
||||
board = valid_knowledge_board()
|
||||
board["ackVersion"] = invalid
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board,
|
||||
"ackVersion 必须是合法 SemVer",
|
||||
)
|
||||
|
||||
def test_schema_declares_semver_routing_and_attempt_contracts(self) -> None:
|
||||
schema = json.loads(SCHEMA.read_text(encoding="utf-8"))
|
||||
ack_pattern = re.compile(schema["properties"]["ackVersion"]["pattern"])
|
||||
for valid in ("0.9.0", "0.10.0", "0.11.2-alpha.1+build.7", "1.0.0"):
|
||||
with self.subTest(valid_semver=valid):
|
||||
self.assertIsNotNone(ack_pattern.fullmatch(valid))
|
||||
for invalid in ("0.10", "00.10.0", "0.10.01", "v0.10.0", "0.10.0-"):
|
||||
with self.subTest(invalid_semver=invalid):
|
||||
self.assertIsNone(ack_pattern.fullmatch(invalid))
|
||||
|
||||
current_gate, orchestration_gate, receipts_gate = schema["allOf"]
|
||||
current_pattern = re.compile(
|
||||
current_gate["if"]["properties"]["ackVersion"]["pattern"]
|
||||
)
|
||||
for current in ("0.10.0", "0.99.1", "1.0.0", "12.34.56+build"):
|
||||
self.assertIsNotNone(current_pattern.search(current))
|
||||
self.assertIsNone(current_pattern.search("0.9.99"))
|
||||
self.assertIn("workerReceipts", current_gate["then"]["required"])
|
||||
self.assertIn(
|
||||
"workerReceipts",
|
||||
current_gate["then"]["properties"],
|
||||
)
|
||||
self.assertIn(
|
||||
"orchestration",
|
||||
current_gate["then"]["properties"]["project"]["required"],
|
||||
)
|
||||
self.assertIn(
|
||||
"orchestration",
|
||||
current_gate["then"]["properties"]["project"]["properties"],
|
||||
)
|
||||
self.assertIn("workerReceipts", orchestration_gate["then"]["required"])
|
||||
self.assertIn(
|
||||
"workerReceipts",
|
||||
orchestration_gate["then"]["properties"],
|
||||
)
|
||||
self.assertIn(
|
||||
"orchestration",
|
||||
receipts_gate["then"]["properties"]["project"]["required"],
|
||||
)
|
||||
self.assertIn(
|
||||
"orchestration",
|
||||
receipts_gate["then"]["properties"]["project"]["properties"],
|
||||
)
|
||||
for gate in (current_gate, orchestration_gate, receipts_gate):
|
||||
self.assertEqual(
|
||||
gate["then"]["properties"]["tasks"]["$ref"],
|
||||
"#/definitions/launchableTasks",
|
||||
)
|
||||
|
||||
role_dispatch = schema["definitions"]["roleDispatch"]
|
||||
self.assertIn("attemptId", role_dispatch["required"])
|
||||
receipt_rule = role_dispatch["allOf"][0]
|
||||
self.assertEqual(
|
||||
receipt_rule["then"]["properties"]["attemptId"]["type"],
|
||||
"null",
|
||||
)
|
||||
self.assertEqual(
|
||||
receipt_rule["else"]["properties"]["attemptId"]["type"],
|
||||
"string",
|
||||
)
|
||||
|
||||
@unittest.skipUnless(
|
||||
importlib.util.find_spec("jsonschema") is not None,
|
||||
"jsonschema is required for the schema-only contract test",
|
||||
)
|
||||
def test_schema_alone_enforces_v010_and_mutual_routing_presence(self) -> None:
|
||||
import jsonschema # type: ignore
|
||||
|
||||
schema = json.loads(SCHEMA.read_text(encoding="utf-8"))
|
||||
validator = jsonschema.Draft7Validator(schema)
|
||||
|
||||
def messages(board: dict) -> list[str]:
|
||||
return [error.message for error in validator.iter_errors(board)]
|
||||
|
||||
base = {
|
||||
"version": 1,
|
||||
"ackVersion": "0.9.0",
|
||||
"project": {"name": "demo"},
|
||||
"tasks": [],
|
||||
}
|
||||
self.assertEqual(messages(base), [])
|
||||
|
||||
for current in ("0.10.0", "0.11.2-alpha.1+build.7", "1.0.0"):
|
||||
with self.subTest(ack_version=current):
|
||||
board = copy.deepcopy(base)
|
||||
board["ackVersion"] = current
|
||||
errors = messages(board)
|
||||
self.assertIn("'workerReceipts' is a required property", errors)
|
||||
self.assertIn("'orchestration' is a required property", errors)
|
||||
|
||||
manual = {
|
||||
"profileVersion": 1,
|
||||
"mode": "manual",
|
||||
"allowedWorktrees": [],
|
||||
"modelAllowlist": {},
|
||||
"profiles": {},
|
||||
"defaults": {},
|
||||
}
|
||||
orchestration_only = copy.deepcopy(base)
|
||||
orchestration_only["project"]["orchestration"] = manual
|
||||
self.assertIn(
|
||||
"'workerReceipts' is a required property",
|
||||
messages(orchestration_only),
|
||||
)
|
||||
|
||||
receipts_only = copy.deepcopy(base)
|
||||
receipts_only["workerReceipts"] = []
|
||||
self.assertIn(
|
||||
"'orchestration' is a required property",
|
||||
messages(receipts_only),
|
||||
)
|
||||
|
||||
complete = copy.deepcopy(orchestration_only)
|
||||
complete["ackVersion"] = "0.10.0+routing.1"
|
||||
complete["workerReceipts"] = []
|
||||
self.assertEqual(messages(complete), [])
|
||||
|
||||
def test_v010_task_ids_match_launcher_while_legacy_ids_remain_readable(
|
||||
self,
|
||||
) -> None:
|
||||
current = valid_manual_routing_board()
|
||||
current["tasks"][0]["id"] = "BUG/001"
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
current,
|
||||
"v0.10 自动路由 id 只允许字母、数字、点、下划线和连字符",
|
||||
)
|
||||
|
||||
legacy = valid_knowledge_board()
|
||||
legacy["ackVersion"] = "0.9.0"
|
||||
legacy["tasks"][0]["id"] = "BUG/001"
|
||||
self.assert_board_accepted_in_all_modes(legacy)
|
||||
|
||||
def test_knowledge_applied_and_checks_must_reference_selected_knowledge(self) -> None:
|
||||
result = self.run_validator(
|
||||
"""
|
||||
@@ -582,6 +755,18 @@ class AckTaskValidationTests(unittest.TestCase):
|
||||
"T-RESOLUTION.resolution: 必须是对象",
|
||||
)
|
||||
|
||||
def test_dispatch_rejects_free_command_field_without_jsonschema(self) -> None:
|
||||
board = valid_knowledge_board()
|
||||
board["tasks"][0]["dispatch"] = {
|
||||
"command": "cursor-agent --yolo",
|
||||
"rounds": [],
|
||||
}
|
||||
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board,
|
||||
"dispatch: 未知字段 'command'",
|
||||
)
|
||||
|
||||
def test_valid_optional_schema_fields_pass_in_all_modes(self) -> None:
|
||||
board = valid_knowledge_board()
|
||||
board.update(
|
||||
|
||||
@@ -0,0 +1,895 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPTS_DIR = REPO_ROOT / "skills" / "ack" / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
import worker_profiles # noqa: E402
|
||||
|
||||
|
||||
def valid_orchestration() -> dict:
|
||||
return {
|
||||
"profileVersion": 1,
|
||||
"mode": "orca",
|
||||
"allowedWorktrees": ["/repo/demo"],
|
||||
"modelAllowlist": {
|
||||
"codex": {
|
||||
"developer": {
|
||||
"standard": ["gpt-safe-dev"],
|
||||
"strong": ["gpt-safe-strong"],
|
||||
},
|
||||
"test": {"standard": ["gpt-safe-test"]},
|
||||
},
|
||||
"cursor-agent": {
|
||||
"developer": {"standard": ["cursor-auto"]},
|
||||
"test": {"standard": ["cursor-auto"]},
|
||||
},
|
||||
},
|
||||
"profiles": {
|
||||
"codex-dev-standard": {
|
||||
"role": "developer",
|
||||
"cli": "codex",
|
||||
"tier": "standard",
|
||||
"model": "gpt-safe-dev",
|
||||
"reasoningEffort": "medium",
|
||||
"permissionMode": "workspace-write",
|
||||
},
|
||||
"codex-dev-strong": {
|
||||
"role": "developer",
|
||||
"cli": "codex",
|
||||
"tier": "strong",
|
||||
"model": "gpt-safe-strong",
|
||||
"reasoningEffort": "high",
|
||||
"permissionMode": "workspace-write",
|
||||
},
|
||||
"cursor-test-standard": {
|
||||
"role": "test",
|
||||
"cli": "cursor-agent",
|
||||
"tier": "standard",
|
||||
"model": "cursor-auto",
|
||||
"reasoningEffort": None,
|
||||
"permissionMode": "read-only",
|
||||
},
|
||||
"cursor-dev-standard": {
|
||||
"role": "developer",
|
||||
"cli": "cursor-agent",
|
||||
"tier": "standard",
|
||||
"model": "cursor-auto",
|
||||
"reasoningEffort": None,
|
||||
"permissionMode": "workspace-write",
|
||||
},
|
||||
},
|
||||
"defaults": {
|
||||
"developer": "codex-dev-standard",
|
||||
"test": "cursor-test-standard",
|
||||
"developerUpgraded": "codex-dev-strong",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def valid_receipt(orchestration: dict | None = None) -> dict:
|
||||
routing = orchestration or valid_orchestration()
|
||||
profile_id = "codex-dev-standard"
|
||||
profile = routing["profiles"][profile_id]
|
||||
executable = "/usr/local/bin/codex"
|
||||
worktree_path = "/repo/demo"
|
||||
argv = worker_profiles.render_worker_argv(profile, executable, worktree_path)
|
||||
launch_id = "a" * 64
|
||||
requested = {
|
||||
"cli": profile["cli"],
|
||||
"tier": profile["tier"],
|
||||
"model": profile["model"],
|
||||
"reasoningEffort": profile["reasoningEffort"],
|
||||
"permissionMode": profile["permissionMode"],
|
||||
"executable": executable,
|
||||
"executableDevice": 8,
|
||||
"executableInode": 201,
|
||||
"cliVersion": "codex 1.0.0",
|
||||
"argv": argv,
|
||||
"argvHash": worker_profiles.canonical_sha256(argv),
|
||||
"environmentPolicy": "per-cli-allowlist-v1",
|
||||
}
|
||||
worktree = {
|
||||
"path": worktree_path,
|
||||
"device": 8,
|
||||
"inode": 101,
|
||||
"gitCommonDir": "/repo/demo/.git",
|
||||
"gitCommonDevice": 8,
|
||||
"gitCommonInode": 102,
|
||||
}
|
||||
receipt = {
|
||||
"receiptVersion": 1,
|
||||
"id": f"WR-{launch_id}",
|
||||
"launchId": launch_id,
|
||||
"profileId": profile_id,
|
||||
"profileHash": worker_profiles.profile_hash(profile),
|
||||
"launchFingerprint": worker_profiles.canonical_sha256(
|
||||
{
|
||||
"protocolVersion": 1,
|
||||
"backend": "orca",
|
||||
"profileId": profile_id,
|
||||
"profileHash": worker_profiles.profile_hash(profile),
|
||||
"createdFor": {
|
||||
"taskId": "TASK-001",
|
||||
"attemptId": "TASK-001-A1",
|
||||
"role": "developer",
|
||||
},
|
||||
"worktree": worktree,
|
||||
"requested": requested,
|
||||
"slot": 1,
|
||||
}
|
||||
),
|
||||
"slot": 1,
|
||||
"createdFor": {
|
||||
"taskId": "TASK-001",
|
||||
"attemptId": "TASK-001-A1",
|
||||
"role": "developer",
|
||||
},
|
||||
"worktree": worktree,
|
||||
"requested": requested,
|
||||
"binding": {
|
||||
"orchestrator": "orca",
|
||||
"runtimeId": "runtime-001",
|
||||
"handle": "terminal-001",
|
||||
"incarnationId": "incarnation-001",
|
||||
"observedWorktreePath": worktree_path,
|
||||
"connected": True,
|
||||
"writable": True,
|
||||
"boundAt": "2026-07-31T12:00:01+08:00",
|
||||
},
|
||||
"createdAt": "2026-07-31T12:00:00+08:00",
|
||||
"receiptHash": "",
|
||||
}
|
||||
receipt["receiptHash"] = worker_profiles.receipt_hash(receipt)
|
||||
return receipt
|
||||
|
||||
|
||||
class CanonicalHashTests(unittest.TestCase):
|
||||
def test_hash_is_prefixed_order_independent_and_content_sensitive(self) -> None:
|
||||
first = worker_profiles.canonical_sha256(
|
||||
{"b": [2, 1], "a": {"enabled": True}}
|
||||
)
|
||||
reordered = worker_profiles.canonical_sha256(
|
||||
{"a": {"enabled": True}, "b": [2, 1]}
|
||||
)
|
||||
changed = worker_profiles.canonical_sha256(
|
||||
{"a": {"enabled": False}, "b": [2, 1]}
|
||||
)
|
||||
|
||||
self.assertEqual(first, reordered)
|
||||
self.assertNotEqual(first, changed)
|
||||
self.assertRegex(first, r"^sha256:[0-9a-f]{64}$")
|
||||
|
||||
def test_hash_rejects_non_json_and_non_finite_values(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
worker_profiles.canonical_sha256({"bad": object()})
|
||||
with self.assertRaises(ValueError):
|
||||
worker_profiles.canonical_sha256({"bad": float("nan")})
|
||||
cyclic: list = []
|
||||
cyclic.append(cyclic)
|
||||
with self.assertRaises(ValueError):
|
||||
worker_profiles.canonical_sha256(cyclic)
|
||||
|
||||
def test_profile_hash_rejects_invalid_profile(self) -> None:
|
||||
profile = valid_orchestration()["profiles"]["codex-dev-standard"]
|
||||
profile["command"] = "codex; touch forged"
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
worker_profiles.profile_hash(profile)
|
||||
|
||||
def test_profile_hash_binds_profile_version(self) -> None:
|
||||
profile = valid_orchestration()["profiles"]["codex-dev-standard"]
|
||||
|
||||
self.assertNotEqual(
|
||||
worker_profiles.profile_hash(profile, profile_version=1),
|
||||
worker_profiles.profile_hash(profile, profile_version=2),
|
||||
)
|
||||
|
||||
|
||||
class ProfileValidationTests(unittest.TestCase):
|
||||
def test_valid_orchestration_passes(self) -> None:
|
||||
self.assertEqual(
|
||||
worker_profiles.validate_orchestration(valid_orchestration()),
|
||||
[],
|
||||
)
|
||||
|
||||
def test_manual_mode_accepts_empty_structured_routing(self) -> None:
|
||||
routing = {
|
||||
"profileVersion": 1,
|
||||
"mode": "manual",
|
||||
"allowedWorktrees": [],
|
||||
"modelAllowlist": {},
|
||||
"profiles": {},
|
||||
"defaults": {},
|
||||
}
|
||||
|
||||
self.assertEqual(worker_profiles.validate_orchestration(routing), [])
|
||||
|
||||
def test_partial_empty_allowlist_nodes_are_rejected(self) -> None:
|
||||
routing = {
|
||||
"profileVersion": 1,
|
||||
"mode": "manual",
|
||||
"allowedWorktrees": [],
|
||||
"modelAllowlist": {"codex": {}},
|
||||
"profiles": {},
|
||||
"defaults": {},
|
||||
}
|
||||
|
||||
errors = worker_profiles.validate_orchestration(routing)
|
||||
|
||||
self.assertTrue(any("modelAllowlist.codex: must not be empty" in error for error in errors))
|
||||
|
||||
def test_profile_rejects_all_command_shaped_and_unknown_fields(self) -> None:
|
||||
forbidden = ("command", "args", "env", "executable", "argv", "extraArgs")
|
||||
for field in forbidden:
|
||||
with self.subTest(field=field):
|
||||
routing = valid_orchestration()
|
||||
routing["profiles"]["codex-dev-standard"][field] = "forged"
|
||||
errors = worker_profiles.validate_orchestration(routing)
|
||||
self.assertTrue(any(f"unknown field '{field}'" in error for error in errors))
|
||||
|
||||
def test_routing_rejects_unknown_top_level_fields(self) -> None:
|
||||
routing = valid_orchestration()
|
||||
routing["command"] = "codex"
|
||||
|
||||
errors = worker_profiles.validate_orchestration(routing)
|
||||
|
||||
self.assertIn("project.orchestration: unknown field 'command'", errors)
|
||||
|
||||
def test_model_id_rejects_shell_and_whitespace_syntax(self) -> None:
|
||||
for model in (
|
||||
"safe;touch-forged",
|
||||
"safe && forged",
|
||||
"$(touch-forged)",
|
||||
"`touch-forged`",
|
||||
"safe\nforged",
|
||||
"--dangerously-bypass-approvals-and-sandbox",
|
||||
):
|
||||
with self.subTest(model=model):
|
||||
routing = valid_orchestration()
|
||||
routing["profiles"]["codex-dev-standard"]["model"] = model
|
||||
errors = worker_profiles.validate_orchestration(routing)
|
||||
self.assertTrue(any("safe model ID" in error for error in errors))
|
||||
|
||||
def test_only_safe_permission_modes_are_accepted(self) -> None:
|
||||
for permission in (
|
||||
"danger-full-access",
|
||||
"full-access",
|
||||
"yolo",
|
||||
"bypass",
|
||||
"never",
|
||||
):
|
||||
with self.subTest(permission=permission):
|
||||
routing = valid_orchestration()
|
||||
routing["profiles"]["codex-dev-standard"][
|
||||
"permissionMode"
|
||||
] = permission
|
||||
errors = worker_profiles.validate_orchestration(routing)
|
||||
self.assertTrue(any("read-only/workspace-write" in error for error in errors))
|
||||
|
||||
def test_reasoning_effort_is_required_for_codex_and_null_for_cursor(self) -> None:
|
||||
codex = valid_orchestration()
|
||||
codex["profiles"]["codex-dev-standard"]["reasoningEffort"] = None
|
||||
cursor = valid_orchestration()
|
||||
cursor["profiles"]["cursor-test-standard"]["reasoningEffort"] = "low"
|
||||
|
||||
codex_errors = worker_profiles.validate_orchestration(codex)
|
||||
cursor_errors = worker_profiles.validate_orchestration(cursor)
|
||||
|
||||
self.assertTrue(any("Codex requires" in error for error in codex_errors))
|
||||
self.assertTrue(any("Cursor requires null" in error for error in cursor_errors))
|
||||
|
||||
def test_test_cannot_use_strong_tier(self) -> None:
|
||||
routing = valid_orchestration()
|
||||
profile = routing["profiles"]["cursor-test-standard"]
|
||||
profile["tier"] = "strong"
|
||||
routing["modelAllowlist"]["cursor-agent"]["test"]["strong"] = [
|
||||
"cursor-auto"
|
||||
]
|
||||
|
||||
errors = worker_profiles.validate_orchestration(routing)
|
||||
|
||||
self.assertTrue(any("Test may only use standard" in error for error in errors))
|
||||
self.assertTrue(any("Test cannot define a strong allowlist" in error for error in errors))
|
||||
|
||||
def test_profile_model_must_match_exact_cli_role_tier_allowlist(self) -> None:
|
||||
routing = valid_orchestration()
|
||||
routing["profiles"]["codex-dev-standard"]["model"] = "other-safe-model"
|
||||
|
||||
errors = worker_profiles.validate_orchestration(routing)
|
||||
|
||||
self.assertTrue(any("is not allowed for its cli/role/tier" in error for error in errors))
|
||||
|
||||
def test_defaults_require_matching_role_and_standard_tier(self) -> None:
|
||||
wrong_role = valid_orchestration()
|
||||
wrong_role["defaults"]["test"] = "codex-dev-standard"
|
||||
strong_default = valid_orchestration()
|
||||
strong_default["defaults"]["developer"] = "codex-dev-strong"
|
||||
|
||||
role_errors = worker_profiles.validate_orchestration(wrong_role)
|
||||
tier_errors = worker_profiles.validate_orchestration(strong_default)
|
||||
|
||||
self.assertTrue(any("profile role must be test" in error for error in role_errors))
|
||||
self.assertTrue(any("default profile must use standard" in error for error in tier_errors))
|
||||
|
||||
def test_developer_upgraded_default_requires_developer_strong(self) -> None:
|
||||
valid = valid_orchestration()
|
||||
wrong_tier = valid_orchestration()
|
||||
wrong_tier["defaults"]["developerUpgraded"] = "codex-dev-standard"
|
||||
wrong_role = valid_orchestration()
|
||||
wrong_role["defaults"]["developerUpgraded"] = "cursor-test-standard"
|
||||
|
||||
self.assertEqual(worker_profiles.validate_orchestration(valid), [])
|
||||
tier_errors = worker_profiles.validate_orchestration(wrong_tier)
|
||||
role_errors = worker_profiles.validate_orchestration(wrong_role)
|
||||
self.assertTrue(any("must use strong tier" in error for error in tier_errors))
|
||||
self.assertTrue(any("profile role must be developer" in error for error in role_errors))
|
||||
|
||||
def test_malformed_scalar_types_return_errors_instead_of_raising(self) -> None:
|
||||
for field in ("role", "cli", "tier", "reasoningEffort", "permissionMode"):
|
||||
with self.subTest(profile_field=field):
|
||||
profile = valid_orchestration()["profiles"]["codex-dev-standard"]
|
||||
profile[field] = []
|
||||
self.assertTrue(worker_profiles.validate_profile(profile))
|
||||
|
||||
routing = valid_orchestration()
|
||||
routing["mode"] = []
|
||||
self.assertTrue(worker_profiles.validate_orchestration(routing))
|
||||
|
||||
receipt = valid_receipt()
|
||||
receipt["profileId"] = []
|
||||
receipt["requested"]["cli"] = []
|
||||
receipt["requested"]["tier"] = []
|
||||
receipt["requested"]["permissionMode"] = []
|
||||
receipt["receiptHash"] = worker_profiles.receipt_hash(receipt)
|
||||
self.assertTrue(worker_profiles.validate_worker_receipt(receipt))
|
||||
self.assertTrue(
|
||||
worker_profiles.validate_worker_receipt(receipt, orchestration=[])
|
||||
)
|
||||
|
||||
def test_orca_requires_defaults_profiles_and_allowed_worktree(self) -> None:
|
||||
routing = valid_orchestration()
|
||||
routing["allowedWorktrees"] = []
|
||||
routing["profiles"] = {}
|
||||
routing["defaults"] = {}
|
||||
|
||||
errors = worker_profiles.validate_orchestration(routing)
|
||||
|
||||
self.assertTrue(any("requires at least one path" in error for error in errors))
|
||||
self.assertTrue(any("Orca mode requires profiles" in error for error in errors))
|
||||
self.assertTrue(any("missing role 'developer'" in error for error in errors))
|
||||
self.assertTrue(any("missing role 'test'" in error for error in errors))
|
||||
|
||||
|
||||
class ArgvRendererTests(unittest.TestCase):
|
||||
def test_codex_exact_safe_argv(self) -> None:
|
||||
profile = valid_orchestration()["profiles"]["codex-dev-standard"]
|
||||
|
||||
argv = worker_profiles.render_worker_argv(
|
||||
profile,
|
||||
"/usr/local/bin/codex",
|
||||
"/repo/demo",
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
argv,
|
||||
[
|
||||
"/usr/local/bin/codex",
|
||||
"--strict-config",
|
||||
"--model",
|
||||
"gpt-safe-dev",
|
||||
"--config",
|
||||
"model_reasoning_effort=medium",
|
||||
"--sandbox",
|
||||
"workspace-write",
|
||||
"--ask-for-approval",
|
||||
"never",
|
||||
"--cd",
|
||||
"/repo/demo",
|
||||
],
|
||||
)
|
||||
|
||||
def test_cursor_read_only_exact_safe_argv(self) -> None:
|
||||
profile = valid_orchestration()["profiles"]["cursor-test-standard"]
|
||||
|
||||
argv = worker_profiles.render_worker_argv(
|
||||
profile,
|
||||
"/usr/local/bin/cursor-agent",
|
||||
"/repo/demo",
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
argv,
|
||||
[
|
||||
"/usr/local/bin/cursor-agent",
|
||||
"--model",
|
||||
"cursor-auto",
|
||||
"--mode",
|
||||
"plan",
|
||||
"--sandbox",
|
||||
"enabled",
|
||||
"--workspace",
|
||||
"/repo/demo",
|
||||
],
|
||||
)
|
||||
|
||||
def test_cursor_workspace_write_adds_auto_review_without_yolo(self) -> None:
|
||||
profile = valid_orchestration()["profiles"]["cursor-dev-standard"]
|
||||
|
||||
argv = worker_profiles.render_worker_argv(
|
||||
profile,
|
||||
"/usr/local/bin/cursor-agent",
|
||||
"/repo/demo",
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
argv,
|
||||
[
|
||||
"/usr/local/bin/cursor-agent",
|
||||
"--model",
|
||||
"cursor-auto",
|
||||
"--auto-review",
|
||||
"--sandbox",
|
||||
"enabled",
|
||||
"--workspace",
|
||||
"/repo/demo",
|
||||
],
|
||||
)
|
||||
self.assertNotIn("--yolo", argv)
|
||||
self.assertNotIn("--force", argv)
|
||||
|
||||
def test_renderer_rejects_wrong_executable_or_unsafe_worktree(self) -> None:
|
||||
profile = valid_orchestration()["profiles"]["codex-dev-standard"]
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
worker_profiles.render_worker_argv(
|
||||
profile,
|
||||
"/tmp/cursor-agent",
|
||||
"/repo/demo",
|
||||
)
|
||||
with self.assertRaises(ValueError):
|
||||
worker_profiles.render_worker_argv(
|
||||
profile,
|
||||
"/usr/local/bin/codex",
|
||||
"/repo/demo/../outside",
|
||||
)
|
||||
for worktree in ("/repo/./demo", "/repo/demo/", "//repo/demo"):
|
||||
with self.subTest(worktree=worktree), self.assertRaises(ValueError):
|
||||
worker_profiles.render_worker_argv(
|
||||
profile,
|
||||
"/usr/local/bin/codex",
|
||||
worktree,
|
||||
)
|
||||
|
||||
|
||||
class ReceiptValidationTests(unittest.TestCase):
|
||||
def test_valid_receipt_is_bound_to_profile_and_routing(self) -> None:
|
||||
routing = valid_orchestration()
|
||||
receipt = valid_receipt(routing)
|
||||
|
||||
self.assertEqual(
|
||||
worker_profiles.validate_worker_receipt(
|
||||
receipt,
|
||||
orchestration=routing,
|
||||
task_ids={"TASK-001"},
|
||||
),
|
||||
[],
|
||||
)
|
||||
self.assertEqual(receipt["receiptHash"], worker_profiles.receipt_hash(receipt))
|
||||
|
||||
def test_receipt_rejects_unknown_fields_at_every_strict_level(self) -> None:
|
||||
cases = (
|
||||
((), "command"),
|
||||
(("createdFor",), "command"),
|
||||
(("worktree",), "command"),
|
||||
(("requested",), "command"),
|
||||
(("binding",), "command"),
|
||||
)
|
||||
for path, field in cases:
|
||||
with self.subTest(path=path):
|
||||
routing = valid_orchestration()
|
||||
receipt = valid_receipt(routing)
|
||||
target = receipt
|
||||
for component in path:
|
||||
target = target[component]
|
||||
target[field] = "forged"
|
||||
receipt["receiptHash"] = worker_profiles.receipt_hash(receipt)
|
||||
|
||||
errors = worker_profiles.validate_worker_receipt(
|
||||
receipt,
|
||||
orchestration=routing,
|
||||
task_ids={"TASK-001"},
|
||||
)
|
||||
|
||||
self.assertTrue(any("unknown field 'command'" in error for error in errors))
|
||||
|
||||
def test_receipt_rejects_profile_and_exact_argv_drift(self) -> None:
|
||||
routing = valid_orchestration()
|
||||
receipt = valid_receipt(routing)
|
||||
receipt["requested"]["model"] = "gpt-safe-strong"
|
||||
receipt["requested"]["argv"][3] = "gpt-safe-strong"
|
||||
receipt["requested"]["argvHash"] = worker_profiles.canonical_sha256(
|
||||
receipt["requested"]["argv"]
|
||||
)
|
||||
receipt["receiptHash"] = worker_profiles.receipt_hash(receipt)
|
||||
|
||||
errors = worker_profiles.validate_worker_receipt(
|
||||
receipt,
|
||||
orchestration=routing,
|
||||
task_ids={"TASK-001"},
|
||||
)
|
||||
|
||||
self.assertTrue(any("requested.model: does not match profile" in error for error in errors))
|
||||
self.assertTrue(
|
||||
any(
|
||||
"requested.argv: does not match exact renderer" in error
|
||||
for error in errors
|
||||
)
|
||||
)
|
||||
|
||||
def test_receipt_rejects_tampering_without_recomputed_hash(self) -> None:
|
||||
routing = valid_orchestration()
|
||||
receipt = valid_receipt(routing)
|
||||
receipt["binding"]["handle"] = "forged-handle"
|
||||
|
||||
errors = worker_profiles.validate_worker_receipt(
|
||||
receipt,
|
||||
orchestration=routing,
|
||||
task_ids={"TASK-001"},
|
||||
)
|
||||
|
||||
self.assertTrue(any("receiptHash: does not match receipt" in error for error in errors))
|
||||
|
||||
def test_receipt_rejects_out_of_budget_attempt_empty_argv_and_id_drift(self) -> None:
|
||||
routing = valid_orchestration()
|
||||
|
||||
attempt = valid_receipt(routing)
|
||||
attempt["createdFor"]["attemptId"] = "TASK-001-A4"
|
||||
attempt["receiptHash"] = worker_profiles.receipt_hash(attempt)
|
||||
attempt_errors = worker_profiles.validate_worker_receipt(
|
||||
attempt,
|
||||
orchestration=routing,
|
||||
task_ids={"TASK-001"},
|
||||
)
|
||||
|
||||
empty_argv = valid_receipt(routing)
|
||||
empty_argv["requested"]["argv"] = []
|
||||
empty_argv["requested"]["argvHash"] = worker_profiles.canonical_sha256([])
|
||||
empty_argv["receiptHash"] = worker_profiles.receipt_hash(empty_argv)
|
||||
argv_errors = worker_profiles.validate_worker_receipt(
|
||||
empty_argv,
|
||||
orchestration=routing,
|
||||
task_ids={"TASK-001"},
|
||||
)
|
||||
|
||||
wrong_id = valid_receipt(routing)
|
||||
wrong_id["id"] = "WR-" + "b" * 64
|
||||
wrong_id["receiptHash"] = worker_profiles.receipt_hash(wrong_id)
|
||||
id_errors = worker_profiles.validate_worker_receipt(
|
||||
wrong_id,
|
||||
orchestration=routing,
|
||||
task_ids={"TASK-001"},
|
||||
)
|
||||
|
||||
self.assertTrue(any("A1..A3" in error for error in attempt_errors))
|
||||
self.assertTrue(any("at least 2 items" in error for error in argv_errors))
|
||||
self.assertTrue(any("must equal 'WR-' + launchId" in error for error in id_errors))
|
||||
|
||||
def test_receipt_rejects_rehashed_launch_fingerprint_tampering(self) -> None:
|
||||
routing = valid_orchestration()
|
||||
receipt = valid_receipt(routing)
|
||||
receipt["launchFingerprint"] = worker_profiles.canonical_sha256(
|
||||
{"forged": True}
|
||||
)
|
||||
receipt["receiptHash"] = worker_profiles.receipt_hash(receipt)
|
||||
|
||||
errors = worker_profiles.validate_worker_receipt(
|
||||
receipt,
|
||||
orchestration=routing,
|
||||
task_ids={"TASK-001"},
|
||||
)
|
||||
|
||||
self.assertTrue(
|
||||
any(
|
||||
"launchFingerprint: does not match launch facts" in error
|
||||
for error in errors
|
||||
)
|
||||
)
|
||||
|
||||
def test_receipt_slot_is_bounded_and_bound_into_launch_fingerprint(self) -> None:
|
||||
routing = valid_orchestration()
|
||||
invalid = valid_receipt(routing)
|
||||
invalid["slot"] = 0
|
||||
invalid["receiptHash"] = worker_profiles.receipt_hash(invalid)
|
||||
invalid_errors = worker_profiles.validate_worker_receipt(
|
||||
invalid,
|
||||
orchestration=routing,
|
||||
task_ids={"TASK-001"},
|
||||
)
|
||||
|
||||
drifted = valid_receipt(routing)
|
||||
drifted["slot"] = 2
|
||||
drifted["receiptHash"] = worker_profiles.receipt_hash(drifted)
|
||||
drifted_errors = worker_profiles.validate_worker_receipt(
|
||||
drifted,
|
||||
orchestration=routing,
|
||||
task_ids={"TASK-001"},
|
||||
)
|
||||
|
||||
self.assertTrue(any("slot: must be" in error for error in invalid_errors))
|
||||
self.assertTrue(
|
||||
any(
|
||||
"launchFingerprint: does not match launch facts" in error
|
||||
for error in drifted_errors
|
||||
)
|
||||
)
|
||||
|
||||
def test_receipt_with_non_json_data_returns_errors(self) -> None:
|
||||
receipt = valid_receipt()
|
||||
receipt["requested"]["argv"] = [object()]
|
||||
|
||||
errors = worker_profiles.validate_worker_receipt(receipt)
|
||||
|
||||
self.assertTrue(any("must contain canonical JSON data" in error for error in errors))
|
||||
|
||||
def test_receipt_rejects_wrong_worktree_task_and_binding(self) -> None:
|
||||
routing = valid_orchestration()
|
||||
receipt = valid_receipt(routing)
|
||||
receipt["worktree"]["path"] = "/repo/other"
|
||||
receipt["binding"]["observedWorktreePath"] = "/repo/elsewhere"
|
||||
receipt["receiptHash"] = worker_profiles.receipt_hash(receipt)
|
||||
|
||||
errors = worker_profiles.validate_worker_receipt(
|
||||
receipt,
|
||||
orchestration=routing,
|
||||
task_ids={"OTHER-TASK"},
|
||||
)
|
||||
|
||||
self.assertTrue(any("unknown task 'TASK-001'" in error for error in errors))
|
||||
self.assertTrue(any("is not in allowedWorktrees" in error for error in errors))
|
||||
self.assertTrue(any("does not match worktree.path" in error for error in errors))
|
||||
|
||||
def test_receipt_requires_safe_environment_and_live_binding(self) -> None:
|
||||
routing = valid_orchestration()
|
||||
receipt = valid_receipt(routing)
|
||||
receipt["requested"]["environmentPolicy"] = "inherit-all"
|
||||
receipt["binding"]["connected"] = False
|
||||
receipt["binding"]["writable"] = False
|
||||
receipt["receiptHash"] = worker_profiles.receipt_hash(receipt)
|
||||
|
||||
errors = worker_profiles.validate_worker_receipt(
|
||||
receipt,
|
||||
orchestration=routing,
|
||||
task_ids={"TASK-001"},
|
||||
)
|
||||
|
||||
self.assertTrue(any("per-cli-allowlist-v1" in error for error in errors))
|
||||
self.assertTrue(any("binding.connected: must be true" in error for error in errors))
|
||||
self.assertTrue(any("binding.writable: must be true" in error for error in errors))
|
||||
|
||||
def test_receipt_list_rejects_duplicate_receipt_and_launch_ids(self) -> None:
|
||||
routing = valid_orchestration()
|
||||
first = valid_receipt(routing)
|
||||
duplicate = copy.deepcopy(first)
|
||||
|
||||
errors = worker_profiles.validate_worker_receipts(
|
||||
[first, duplicate],
|
||||
routing,
|
||||
task_ids={"TASK-001"},
|
||||
)
|
||||
|
||||
self.assertTrue(any("duplicate receipt ID" in error for error in errors))
|
||||
self.assertTrue(any("duplicate launch ID" in error for error in errors))
|
||||
|
||||
def test_document_requires_top_level_receipts_and_routing(self) -> None:
|
||||
missing = {
|
||||
"version": 1,
|
||||
"project": {"name": "demo"},
|
||||
"tasks": [],
|
||||
}
|
||||
|
||||
errors = worker_profiles.validate_routing_document(missing)
|
||||
|
||||
self.assertEqual(errors, ["project.orchestration: is required"])
|
||||
|
||||
def test_valid_document_passes_and_manual_receipts_fail(self) -> None:
|
||||
routing = valid_orchestration()
|
||||
document = {
|
||||
"version": 1,
|
||||
"project": {"name": "demo", "orchestration": routing},
|
||||
"workerReceipts": [valid_receipt(routing)],
|
||||
"tasks": [{"id": "TASK-001", "title": "demo", "status": "open"}],
|
||||
}
|
||||
self.assertEqual(worker_profiles.validate_routing_document(document), [])
|
||||
|
||||
manual = {
|
||||
"version": 1,
|
||||
"project": {
|
||||
"name": "demo",
|
||||
"orchestration": {
|
||||
"profileVersion": 1,
|
||||
"mode": "manual",
|
||||
"allowedWorktrees": [],
|
||||
"modelAllowlist": {},
|
||||
"profiles": {},
|
||||
"defaults": {},
|
||||
},
|
||||
},
|
||||
"workerReceipts": [valid_receipt(routing)],
|
||||
"tasks": [{"id": "TASK-001", "title": "demo", "status": "open"}],
|
||||
}
|
||||
manual_errors = worker_profiles.validate_routing_document(manual)
|
||||
self.assertTrue(
|
||||
any("manual orchestration requires an empty list" in error for error in manual_errors)
|
||||
)
|
||||
|
||||
def test_dispatch_receipt_allows_two_phase_for_exact_task_attempt_binding(self) -> None:
|
||||
routing = valid_orchestration()
|
||||
receipt = valid_receipt(routing)
|
||||
role_dispatch = {
|
||||
"profileId": receipt["profileId"],
|
||||
"receiptId": receipt["id"],
|
||||
"attemptId": "TASK-001-A1",
|
||||
"taskId": None,
|
||||
"dispatchId": None,
|
||||
}
|
||||
document = {
|
||||
"version": 1,
|
||||
"project": {"name": "demo", "orchestration": routing},
|
||||
"workerReceipts": [receipt],
|
||||
"tasks": [
|
||||
{
|
||||
"id": "TASK-001",
|
||||
"title": "created for",
|
||||
"status": "dispatched",
|
||||
"dispatch": {"developer": role_dispatch},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
self.assertEqual(worker_profiles.validate_routing_document(document), [])
|
||||
role_dispatch["taskId"] = "orca-task-002"
|
||||
role_dispatch["dispatchId"] = "orca-dispatch-002"
|
||||
self.assertEqual(worker_profiles.validate_routing_document(document), [])
|
||||
|
||||
def test_dispatch_rejects_unknown_or_mismatched_receipt(self) -> None:
|
||||
routing = valid_orchestration()
|
||||
receipt = valid_receipt(routing)
|
||||
role_dispatch = {
|
||||
"profileId": "codex-dev-strong",
|
||||
"receiptId": receipt["id"],
|
||||
"attemptId": "TASK-001-A1",
|
||||
"taskId": None,
|
||||
"dispatchId": None,
|
||||
}
|
||||
document = {
|
||||
"version": 1,
|
||||
"project": {"name": "demo", "orchestration": routing},
|
||||
"workerReceipts": [receipt],
|
||||
"tasks": [
|
||||
{
|
||||
"id": "TASK-001",
|
||||
"title": "demo",
|
||||
"status": "open",
|
||||
"dispatch": {"developer": role_dispatch},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
errors = worker_profiles.validate_routing_document(document)
|
||||
self.assertTrue(
|
||||
any("profileId: does not match referenced receipt" in error for error in errors)
|
||||
)
|
||||
role_dispatch["receiptId"] = f"WR-{'b' * 64}"
|
||||
errors = worker_profiles.validate_routing_document(document)
|
||||
self.assertTrue(any("unknown receipt" in error for error in errors))
|
||||
|
||||
def test_dispatch_rejects_cross_task_role_and_attempt_receipts(self) -> None:
|
||||
routing = valid_orchestration()
|
||||
receipt = valid_receipt(routing)
|
||||
link = {
|
||||
"profileId": receipt["profileId"],
|
||||
"receiptId": receipt["id"],
|
||||
"attemptId": "TASK-001-A1",
|
||||
"taskId": None,
|
||||
"dispatchId": None,
|
||||
}
|
||||
document = {
|
||||
"version": 1,
|
||||
"project": {"name": "demo", "orchestration": routing},
|
||||
"workerReceipts": [receipt],
|
||||
"tasks": [
|
||||
{"id": "TASK-001", "title": "receipt owner", "status": "open"},
|
||||
{
|
||||
"id": "TASK-002",
|
||||
"title": "must not reuse receipt",
|
||||
"status": "dispatched",
|
||||
"dispatch": {"developer": link},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
errors = worker_profiles.validate_routing_document(document)
|
||||
self.assertTrue(any("must be current ACK task 'TASK-002'" in error for error in errors))
|
||||
self.assertTrue(any("must belong to current ACK task 'TASK-002'" in error for error in errors))
|
||||
|
||||
document["tasks"][1]["id"] = "TASK-001"
|
||||
link["attemptId"] = "TASK-001-A2"
|
||||
errors = worker_profiles.validate_routing_document(document)
|
||||
self.assertTrue(any("does not match referenced receipt" in error for error in errors))
|
||||
|
||||
link["attemptId"] = "TASK-001-A1"
|
||||
document["tasks"][1]["dispatch"] = {"test": link}
|
||||
errors = worker_profiles.validate_routing_document(document)
|
||||
self.assertTrue(any("referenced receipt role must be test" in error for error in errors))
|
||||
|
||||
def test_dispatch_attempt_presence_tracks_receipt_presence(self) -> None:
|
||||
routing = valid_orchestration()
|
||||
receipt = valid_receipt(routing)
|
||||
link = {
|
||||
"profileId": receipt["profileId"],
|
||||
"receiptId": None,
|
||||
"attemptId": "TASK-001-A1",
|
||||
"taskId": None,
|
||||
"dispatchId": None,
|
||||
}
|
||||
document = {
|
||||
"version": 1,
|
||||
"project": {"name": "demo", "orchestration": routing},
|
||||
"workerReceipts": [receipt],
|
||||
"tasks": [
|
||||
{
|
||||
"id": "TASK-001",
|
||||
"title": "demo",
|
||||
"status": "open",
|
||||
"dispatch": {"developer": link},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
errors = worker_profiles.validate_routing_document(document)
|
||||
self.assertTrue(any("must be null when receiptId is null" in error for error in errors))
|
||||
|
||||
link["receiptId"] = receipt["id"]
|
||||
link["attemptId"] = None
|
||||
errors = worker_profiles.validate_routing_document(document)
|
||||
self.assertTrue(any("is required when receiptId is set" in error for error in errors))
|
||||
|
||||
def test_dispatch_requires_receipt_before_paired_runtime_ids(self) -> None:
|
||||
routing = valid_orchestration()
|
||||
document = {
|
||||
"version": 1,
|
||||
"project": {"name": "demo", "orchestration": routing},
|
||||
"workerReceipts": [],
|
||||
"tasks": [
|
||||
{
|
||||
"id": "TASK-001",
|
||||
"title": "demo",
|
||||
"status": "dispatched",
|
||||
"dispatch": {
|
||||
"developer": {
|
||||
"profileId": "codex-dev-standard",
|
||||
"receiptId": None,
|
||||
"attemptId": None,
|
||||
"taskId": "orca-task-001",
|
||||
"dispatchId": None,
|
||||
}
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
errors = worker_profiles.validate_routing_document(document)
|
||||
|
||||
self.assertTrue(any("must both be null or both be set" in error for error in errors))
|
||||
self.assertTrue(any("required before runtime dispatch IDs" in error for error in errors))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user