diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5ce29f9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,93 @@ +name: CI + +on: + push: + branches: + - main + - "dev/**" + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + unit-tests: + name: Unit tests (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: + - "3.10" + - "3.12" + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + - name: Install package and development tools + run: python -m pip install --upgrade pip ".[dev]" + + - name: Run strict unit test suite + run: python -W error -m unittest discover -s tests + + teammate-resilience: + name: Teammate resilience release gate + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Install package and development tools + run: python -m pip install --upgrade pip ".[dev]" + + - name: Run resilience evaluation matrix + run: python teammate-evals/runtime-resilience/evaluate.py + + package: + name: Build wheel + needs: + - unit-tests + - teammate-resilience + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Install build tooling + run: python -m pip install --upgrade pip build + + - name: Build wheel + run: python -m build --wheel + + - name: Upload wheel + uses: actions/upload-artifact@v4 + with: + name: clawd-codex-wheel + path: dist/*.whl + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 6e1a6aa..bc28551 100644 --- a/.gitignore +++ b/.gitignore @@ -79,6 +79,12 @@ CLAUDE.md sessions/ .transcripts/ .port_sessions/ +.clawd/team.json +.clawd/teams/ +teammate-evals/solo-vs-team/runs/ +teammate-evals/nl2repo-pilot/runs/ +teammate-evals/nl2repo-pilot/latency-runs/ +teammate-evals/nl2repo-pilot/reports/ # Claude Code personal settings CLAUDE.local.md diff --git a/CODEPEERS_IMPLEMENTATION_PROMPT.md b/CODEPEERS_IMPLEMENTATION_PROMPT.md new file mode 100644 index 0000000..caf556f --- /dev/null +++ b/CODEPEERS_IMPLEMENTATION_PROMPT.md @@ -0,0 +1,378 @@ +# Clawd-Code:Peer-Native Coding Agent Collaboration 实现任务 + +你正在修改 Clawd-Code。请直接完成下面的代码、测试和文档工作,不要只给设计建议。 + +## 0. 研究目标与硬约束 + +我们要研究的问题是: + +> 今天的 coding agents 究竟具不具备 peer collaboration intelligence,还是只能充当被 workflow 调用的独立执行器? + +这里评测的是 Claude Code、Codex 这一类能够读写仓库、执行命令、持续行动的完整 coding agent,而不是一个由 Planner/Coder/Reviewer 节点组成的预编排 workflow。 + +硬约束: + +1. **不训练任何模型**。不要加入训练、微调、强化学习、学习型 router 或 learned policy。 +2. Peer 模式中不存在有特权的 LLM manager/lead。运行基础设施可以有非智能 supervisor,但它只能负责启动、资源限制、日志、超时和停止,不能替 agents 做任务分解、分配、冲突解决或答案选择。 +3. 所有 peers 接收同一个顶层任务、同一套非通信工具、同等级权限。除稳定的 peer ID/name 外,不给任何 peer 预设 Planner、Coder、Reviewer 等角色。 +4. 不预先创建 task DAG,不给 peer 分配 owned task,不通过 prompt 暗示固定分工,不硬编码协作策略。 +5. 保留现有 lead-controlled teammate/team workflow 及其兼容性。新增 peer-native mode,不要把原有模式强行改成另一种语义。 +6. 不要通过简单解除现有 teammate 的 forbidden tools 来伪造 peer mode。Peer mode 应有清楚、独立的控制面和权限语义。 +7. 不得破坏或覆盖当前 dirty worktree 中与本任务无关的用户修改;禁止 destructive git 操作。 + +## 1. 开始修改前 + +先完成以下检查,再给出一个简短实施计划并开始实现: + +- 阅读现有 teammate/team runtime、store、message tools、task tools、CLI、trace/event、workspace/worktree 和相关测试。 +- 检查 `git status --short`,区分现有修改和本任务修改。 +- 运行当前 teammate 相关测试作为 baseline: + +```bash +.venv/bin/python -m pytest -q \ + tests/test_teammate_runtime.py \ + tests/test_teammate_store.py \ + tests/test_teammate_resilience.py +``` + +- 优先复用现有可靠的 session、message persistence、workspace、provider、tool registry 和 trace 能力,但不要把 `lead_agent_id` 偷换成一个“名义上是 peer、实际上有特权”的 agent。 + +## 2. 需要实现的核心语义 + +### 2.1 新增固定 N 的 peer-native run + +实现一个独立、明确命名的 peer collaboration mode。具体类名和模块位置可根据现有架构选择,但代码中的概念应能区分: + +- 旧的 lead-controlled `TeamRun`; +- 新的 peer-native run; +- 非 LLM 的运行 supervisor; +- 地位完全相同的 peer participants。 + +第一版使用固定 N 个 peers 即可。动态招募、动态扩缩容和 peer 创建 peer 暂不作为必需功能。 + +Peer run 启动时: + +1. supervisor 创建 N 个独立、持久的 agent sessions; +2. 所有 peers 并发启动,而不是逐个串行执行; +3. 所有 peers 获得同一个顶层 mission; +4. prompt 不包含预设角色、子任务、owner、依赖关系或建议分工; +5. 每个 peer 只额外知道自己的稳定 ID/name、团队 roster 的获取方式以及可用通信协议; +6. peer 在一次局部工作完成后不能像 task worker 一样立即永久退出,而应保持可唤醒状态,直到 run 被提交、取消、超时或耗尽预算。 + +建议将 peer system context 保持中性,例如只说明:你是平等的 coding peer;你可以自主检查仓库、决定工作、与其他 peers 协调;任何 peer 都可以发起最终提交。不要告诉它应该如何分工。 + +### 2.2 最小 P2P 接口 + +Peer mode 至少提供以下能力: + +#### `PeerList` + +- 返回当前 run 中可通信的 peers、稳定 ID/name 和粗粒度 lifecycle status; +- 不返回 supervisor 规划出的任务、角色或“你应该联系谁”的建议; +- roster 对所有 peers 一致,不存在隐藏的 lead 权限。 + +#### `SendMessage` + +- 允许任意 peer 直接给任意其他 peer 发消息; +- 不需要经过 lead 转发; +- 验证 sender/recipient 都属于当前 run; +- 未知 recipient、越权通信和非法 payload 应明确报错; +- 每条消息有稳定 ID、sender、recipient、创建/投递时间和消费状态; +- 保留现有 team mode 的兼容行为。 + +#### `ReadMessages` + +- 读取当前 peer 的 inbox; +- 明确定义 unread/consumed 语义,重复读取不能造成消息无意丢失或重复执行; +- 支持非 busy-polling 的等待方式; +- peer 从 idle 被消息唤醒后,下一次 model boundary 必须能看到这条消息。 + +#### `Broadcast` + +- 向当前 run 中除自己外的所有可通信 peers 发送同一消息; +- 对每个 recipient 有可审计的 delivery/consumption 记录; +- 不给 sender 自己投递; +- 重试时要有清楚的幂等规则,不能静默重复广播。 + +#### `TeamSubmit`(或语义等价的 `PeerSubmit`) + +- 任意 peer 都可以提交最终结果; +- 参数至少包含最终 commit hash 或可验证的 workspace revision,以及简短说明; +- supervisor 验证 revision 属于本次允许的仓库/工作空间且真实存在; +- 第一份原子接受的有效提交结束 run,后续并发提交返回同一个已接受结果或清楚的 already-submitted 状态; +- 记录 submitting peer、revision、时间和验证结果; +- 不要求预设 lead 才能提交,也不要由隐藏 judge 在多个候选中替 agents 做智能选择。 + +工具命名可以与现有风格协调,但用户可见语义必须完整。 + +### 2.3 Persistent、event-driven peer loop + +当前 task-bounded worker “完成当前 task 后退出/idle” 的语义不够。Peer mode 需要: + +- peer session 在 run 生命周期内持续存在; +- 没有可做工作时进入可观测 idle 状态; +- 收到新消息时可被事件驱动地唤醒,而不是只能依靠 agent 碰巧轮询 inbox; +- 不用高频 polling 或 busy loop; +- run submit/cancel/timeout/budget exhausted 时能够干净停止所有 peers; +- 停止后不能继续执行 tool call; +- 异常 peer 不应导致其他 peer 或 supervisor 永久死锁; +- 并发发送、读取、广播、提交必须线程安全;若当前 backend 只支持线程,要明确隔离边界并为未来独立进程 backend 留出接口。 + +第一版允许复用现有线程池,但不要在文档中把同进程线程描述成强进程隔离。真实 Claude Code/Codex CLI 进程适配器可放在后续阶段。 + +### 2.4 Workspace 与集成语义 + +同时保留两种实验能力: + +- `shared`:所有 peers 直接操作同一工作区,用于研究 contention/race; +- `worktree`:每个 peer 有独立 worktree,用于更可控的实验。 + +Peer benchmark 的科学默认值建议使用独立 worktree,并遵循: + +- 不启用旧 team workflow 的隐藏 `auto_integrate`; +- peers 通过消息交换接口、文件、分支或 commit 信息; +- peers 自己使用正常 git 操作整合彼此工作; +- 最终由任意 peer 使用 `TeamSubmit(revision=...)` 提交一个可验证 revision; +- trace 能把 commit/worktree 与 peer 对应起来; +- shared 模式和 worktree 模式都必须有清楚的 teardown,不残留失控 worker。 + +### 2.5 Agent backend 抽象 + +不要让 peer benchmark 永久耦合到单一 provider 或当前内部 agent loop。 + +请定义尽量小的 peer runner/session adapter 边界: + +- 当前 Clawd agent loop 是第一个可用 backend; +- 测试中可注入 deterministic/scripted fake backend; +- 后续可以接 Claude Code CLI、Codex CLI 等完整 coding agent 进程; +- 本次不要求真的实现所有外部 CLI adapter,但接口和生命周期不能阻止它们接入。 + +## 3. 实验条件必须由协议控制,不靠角色 prompt 模拟 + +为 benchmark 增加 communication policy/condition。至少支持并测试: + +1. `solo`:1 个 agent; +2. `independent` / `none`:N 个 agent,同一个顶层任务,但无 peer 消息; +3. `artifact-only`:N 个 agent,无消息工具,仅通过实验允许的仓库/artifact 可见性协作; +4. `star`:N 个 agent,只有指定 coordinator peer 可以与其他 peers 通信,普通 peers 不能直接互发; +5. `p2p`:N 个 agents 可任意 direct message 和 broadcast。 + +要求: + +- policy 在 tool registry/transport ACL 层执行,不是只在 prompt 里写“请不要通信”; +- 对同一 backend 和同一实验,除 communication policy 所必需的工具差异外,非通信工具、顶层任务、模型设置、预算口径应一致; +- `star` 中 coordinator 是实验通信拓扑中的普通 agent 节点,不获得额外代码权限、预算或 supervisor 控制权; +- 非法边必须被拒绝并留下 trace; +- 设计上允许以后加入局部图、带宽限制、延迟和消息成本,但本次不必全部实现。 + +如果一次改动无法安全完成全部条件,优先级为:`p2p`、`artifact-only`、`independent`、`solo`、`star`。不过交付时必须明确未完成项,不能用 prompt 约束假装协议已经实现。 + +## 4. CLI / API + +为 peer run 增加一个最小、可复现的入口。命令形式可以服从现有 CLI 规范,功能上至少能表达: + +```text +run peer collaboration + --repo /path/to/repo + --prompt-file TASK.md + --peers N + --communication solo|independent|artifact-only|star|p2p + --workspace-mode shared|worktree + --model ... + --timeout-seconds ... + --max-turns ... + --token-budget ... + --output-dir ... +``` + +要求: + +- 所有会影响结果的配置写入 run manifest; +- 支持非交互运行和明确 exit code; +- API/CLI 参数校验覆盖 N、condition、workspace、预算和路径; +- 不影响现有 team CLI; +- 文档给出一个无需真实 API 的 scripted smoke 示例,以及一个真实模型运行示例。 + +## 5. Trace、结果格式与可计算指标 + +Peer benchmark 首先要保证原始事实可审计,不要在 runtime 中强行判断“这次协作好不好”。 + +每次 run 至少记录: + +- run ID、condition、N、model/provider、任务/仓库 revision、workspace mode; +- 每个 peer 的 session ID、start/idle/wake/stop/error 时间; +- model call、tool call、输入/输出/cache tokens、预算消耗; +- message created/delivered/consumed,sender、recipient、message ID、时间戳、payload 大小; +- broadcast ID 与实际 recipients; +- policy rejection; +- worktree/branch/commit 与 peer 的关联; +- submit attempt、accepted submit、revision、submitting peer; +- timeout、cancel、budget exhaustion 和异常; +- 最终 wall-clock time、aggregate tokens/calls、验收测试结果。 + +事件同时记录 wall-clock 和适合计算延迟的 monotonic 时间(或等价可靠设计)。结果应能离线计算: + +- direct P2P edge 和通信图; +- delivery/consumption/response latency; +- message volume 与成本; +- accepted solution quality; +- wall time、总 token、每 peer token; +- commit/work attribution; +- 重复工作、stale work、冲突和 rework 的代理指标; +- P2P 相对 `solo`、`independent`、`artifact-only`、`star` 的差异。 + +不要把单次运行的差异直接宣称为 scaling law 或 causal result。统计聚合由 benchmark analysis 层完成。 + +## 6. Benchmark 目录 + +在现有 eval 结构中新增一个名称清楚的目录,例如: + +```text +teammate-evals/peer-collaboration/ +``` + +至少包含: + +- `README.md`:研究问题、非训练设定、五种 condition、运行方式、输出字段、限制; +- run/config schema 或等价配置; +- 可重复的 scripted smoke fixture; +- 调用 peer runtime 的 runner; +- 对结果 JSON/JSONL 的 schema 验证; +- 一个 coupled task 示例,必须需要至少一次接口对齐、信息传播或对 peer 工作的适应,不能只是两个完全独立文件的机械拼接; +- real-model pilot 入口默认不在普通单元测试中运行,必须显式 opt-in。 + +可以复用 `teammate-evals/nl2repo-pilot` 的仓库任务和 acceptance-test 方式,但不要复用其中 lead 预先分配 task 的控制逻辑。 + +## 7. 必须添加的测试 + +测试默认不得依赖网络、真实 API key 或非确定模型。使用 scripted/fake backend 测 runtime 语义;真实模型只做 opt-in pilot。 + +### 7.1 单元测试 + +覆盖: + +- 所有 peers 看到同一 roster,且没有隐藏 lead 权限; +- peer A 可直接给 peer B 发消息,不经过第三方; +- unknown/self/跨 run recipient 的明确行为; +- unread、consumed、重复 read 的语义; +- broadcast 恰好投递给每个其他 peer 一次,不给自己; +- broadcast 重试/幂等; +- message persistence 在 store reload 后仍正确; +- `independent`、`artifact-only`、`star`、`p2p` 的 ACL; +- 非法通信产生 rejection event; +- 任意 peer 均可 submit; +- invalid revision 被拒绝; +- 两个并发有效 submit 只有一个原子胜出,结果可重复读取; +- prompt/context 不含预设 Planner/Coder/Reviewer、owned task 或 DAG; +- peer 间除 ID 外的工具和权限对称; +- 现有 team mode 的 `SendMessage` / `ReadMessages` 行为保持兼容。 + +### 7.2 并发与生命周期测试 + +覆盖: + +- N 个 peers 实际重叠执行,且 session ID 独立; +- recipient 已 idle 后收到消息会被唤醒,并在下一 model boundary 看到消息; +- 多个 peers 同时 send/read 不丢失、不重复、不死锁; +- 同时 broadcast 的 delivery 数正确; +- 一个 peer submit 后,其余 peers 被干净停止; +- accepted submit 之后不再出现新的业务 tool call; +- timeout、cancel、peer crash、budget exhaustion 不留下 orphan worker; +- worktree 模式下 peer A 的 commit 可被 peer B 正常获取/整合,最终 revision 可验证; +- shared 模式的并发行为至少有一个 race-safe smoke test; +- store/event 写入在并发下不产生损坏 JSON 或丢事件。 + +### 7.3 Benchmark protocol 测试 + +覆盖: + +- 五种 conditions 能从同一任务配置生成,模型/预算/非通信工具保持可比较; +- `artifact-only` 和 `independent` 不应意外暴露 message tools; +- `star` 的 worker→worker 被拒绝,worker↔coordinator 被允许; +- `p2p` trace 中可以出现非 coordinator 的 peer↔peer edge; +- manifest 完整记录所有实验参数; +- result schema 可验证; +- acceptance test 的 stdout/stderr/exit code 被保存; +- scripted coupled-task smoke 中,两个 peers 可通过消息完成一次接口协商并提交有效结果; +- 不要在单元测试中断言真实模型一定会表现出“聪明协作”,只断言机制、可观测性和协议正确。 + +### 7.4 回归测试 + +至少运行: + +```bash +.venv/bin/python -m pytest -q \ + tests/test_teammate_runtime.py \ + tests/test_teammate_store.py \ + tests/test_teammate_resilience.py + +.venv/bin/python -m pytest -q <新增的 peer runtime/tool/protocol 测试> + +.venv/bin/python -m pytest -q +``` + +如果项目配置了 Ruff/type checker,也运行与改动文件对应的 lint/type check。不要通过放宽全局规则、删除断言或跳过测试来获得绿色结果。 + +## 8. 建议的实现阶段 + +### P0:保护兼容性 + +- 固定现有 teammate tests 的 baseline; +- 将 peer mode 与 lead-controlled mode 的状态和权限分开; +- 复用组件时保持旧 API/serialization 兼容。 + +### P1:Peer runtime MVP(本任务必需) + +- 固定 N、对等 session、统一 mission; +- `PeerList`、direct send/read、broadcast、submit; +- persistent idle/wake loop; +- `p2p`、`artifact-only`、`independent`、`solo`; +- shared/worktree; +- manifest、trace、scripted tests。 + +### P2:完整 benchmark protocol(尽量在本任务完成) + +- `star` ACL; +- benchmark 目录、coupled fixture、result schema; +- CLI、文档、全量回归。 + +### 暂不要求 + +- 训练或 learned policy; +- 动态创建/销毁 peers; +- 自动选择最优拓扑; +- 真正的网络分布式执行; +- 完整 Claude Code/Codex CLI adapters; +- 大规模真实模型实验或论文结论。 + +## 9. 验收标准(Definition of Done) + +只有同时满足以下条件才算完成: + +- [ ] 新 peer mode 中不存在有特权的 LLM lead/manager; +- [ ] peers 的任务、非通信工具、权限和预算口径对称,除 peer identity 外没有预设角色; +- [ ] runtime 不创建 owned tasks 或预定义 task DAG; +- [ ] peer A 能直接联系 peer B,消息可持久化、审计并唤醒 idle peer; +- [ ] broadcast、communication ACL 和并发 submit 行为确定且有测试; +- [ ] 任意 peer 能提交最终 revision,提交后所有 peers 干净停止; +- [ ] `solo`、`independent`、`artifact-only`、`p2p` 可运行;`star` 完成或明确列为唯一剩余协议项; +- [ ] shared/worktree 语义清楚,peer mode 不使用隐藏 auto-integrate; +- [ ] 默认测试不依赖真实模型或网络; +- [ ] 新测试通过,原有 teammate 测试和全量测试无回归; +- [ ] README 能让另一位研究者复现实验并理解其局限; +- [ ] 没有训练代码、固定角色 workflow 或通过 prompt 假装实现 ACL; +- [ ] 没有覆盖当前 worktree 中无关的用户修改。 + +## 10. 最终交付回复格式 + +实现完成后,请给出: + +1. 实际实现了哪些语义; +2. 关键文件列表; +3. 运行过的测试命令和结果; +4. 一个 scripted smoke 命令; +5. 一个真实模型 pilot 命令(不要实际消耗 API,除非明确授权); +6. 仍存在的限制,尤其是线程/进程隔离、外部 agent adapter 和未完成的 condition; +7. 说明如何确认 peer mode 中没有隐藏 lead、固定角色或 task DAG。 + +如果某个要求与现有架构冲突,不要静默弱化要求。先用代码证据说明冲突,再选择最小且兼容的设计;只有在会显著改变研究语义时才停下来询问。 diff --git a/FEATURE_LIST.md b/FEATURE_LIST.md index 8c1dc02..243be59 100644 --- a/FEATURE_LIST.md +++ b/FEATURE_LIST.md @@ -43,6 +43,11 @@ | Token / Cost 跟踪 | 🚫 | 当前聊天 CLI 尚未形成完整统计视图 | | 上下文构建 | 🟡 | 已有 `context_system` 基础版,支持 workspace / git / `CLAUDE.md` 注入,仍缺 README 摘要、memory、compact | | Claude Code Agent Loop | ✅ | 已实现 agent_loop.py,支持工具调用循环 | +| 非交互任务执行 | ✅ | `clawd run` 支持 prompt 文件、stdin、workspace、模型与 turn 限制 | +| Teammate Runtime | ✅ | 持久化团队、独立会话、lease 恢复、重试、并行调度、预算、整队取消、单 worker 停止与消息交接 | +| Teammate 控制面 | ✅ | `clawd team` 支持 list/status/stop/resume-worker/reassign/cancel/resume | +| Teammate Trace Viewer | ✅ | `clawd trace` 提供实时事件、工具调用、消息与任务依赖可视化 | +| Teammate Worktree | ✅ | 可选 Git worktree 隔离,支持自动或 lead 手动整合 | | `/resume` 会话恢复体验 | 🚫 | 暂无独立恢复流程与 UI | | `/compact` 对话压缩 | 🚫 | 暂无自动/手动压缩能力 | | `/doctor` 诊断系统 | 🚫 | 暂无环境、配置、权限、依赖诊断命令 | @@ -132,6 +137,10 @@ | Provider 测试 | ✅ | `test_providers.py` (113 行) | | 输出样式测试 | ✅ | `test_output_styles.py` (64 行) | | 配置测试 | ✅ | `test_config.py` | +| Teammate runtime / trace 测试 | ✅ | `test_teammate_runtime.py`、`test_teammate_viewer.py` | +| Teammate resilience 评测 | ✅ | crash-resume、retry、review-reject、cancel、worker-stop、budget、parallel、worktree | +| Solo vs Adaptive Team benchmark | ✅ | Lead 自主决定是否组队、角色与拓扑;5 个隔离任务、确定性评分和成本指标 | +| 非交互 runner 测试 | ✅ | `test_runner.py` | ## 路线图 diff --git a/README.md b/README.md index f109e37..60bbc8a 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,8 @@ clawd # Start REPL clawd login # Configure API clawd --version # Check version clawd config # View settings +clawd config --use glm5 # Switch to Z.ai GLM-5.2 profile +clawd config --use qwen3.5 # Switch to Tencent TI-ONE Qwen 3.5 profile ``` *** @@ -145,7 +147,7 @@ clawd config # View settings |--------|--------|-------------| | CLI Entry | ✅ | `clawd`, `login`, `config`, `--version` | | Interactive REPL | ✅ | Rich interactive output, history, tab completion, multiline | -| Multi-Provider | ✅ | Anthropic, OpenAI, GLM support | +| Multi-Provider | ✅ | Anthropic, OpenAI, GLM, Qwen/TI-ONE support | | Session Persistence | ✅ | Save/load sessions locally | | Agent Loop | ✅ | Tool calling loop implementation | | Skill System | ✅ | SKILL.md-based slash-command skills with args + tool limits | @@ -209,6 +211,19 @@ This flow will: 4. optionally save a default model 5. set the selected provider as default +For the preconfigured benchmark model profiles, switching does not require +re-entering keys: + +```bash +clawd config --use glm5 +clawd config --use qwen3.5 +``` + +The Qwen profile uses the TI-ONE service group ID `ms-mnhdj86z` as its model +and the service URL ending in `/ms-mnhdj86z/v1`. Store the service AuthToken +through `clawd login` or `QWEN_API_KEY`; it is sent as the Authorization header +value and is never committed to the repository. + The configuration file is saved in in `~/.clawd/config.json`. Example structure: ```json @@ -239,8 +254,20 @@ The configuration file is saved in in `~/.clawd/config.json`. Example structure: ```bash python -m src.cli # Start REPL python -m src.cli --help # Show help +clawd run -C ./project --prompt-file TASK.md # Run one task non-interactively +clawd trace ./project # Inspect teammate traces locally +clawd peer run --repo ./project --prompt-file TASK.md --peers 2 --communication p2p --workspace-mode worktree ``` +`clawd run` also accepts a quoted prompt or piped stdin. Local file operations +are scoped to the selected workspace, progress is written to stderr, and the +final answer is written to stdout for scripting and CI use. Provider credentials +can come from `clawd login` or standard environment variables such as +`ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_BASE_URL`, and `OPENAI_API_KEY`. +Peer-native runs create equal persistent sessions without a lead or owned task +graph. See `teammate-evals/peer-collaboration/README.md` for conditions, +scripted smoke tests, output schemas, and real-model pilot commands. + **That's it!** Start chatting with AI in 3 steps. *** @@ -259,6 +286,89 @@ python -m src.cli --help # Show help | `/clear` | Clear history | | `/exit` | Exit REPL | +### Teammate Workflows + +The lead can create persistent teammates, assign dependency-aware tasks, and +run them with bounded concurrency. + +The lead first decides whether collaboration is worthwhile; using no team is a +valid outcome. When delegation helps, the lead chooses any task-specific roles, +models, tool allowlists, workspace modes, dependencies, and concurrency. There +is no required planner/coder/reviewer pipeline. Teammates can communicate +directly with one another using `SendMessage` and poll peer replies with +`ReadMessages`, so the communication topology emerges from the work. + +```text +TeamCreate -> TeammateCreate -> TaskCreate -> TeamRun +``` + +For repository-generation work, enable `quality_gates` on `TeamCreate` and use the +protocol-v2 atomic workflow: + +```text +TeamCreate -> TeamPlan -> TeamRun +``` + +`TeamPlan` atomically replaces the complete contract, worker set, task DAG, validation +profile, and execution budget. It requires two independently runnable implementation +owners, concrete non-overlapping `owned_files`, behavioral `acceptance_checks`, and +explicit frozen or handoff interfaces. Worker writes are checked against their current +task ownership. `TeamRun` owns produced-to-accepted transitions and automatically creates +a fresh environment for install, import, and integration verification before the Team can +transition to `completed`. A validation or ownership failure enters `repair_required` and +can continue only after a new `TeamPlan` revision. `TeamAbort` is the explicit terminal +escape hatch. The incremental `TeamConfigure`/`TeammateCreate`/`TaskCreate` flow remains +available only for protocol-v1 teams. + +Creation tools return structured `next_required_actions`; creating a teammate +does not start it. A worker runs only after it owns a task and the lead calls +`TeamRun`. If the lead tries to finish with an active team that has not settled, +the agent loop returns a lifecycle warning and requires the lead to run, resume, +or explicitly delete the team first. + +For protocol v1, `TeamRun` accepts `max_workers`, `max_batches`, `max_retries`, +`lease_timeout_s`, `timeout_s`, `token_budget`, and `turn_budget`. Use +`TeamResume` to recover expired leases or +resume a failed/cancelled team, `TaskRetry` for an explicit task retry, and +`TeamCancel` for cooperative cancellation. Every state transition, model call, +tool call, and message handoff is persisted for `clawd trace`. +Set `max_batches` to return control after a bounded number of scheduling batches +so the lead can inspect progress, add or reassign tasks, adjust dependencies, +stop or resume workers, and then continue the team. + +Only the lead may call `TeammateStop` or `TeammateResume`. `TeammateStop` stops +one worker without cancelling the team and applies `task_policy: "requeue"` +(the default) or `task_policy: "cancel"` to unfinished work. Active model and +tool calls stop cooperatively at the next safe boundary; they are not force-killed. + +Human operators can inspect and control the same persisted state without a lead +model round trip. The worker lifecycle and process-based force-stop migration +are documented in `docs/guide/TEAMMATE_WORKER_LIFECYCLE.md`: + +```bash +clawd team list -C ./project +clawd team status -C ./project +clawd team stop coder --task-policy requeue -C ./project +clawd team resume-worker coder -C ./project +clawd team reassign implementation replacement-coder -C ./project +clawd team cancel --reason "operator request" -C ./project +clawd team resume --provider anthropic --model glm-5.2 -C ./project +``` + +The atomic repository-generation protocol, ownership enforcement, repair lifecycle, +and Q/P/E evaluation policy are documented in `docs/guide/TEAM_PROTOCOL_V2.md`. + +Set `workspace_mode: "worktree"` on `TeammateCreate` for git isolation. With +`auto_integrate: true`, successful changes are committed in the isolated +worktree and cherry-picked into the lead repository. Downstream reviewers that +must inspect newly integrated changes should use the shared workspace. The +resilience evaluator is in `teammate-evals/runtime-resilience/`. + +The five-scenario real-model benchmark in `teammate-evals/solo-vs-team/` +compares solo and adaptive lead-controlled execution on identical business +tasks. It records acceptance quality, elapsed time, token use, model/tool calls, +and collaboration evidence in isolated workspaces. + ### Skills (Slash Commands) Skills are markdown-based slash commands stored under `.clawd/skills`. Each skill lives in its own directory and must be named `SKILL.md`. @@ -558,6 +668,8 @@ clawd # 启动 REPL clawd login # 配置 API clawd --version # 检查版本 clawd config # 查看设置 +clawd config --use glm5 # 切换到 Z.ai GLM-5.2 +clawd config --use qwen3.5 # 切换到腾讯 TI-ONE Qwen 3.5 ``` *** @@ -577,7 +689,7 @@ clawd config # 查看设置 |------|------|------| | CLI 入口 | ✅ | `clawd`、`login`、`config`、`--version` | | 交互式 REPL | ✅ | 丰富的交互输出、历史记录、Tab 补全、多行输入 | -| 多提供商支持 | ✅ | 支持 Anthropic、OpenAI、GLM | +| 多提供商支持 | ✅ | 支持 Anthropic、OpenAI、GLM、Qwen/TI-ONE | | 会话持久化 | ✅ | 本地保存/加载会话 | | Agent Loop | ✅ | 工具调用循环实现 | | Skill 系统 | ✅ | 基于 SKILL.md 的 /skill 技能:参数替换 + 工具限制 | @@ -641,6 +753,17 @@ python -m src.cli login 4. 可选:保存默认 model 5. 将该 provider 设为默认 +两个评测模型可以直接切换,不需要重复录入已有密钥: + +```bash +clawd config --use glm5 +clawd config --use qwen3.5 +``` + +Qwen profile 使用 TI-ONE 服务组 ID `ms-mnhdj86z` 作为模型 ID,Base URL +以 `/ms-mnhdj86z/v1` 结尾。请通过 `clawd login` 或 `QWEN_API_KEY` 配置服务 +AuthToken;密钥只保存在用户配置中,不会写入仓库。 + 配置文件会保存在 `~/.clawd/config.json`。示例结构: ```json @@ -671,8 +794,19 @@ python -m src.cli login ```bash python -m src.cli # 启动 REPL python -m src.cli --help # 显示帮助 +clawd run -C ./project --prompt-file TASK.md # 非交互执行单次任务 +clawd trace ./project # 在本地查看 teammate 运行轨迹 +clawd peer run --repo ./project --prompt-file TASK.md --peers 2 --communication p2p --workspace-mode worktree ``` +`clawd run` 也支持直接传入 prompt 或从 stdin 读取。本地文件操作范围限制在指定 +workspace 内,执行进度输出到 stderr,最终回答输出到 stdout,便于脚本和 CI 使用。 +Provider 凭据既可来自 `clawd login`,也可使用 `ANTHROPIC_AUTH_TOKEN`、 +`ANTHROPIC_BASE_URL`、`OPENAI_API_KEY` 等标准环境变量。 +Peer-native 模式会创建没有 lead 和 owned task graph 的平等持久 session。五种实验 +condition、scripted smoke、输出 schema 与真实模型命令见 +`teammate-evals/peer-collaboration/README.md`。 + **就这样!** 3 步开始与 AI 对话。 *** @@ -691,6 +825,75 @@ python -m src.cli --help # 显示帮助 | `/clear` | 清空历史 | | `/exit` | 退出 REPL | +### Teammate 工作流 + +Lead 可以创建持久化 teammate、分配带依赖的任务,并限制并行度执行。 + +Lead 会先判断协作是否值得;完全不创建 team 也是正确结果。需要分工时,Lead 根据 +任务自行决定任意角色、模型、工具权限、workspace、依赖和并行度,不要求固定的 +planner/coder/reviewer 流水线。Teammate 可通过 `SendMessage` 直接相互通信,并用 +`ReadMessages` 获取 peer 回复,因此通信拓扑由实际工作自然产生。 + +```text +TeamCreate -> TeammateCreate -> TaskCreate -> TeamRun +``` + +仓库生成任务建议在 `TeamCreate` 开启 `quality_gates`,使用 protocol v2 原子链路: + +```text +TeamCreate -> TeamPlan -> TeamRun +``` + +`TeamPlan` 会一次性原子替换完整契约、worker、任务 DAG、验证配置和执行预算。严格 +模式要求至少两个可立即并行的真实实现 owner、明确且不重叠的 `owned_files`、行为级 +`acceptance_checks`,以及 frozen/handoff 接口。worker 的实际写入也会按当前任务所有权 +检查。`TeamRun` 负责从 produced 到 accepted 的转换,并自动在新虚拟环境中完成安装、 +import smoke 和 integration 三段验证;验证或越界写入失败会进入 `repair_required`,只能 +提交新的 `TeamPlan` revision 后继续。`TeamAbort` 是不可恢复的终止操作。增量式 +`TeamConfigure`/`TeammateCreate`/`TaskCreate` 链路仅为 protocol v1 保留。 + +创建类工具会返回结构化的 `next_required_actions`;创建 teammate 并不等于启动它。 +只有 worker 已拥有任务且 Lead 调用 `TeamRun` 后才会执行。如果 Lead 在 active team +尚未收敛时尝试结束,agent loop 会返回生命周期警告,要求先运行、恢复或显式删除 team。 + +protocol v1 的 `TeamRun` 支持 `max_workers`、`max_batches`、`max_retries`、`lease_timeout_s`、 +`timeout_s`、`token_budget` 和 `turn_budget`。`TeamResume` 用于恢复过期 lease 或失败/取消的团队, +`TaskRetry` 显式重试单个任务,`TeamCancel` 执行协作式取消。所有状态迁移、模型调用、 +工具调用和消息交接都会持久化,可通过 `clawd trace` 查看。 +设置 `max_batches` 可在执行限定批次后把控制权交还 Lead,供其检查进度、追加或重派 +任务、调整依赖、停止或恢复 worker,然后继续运行。 + +只有 Lead 可以调用 `TeammateStop` 和 `TeammateResume`。`TeammateStop` 只停止一个 +worker,不会取消整个团队;未完成任务可选择默认的 `task_policy: "requeue"`,或使用 +`task_policy: "cancel"`。当前停止会在模型/工具调用的下一个安全边界生效,不会伪装成 +能够强杀线程中的 HTTP 或 Bash 调用。 + +人类操作者也可以直接控制同一份持久化状态,无需额外消耗 Lead 模型回合。 +完整生命周期与进程级 force-stop 迁移设计见 +`docs/guide/TEAMMATE_WORKER_LIFECYCLE.md`: + +```bash +clawd team list -C ./project +clawd team status -C ./project +clawd team stop coder --task-policy requeue -C ./project +clawd team resume-worker coder -C ./project +clawd team reassign implementation replacement-coder -C ./project +clawd team cancel --reason "operator request" -C ./project +clawd team resume --provider anthropic --model glm-5.2 -C ./project +``` + +原子仓库生成协议、写入所有权、repair 生命周期和 Q/P/E 评测口径见 +`docs/guide/TEAM_PROTOCOL_V2.md`。 + +在 `TeammateCreate` 中设置 `workspace_mode: "worktree"` 可启用 Git 隔离;配合 +`auto_integrate: true`,成功改动会在隔离 worktree 中提交并 cherry-pick 回 lead 仓库。 +需要检查新整合改动的下游 reviewer 应使用 shared workspace。稳定性评测位于 +`teammate-evals/runtime-resilience/`。 + +`teammate-evals/solo-vs-team/` 还提供五场景真实模型 benchmark,在保持业务任务 +一致的前提下比较单 agent 与 Lead 自主决策的 teammate 工作流,并记录验收质量、耗时、token、 +模型/工具调用次数和协作证据。 + ### Skills(技能 / 斜杠命令)教程 技能是存放在 `.clawd/skills` 下的 Markdown 斜杠命令。每个技能对应一个目录,并且文件名固定为 `SKILL.md`。 diff --git a/docs/guide/TEAMMATE_WORKER_LIFECYCLE.md b/docs/guide/TEAMMATE_WORKER_LIFECYCLE.md new file mode 100644 index 0000000..66c7842 --- /dev/null +++ b/docs/guide/TEAMMATE_WORKER_LIFECYCLE.md @@ -0,0 +1,66 @@ +# Teammate Worker Lifecycle + +## Current control model + +The lead owns worker lifecycle changes. Teammates cannot stop or resume one +another, change another task, or invoke team-management tools. + +Worker states: + +```text +created -> running -> idle -> completed + | | + +-> stopping -> cancelled -> running + +-> failed -----------------> running +``` + +`TeammateStop` records a stop request before changing tasks. A running worker +observes that request at the next model or tool boundary. The task policy is: + +- `requeue`: return unfinished work to `pending` with no owner. +- `cancel`: mark unfinished work `cancelled`. + +Other workers continue. The team stays `running` and may return `blocked` when +the stopped worker leaves an unassigned or cancelled dependency. The lead or a +human operator can then resume a worker, reassign the task, and resume the team. + +Every request and acknowledgement is auditable through `agent.stop_requested`, +`run.cancelled`, `agent.stopped`, `task.requeued`, `task.cancelled`, +`agent.resumed`, and `task.reassigned` trace events. + +## Why force is not exposed yet + +Workers currently execute in a `ThreadPoolExecutor`. Python cannot safely kill +one running thread. A graceful stop can prevent the next model or tool call, but +it cannot interrupt an HTTP request or a shell command already in progress. +Calling that behavior `force` would provide a false guarantee. + +## Process-worker migration + +True force termination requires the following architecture: + +1. Run each worker in its own process and process group. +2. Recreate provider and tool dependencies inside the child instead of sharing + non-picklable clients from the lead process. +3. Send commands over a small supervisor channel and keep team/task/events in + the existing durable store. +4. Persist worker PID, process start identity, heartbeat, and supervisor epoch + so stale PIDs cannot be terminated accidentally after a restart. +5. Implement `graceful` as a stop request followed by a bounded grace period. +6. Implement `force` as process-group termination after that grace period, + including descendant shell processes. +7. Recover the task lease using the same `requeue` or `cancel` policy after the + child exits. +8. Use `terminate()`/`kill()` on POSIX and the corresponding Windows process + APIs behind one supervisor abstraction. + +## Force-mode acceptance criteria + +- Stopping one worker never changes another worker or the team cancellation flag. +- A force-stopped worker cannot emit tool results after acknowledgement. +- Child shell processes are gone before the task lease is released. +- Repeated stop requests are idempotent. +- Supervisor restart cannot kill an unrelated process with a reused PID. +- Trace order shows request, signal, process exit, task disposition, and final + worker state. +- Linux, macOS, and Windows integration tests cover graceful and force modes. diff --git a/docs/guide/TEAM_PROTOCOL_V2.md b/docs/guide/TEAM_PROTOCOL_V2.md new file mode 100644 index 0000000..8de5c8b --- /dev/null +++ b/docs/guide/TEAM_PROTOCOL_V2.md @@ -0,0 +1,79 @@ +# Team Protocol v2 + +Protocol v2 is the repository-generation harness used by NL2Repo `adaptive-team-v2` +and `forced-team`. Its goal is to make delegation useful rather than ceremonial: a Team +receives credit only when independently owned work is actually executed, integrated, and +accepted by harness-controlled checks. + +## Control flow + +```text +TeamCreate(quality_gates=true) + -> TeamPlan(replace, expected_revision) + -> TeamRun + -> task claimed + -> worker writes audited against owned_files + -> task produced + -> task acceptance checks + -> task accepted + -> clean install/import/integration verification + -> completed +``` + +`TeamPlan` is an atomic manifest. It contains the architecture contract, all workers, the +complete task DAG, file ownership, acceptance checks, validation profile, and execution +budget. Invalid plans return structured issues without materializing a partial Team. A +plan revision is immutable while work is running, and legacy incremental mutation tools +cannot alter a v2 plan. + +## Planning invariants + +- At least two distinct workers own real implementation tasks. +- At least two implementation owners can start immediately; frozen interfaces enable + parallel work, while handoff interfaces add explicit DAG dependencies. +- Concrete `owned_files` may overlap only between tasks owned by the same worker. +- Persistent project tests are deliverables and must appear in `owned_files`. Each task + also receives `.clawd/task-tests//` for disposable self-tests; that subtree + is private to the task and excluded from the delivered repository. For compatibility + with common test workflows, a task may create a previously absent, unreserved + `tests/test_*.py` self-test; existing or task-reserved tests remain protected. +- Every implementation task has a behavioral acceptance check; file-existence, no-op, + and fail-open commands are rejected. +- Worker models must use the configured endpoint/model policy. +- Replacing a failed plan clears stale execution settings and increments the revision. + +## Runtime invariants + +- Each write/edit is checked before execution. Shell commands are audited by a guarded + before/after workspace snapshot. An out-of-scope mutation is sticky and moves the Team + to `repair_required`. +- Task-private scratch and newly created local self-tests are included in the ownership + audit, so a worker cannot modify another task's or any pre-existing test through a + shell command. +- A worker completion produces evidence; it does not directly accept its task. +- The harness runs task acceptance checks, then changes `produced` to `accepted`. +- Final verification always uses a fresh environment and runs install, import, and + integration stages. Cleanup runs even when verification fails. +- Provider, AGS, and transport failures pause the Team as retryable infrastructure work; + they do not become candidate failures. +- `completed` is terminal for `TeamRun`. Repair requires a new plan revision. + `TeamAbort` is terminal and cannot be resumed or replanned. + +## Evaluation metrics + +The benchmark reports three orthogonal aggregates: + +- `Q = mean(code_quality_score)` over scoreable hidden-test results. +- `P = passed protocol cases / protocol-eligible cases`. +- `E = mean(effective_quality_score)`, where valid delivery with a failed Team protocol + receives zero protocol credit. + +Infrastructure failures have `protocol_status=not_evaluated`, null Q/E, and all metric +eligibility flags false. They remain retryable. A protocol failure can still have a valid +Q, which lets the evaluation distinguish poor code from poor collaboration discipline. + +## Compatibility + +Protocol-v1 Teams keep the incremental +`TeamConfigure -> TeammateCreate -> TaskCreate -> TeamRun -> TeamVerify` workflow. +Protocol v2 deliberately blocks those mutation paths after an atomic plan is committed. diff --git a/pyproject.toml b/pyproject.toml index db706eb..86aaf0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,12 @@ dev = [ "twine>=5.0.0", "pytest>=8.0.0", ] +ags = [ + "aiohttp>=3.10", + "swe-rex>=1.4.0", + "tencentcloud-sdk-python-ags>=3.1.132", + "tencentcloud-sdk-python-common>=3.1.132", +] [project.scripts] clawd = "src.cli:main" @@ -51,3 +57,9 @@ Documentation = "https://github.com/GPT-AGI/Clawd-Codex#readme" [tool.setuptools.packages.find] where = ["."] include = ["src*"] + +[tool.setuptools.package-data] +"src.teammate" = ["trace_viewer.html"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/src/agent/conversation.py b/src/agent/conversation.py index 41de745..d03d585 100644 --- a/src/agent/conversation.py +++ b/src/agent/conversation.py @@ -49,7 +49,7 @@ class Message: class Conversation: """Conversation manager.""" messages: list[Message] = field(default_factory=list) - max_history: int = 100 + max_history: int = 300 def add_message(self, role: str, content: Union[str, list[ContentBlock]]): """Add a message to conversation.""" @@ -150,7 +150,7 @@ def to_dict(self) -> dict: @classmethod def from_dict(cls, data: dict) -> 'Conversation': """Deserialize conversation.""" - conv = cls(max_history=data.get("max_history", 100)) + conv = cls(max_history=data.get("max_history", 300)) for msg_data in data.get("messages", []): content = msg_data["content"] if isinstance(content, str): diff --git a/src/cli.py b/src/cli.py index f22b870..8b96037 100644 --- a/src/cli.py +++ b/src/cli.py @@ -3,6 +3,8 @@ from __future__ import annotations import argparse +import json +import shlex import sys from pathlib import Path @@ -27,6 +29,11 @@ def main(): clawd --version Show version clawd login Configure API keys clawd config Show current configuration + clawd run --prompt-file TASK.md + clawd trace . Inspect teammate tool calls and messages + clawd team status Inspect the active teammate team + clawd team stop coder Stop one worker and requeue its work + clawd peer run --repo . --prompt-file TASK.md --peers 3 --communication p2p clawd --stream Start REPL with live response rendering clawd Start interactive REPL """ @@ -51,10 +58,117 @@ def main(): subparsers = parser.add_subparsers(dest='command', help='Available commands') # login subcommand - login_parser = subparsers.add_parser('login', help='Configure API keys') + subparsers.add_parser('login', help='Configure API keys') # config subcommand - config_parser = subparsers.add_parser('config', help='Show current configuration') + config_parser = subparsers.add_parser('config', help='Show or switch configuration') + config_parser.add_argument( + '--use', + choices=('glm', 'glm5', 'qwen', 'qwen3.5'), + help='Switch the default model profile', + ) + + run_parser = subparsers.add_parser('run', help='Run one prompt non-interactively') + run_parser.add_argument('prompt', nargs='?', help='Prompt text (reads stdin when omitted)') + run_parser.add_argument('-f', '--prompt-file', type=Path, help='Read the prompt from a file') + run_parser.add_argument('-C', '--workspace', type=Path, default=Path('.'), help='Workspace root for local tools') + run_parser.add_argument('--provider', help='Configured provider to use') + run_parser.add_argument('--model', help='Model override for this run') + run_parser.add_argument('--max-turns', type=int, default=100, help='Maximum model turns') + run_parser.add_argument('--stream', dest='run_stream', action='store_true', help='Stream final text') + run_parser.add_argument('--quiet', action='store_true', help='Hide tool progress') + + trace_parser = subparsers.add_parser('trace', help='Open the teammate trace viewer') + trace_parser.add_argument('workspace', nargs='?', default='.', help='Workspace containing .clawd state') + trace_parser.add_argument('--host', default='127.0.0.1', help='Viewer bind host') + trace_parser.add_argument('--port', type=int, default=8765, help='Viewer bind port (0 chooses a free port)') + trace_parser.add_argument('--team', help='Team ID to select initially') + trace_parser.add_argument('--open', action='store_true', help='Open the viewer in the default browser') + + team_parser = subparsers.add_parser('team', help='Inspect and control teammate workers') + team_subparsers = team_parser.add_subparsers(dest='team_command', required=True) + + def add_team_workspace(command_parser: argparse.ArgumentParser) -> None: + command_parser.add_argument( + '-C', '--workspace', type=Path, default=Path('.'), + help='Workspace containing persistent teammate state', + ) + + team_list_parser = team_subparsers.add_parser('list', help='List persisted teams') + add_team_workspace(team_list_parser) + + team_status_parser = team_subparsers.add_parser('status', help='Show team, worker, and task status') + add_team_workspace(team_status_parser) + team_status_parser.add_argument('--team-id', help='Historical team ID; defaults to the active team') + + team_stop_parser = team_subparsers.add_parser('stop', help='Stop one worker without cancelling the team') + add_team_workspace(team_stop_parser) + team_stop_parser.add_argument('teammate', help='Worker name or ID') + team_stop_parser.add_argument('--reason', help='Reason recorded in the trace') + team_stop_parser.add_argument( + '--task-policy', choices=('requeue', 'cancel'), default='requeue', + help='How to handle unfinished tasks', + ) + + worker_resume_parser = team_subparsers.add_parser('resume-worker', help='Make a stopped worker available again') + add_team_workspace(worker_resume_parser) + worker_resume_parser.add_argument('teammate', help='Worker name or ID') + + reassign_parser = team_subparsers.add_parser('reassign', help='Assign a stopped or pending task to a worker') + add_team_workspace(reassign_parser) + reassign_parser.add_argument('task', help='Task ID or stable key') + reassign_parser.add_argument('teammate', help='Replacement worker name or ID') + + team_cancel_parser = team_subparsers.add_parser('cancel', help='Cancel the entire active team') + add_team_workspace(team_cancel_parser) + team_cancel_parser.add_argument('--reason', help='Reason recorded in the trace') + + team_resume_parser = team_subparsers.add_parser('resume', help='Resume active persisted team execution') + add_team_workspace(team_resume_parser) + team_resume_parser.add_argument('--provider', help='Configured provider to use') + team_resume_parser.add_argument('--model', help='Model override for worker runs') + team_resume_parser.add_argument('--max-turns', type=int, default=30, help='Maximum turns per worker task') + team_resume_parser.add_argument('--max-workers', type=int, help='Maximum concurrent workers') + team_resume_parser.add_argument('--timeout', type=float, help='Team timeout in seconds') + team_resume_parser.add_argument('--token-budget', type=int, help='Aggregate token budget') + team_resume_parser.add_argument('--turn-budget', type=int, help='Aggregate model turn budget') + team_resume_parser.add_argument('--max-retries', type=int, help='Automatic retries per task') + team_resume_parser.add_argument('--lease-timeout', type=int, help='Task lease timeout in seconds') + team_resume_parser.add_argument('--no-retry-failed', action='store_true', help='Do not retry failed tasks') + team_resume_parser.add_argument('--no-retry-cancelled', action='store_true', help='Do not retry cancelled tasks') + + peer_parser = subparsers.add_parser('peer', help='Run peer-native collaboration') + peer_subparsers = peer_parser.add_subparsers(dest='peer_command', required=True) + peer_run_parser = peer_subparsers.add_parser( + 'run', help='Run a fixed-size peer-native collaboration experiment' + ) + peer_run_parser.add_argument('--repo', type=Path, required=True, help='Git repository root') + peer_run_parser.add_argument('--prompt-file', type=Path, required=True, help='Top-level mission file') + peer_run_parser.add_argument('--peers', type=int, required=True, help='Number of equal peers') + peer_run_parser.add_argument( + '--communication', + required=True, + choices=('solo', 'independent', 'none', 'artifact-only', 'star', 'p2p'), + ) + peer_run_parser.add_argument( + '--workspace-mode', required=True, choices=('shared', 'worktree') + ) + peer_run_parser.add_argument('--provider', help='Configured provider to use') + peer_run_parser.add_argument('--model', help='Model override for every peer') + peer_run_parser.add_argument('--timeout-seconds', type=float, default=300.0) + peer_run_parser.add_argument('--max-turns', type=int, default=30) + peer_run_parser.add_argument('--max-output-tokens', type=int, default=4096) + peer_run_parser.add_argument('--token-budget', type=int) + peer_run_parser.add_argument('--turn-budget', type=int) + peer_run_parser.add_argument('--output-dir', type=Path) + peer_run_parser.add_argument('--coordinator-peer', help='Star coordinator ID/name') + peer_run_parser.add_argument( + '--acceptance-command', + help='Shell-like argv string run against the accepted revision', + ) + peer_run_parser.add_argument( + '--retain-worktrees', action='store_true', help='Do not remove peer worktrees after the run' + ) args = parser.parse_args() @@ -72,12 +186,278 @@ def main(): if args.command == 'login': return handle_login() elif args.command == 'config': - return show_config() + return show_config(use_provider=args.use) + elif args.command == 'run': + try: + prompt = _read_run_prompt(args.prompt, args.prompt_file, args.workspace) + except (OSError, ValueError) as exc: + parser.error(str(exc)) + return run_once( + prompt, + workspace=args.workspace, + provider_name=args.provider, + model=args.model, + max_turns=args.max_turns, + stream=args.stream or args.run_stream, + quiet=args.quiet, + ) + elif args.command == 'trace': + from src.teammate.viewer import serve_trace_viewer + + return serve_trace_viewer( + Path(args.workspace), + host=args.host, + port=args.port, + team_id=args.team, + open_browser=args.open, + ) + elif args.command == 'team': + return handle_team_command(args) + elif args.command == 'peer': + return handle_peer_command(args) # Default: start REPL return start_repl(stream=args.stream) +def _read_run_prompt( + prompt: str | None, + prompt_file: Path | None, + workspace: Path, +) -> str: + if prompt is not None and prompt_file is not None: + raise ValueError("provide either prompt text or --prompt-file, not both") + if prompt_file is not None: + path = prompt_file.expanduser() + if not path.is_absolute(): + path = workspace.expanduser().resolve() / path + text = path.read_text(encoding="utf-8") + elif prompt is not None: + text = prompt + elif not sys.stdin.isatty(): + text = sys.stdin.read() + else: + raise ValueError("provide prompt text, --prompt-file, or piped stdin") + if not text.strip(): + raise ValueError("prompt must be non-empty") + return text + + +def handle_peer_command(args: argparse.Namespace) -> int: + if args.peer_command != 'run': + return 1 + from src.peer.runner import run_peer_collaboration + + repo = args.repo.expanduser().resolve() + prompt_path = args.prompt_file.expanduser() + if not prompt_path.is_absolute(): + prompt_path = repo / prompt_path + try: + mission = prompt_path.read_text(encoding='utf-8') + acceptance = ( + shlex.split(args.acceptance_command) + if args.acceptance_command + else None + ) + result = run_peer_collaboration( + mission, + repo=repo, + peers=args.peers, + communication=args.communication, + workspace_mode=args.workspace_mode, + provider_name=args.provider, + model=args.model, + timeout_seconds=args.timeout_seconds, + max_turns=args.max_turns, + max_output_tokens=args.max_output_tokens, + token_budget=args.token_budget, + turn_budget=args.turn_budget, + output_dir=args.output_dir, + coordinator_peer=args.coordinator_peer, + acceptance_command=acceptance, + cleanup_worktrees=not args.retain_worktrees, + ) + except (OSError, RuntimeError, ValueError) as exc: + Console(stderr=True).print(f"[red]Peer run failed:[/red] {exc}") + return 1 + print(json.dumps(result, ensure_ascii=False, indent=2)) + if result.get('status') != 'completed': + return 2 + acceptance_result = result.get('acceptance') + if isinstance(acceptance_result, dict) and acceptance_result.get('exit_code') != 0: + return 3 + return 0 + + +def run_once( + prompt: str, + *, + workspace: Path, + provider_name: str | None, + model: str | None, + max_turns: int, + stream: bool, + quiet: bool, +) -> int: + from src.runner import run_prompt + from src.tool_system.agent_loop import ToolEvent, summarize_tool_result, summarize_tool_use + + output = Console() + progress = Console(stderr=True) + + def on_event(event: ToolEvent) -> None: + if quiet: + return + if event.kind == "tool_use": + summary = summarize_tool_use(event.tool_name, event.tool_input or {}) + suffix = f" ({summary})" if summary else "" + progress.print(f"[cyan]{event.tool_name}[/cyan]{suffix}") + elif event.kind == "tool_result" and event.is_error: + summary = summarize_tool_result(event.tool_name or "Tool", event.tool_output) + progress.print(f"[red]{summary}[/red]") + elif event.kind == "tool_error": + progress.print(f"[red]{event.tool_name or 'Tool'}: {event.error or 'failed'}[/red]") + + def on_text_chunk(chunk: str) -> None: + output.print(chunk, end="", markup=False, highlight=False, soft_wrap=True) + + try: + result = run_prompt( + prompt, + workspace=workspace, + provider_name=provider_name, + model=model, + max_turns=max_turns, + stream=stream, + on_event=on_event, + on_text_chunk=on_text_chunk if stream else None, + ) + except Exception as exc: + progress.print(f"[red]Run failed: {exc}[/red]") + return 1 + + if stream: + output.print() + else: + output.print(result.response_text, markup=False, highlight=False) + return 2 if result.response_text == "[Max tool turns reached]" else 0 + + +def handle_team_command(args: argparse.Namespace) -> int: + from src.teammate.control import ( + cancel_team, + list_teams, + reassign_task, + resume_teammate, + stop_teammate, + team_status, + ) + from src.teammate.store import TeamStore + + output = Console() + errors = Console(stderr=True) + workspace = Path(args.workspace).expanduser().resolve() + + try: + if args.team_command == 'list': + teams = list_teams(workspace) + table = Table(title="Teammate Teams") + table.add_column("Active") + table.add_column("Team") + table.add_column("ID") + table.add_column("Status") + table.add_column("Updated") + for team in teams: + table.add_row( + "*" if team["active"] else "", + str(team["team_name"]), + str(team["team_id"]), + str(team["status"]), + str(team["updated_at"]), + ) + output.print(table) + return 0 + + if args.team_command == 'status': + snapshot = team_status(workspace, args.team_id) + team = snapshot["team"] + output.print( + f"[bold]{team['team_name']}[/bold] ({team['team_id']}) " + f"status=[cyan]{team['status']}[/cyan]" + ) + agents = Table(title="Workers") + agents.add_column("Name") + agents.add_column("Role") + agents.add_column("Status") + agents.add_column("Model") + for agent in snapshot["agents"]: + agents.add_row( + str(agent["name"]), + str(agent["role"]), + str(agent["status"]), + str(agent.get("model") or "default"), + ) + output.print(agents) + tasks = Table(title="Tasks") + tasks.add_column("Key") + tasks.add_column("Status") + tasks.add_column("Owner") + tasks.add_column("Subject") + names = {agent["agent_id"]: agent["name"] for agent in snapshot["agents"]} + for task in snapshot["tasks"]: + owner = task.get("owner") + tasks.add_row( + str(task.get("key") or task["id"]), + str(task["status"]), + str(names.get(owner, owner or "unassigned")), + str(task["subject"]), + ) + output.print(tasks) + output.print( + f"Messages: {snapshot['message_count']} Events: {snapshot['event_count']}" + ) + return 0 + + store = TeamStore(workspace) + if args.team_command == 'stop': + result = stop_teammate( + store, + args.teammate, + task_policy=args.task_policy, + reason=args.reason, + ) + elif args.team_command == 'resume-worker': + result = resume_teammate(store, args.teammate) + elif args.team_command == 'reassign': + result = reassign_task(store, args.task, args.teammate) + elif args.team_command == 'cancel': + result = cancel_team(store, args.reason) + elif args.team_command == 'resume': + from src.runner import resume_team + + result = resume_team( + workspace=workspace, + provider_name=args.provider, + model=args.model, + max_turns=args.max_turns, + max_workers=args.max_workers, + timeout_s=args.timeout, + token_budget=args.token_budget, + turn_budget=args.turn_budget, + max_retries=args.max_retries, + lease_timeout_s=args.lease_timeout, + retry_failed=not args.no_retry_failed, + retry_cancelled=not args.no_retry_cancelled, + ) + else: # pragma: no cover - argparse enforces known commands + raise ValueError(f"unknown team command: {args.team_command}") + output.print(json.dumps(result, indent=2, ensure_ascii=False), markup=False) + return 0 if result.get("status") not in {"failed", "blocked"} else 2 + except (OSError, ValueError) as exc: + errors.print(f"[red]Team command failed: {exc}[/red]") + return 1 + + def _show_provider_defaults_table() -> None: """Print a table showing available providers and their defaults.""" from src.providers import PROVIDER_INFO @@ -155,12 +535,19 @@ def handle_login(): return 0 -def show_config(): - """Show current configuration.""" +def show_config(use_provider: str | None = None): + """Show current configuration and optionally switch the default provider.""" console = Console() try: - from src.config import load_config, get_config_path + from src.config import get_config_path, load_config, use_model_profile + + if use_provider is not None: + profile = use_model_profile(use_provider) + console.print( + f"\n[green]✓ Active model profile switched to: {profile['name']} " + f"({profile['provider']}/{profile['default_model']})[/green]" + ) config = load_config() config_path = get_config_path() @@ -170,6 +557,7 @@ def show_config(): # Show default provider console.print(f"[cyan]Default Provider:[/cyan] {config.get('default_provider', 'Not set')}") + console.print(f"[cyan]Active Profile:[/cyan] {config.get('active_profile') or 'custom'}") # Show providers (without showing full API keys) console.print("\n[cyan]Configured Providers:[/cyan]") diff --git a/src/config.py b/src/config.py index 909cf7a..01f5ebf 100644 --- a/src/config.py +++ b/src/config.py @@ -9,6 +9,33 @@ from typing import Any, Optional +MODEL_PROFILES: dict[str, dict[str, str]] = { + "glm5": { + "provider": "anthropic", + "base_url": "https://api.z.ai/api/anthropic", + "default_model": "glm-5.2", + }, + "qwen3.5": { + "provider": "qwen", + "base_url": ( + "https://ms-mnhdj86z-100034032793-sw.gw.ap-zhongwei.ti.tencentcs.com/" + "ms-mnhdj86z/v1" + ), + "default_model": "ms-mnhdj86z", + }, +} + +MODEL_PROFILE_ALIASES = { + "glm": "glm5", + "glm5": "glm5", + "glm-5": "glm5", + "glm5.2": "glm5", + "zhipu-glm5": "glm5", + "qwen": "qwen3.5", + "qwen3.5": "qwen3.5", +} + + def get_config_path() -> Path: """Get the path to the configuration file.""" config_dir = Path.home() / ".clawd" @@ -22,6 +49,7 @@ def _get_default_config_from_providers() -> dict[str, Any]: return { "default_provider": "anthropic", + "active_profile": None, "providers": { name: { "api_key": "", @@ -32,7 +60,7 @@ def _get_default_config_from_providers() -> dict[str, Any]: }, "session": { "auto_save": True, - "max_history": 100 + "max_history": 300 } } @@ -42,6 +70,32 @@ def get_default_config() -> dict[str, Any]: return _get_default_config_from_providers() +def _merge_default_config(config: dict[str, Any]) -> bool: + """Add newly introduced providers/settings without overwriting user values.""" + defaults = get_default_config() + changed = False + for key in ("default_provider", "active_profile", "session"): + if key not in config: + config[key] = defaults[key] + changed = True + + providers = config.get("providers") + if not isinstance(providers, dict): + config["providers"] = defaults["providers"] + return True + for name, provider_defaults in defaults["providers"].items(): + provider = providers.get(name) + if not isinstance(provider, dict): + providers[name] = dict(provider_defaults) + changed = True + continue + for key, value in provider_defaults.items(): + if key not in provider: + provider[key] = value + changed = True + return changed + + def _encode_api_key(api_key: str) -> str: """Encode API key for basic obfuscation.""" return base64.b64encode(api_key.encode()).decode() @@ -74,11 +128,18 @@ def load_config() -> dict[str, Any]: with open(config_path, 'r', encoding='utf-8') as f: config = json.load(f) + if not isinstance(config, dict): + raise ValueError("configuration root must be an object") + changed = _merge_default_config(config) + # Decode API keys for provider_name, provider_config in config.get("providers", {}).items(): if provider_config.get("api_key"): provider_config["api_key"] = _decode_api_key(provider_config["api_key"]) + if changed: + save_config(config) + return config except Exception as e: print(f"Error loading config: {e}") @@ -125,6 +186,9 @@ def get_provider_config(provider: str) -> dict[str, Any]: Returns: Provider configuration dictionary """ + from src.providers import normalize_provider_name + + provider = normalize_provider_name(provider) config = load_config() providers = config.get("providers", {}) @@ -144,6 +208,9 @@ def set_api_key(provider: str, api_key: str, base_url: Optional[str] = None, base_url: Optional base URL override default_model: Optional default model override """ + from src.providers import normalize_provider_name + + provider = normalize_provider_name(provider) config = load_config() if provider not in config.get("providers", {}): @@ -169,9 +236,33 @@ def set_default_provider(provider: str) -> None: Args: provider: Provider name """ + from src.providers import PROVIDER_INFO, normalize_provider_name + + provider = normalize_provider_name(provider) + if provider not in PROVIDER_INFO: + raise ValueError(f"Unknown provider: {provider}") + config = load_config() + config["default_provider"] = provider + config["active_profile"] = None + save_config(config) + + +def use_model_profile(profile: str) -> dict[str, str]: + """Activate a named model profile without changing its saved API key.""" + normalized = MODEL_PROFILE_ALIASES.get(profile.strip().lower()) + if normalized is None: + choices = ", ".join(sorted(MODEL_PROFILES)) + raise ValueError(f"Unknown model profile: {profile}; choose one of {choices}") + selected = MODEL_PROFILES[normalized] config = load_config() + provider = selected["provider"] + provider_config = config["providers"][provider] + provider_config["base_url"] = selected["base_url"] + provider_config["default_model"] = selected["default_model"] config["default_provider"] = provider + config["active_profile"] = normalized save_config(config) + return {"name": normalized, **selected} def get_default_provider() -> str: diff --git a/src/execution/__init__.py b/src/execution/__init__.py new file mode 100644 index 0000000..8593d6e --- /dev/null +++ b/src/execution/__init__.py @@ -0,0 +1,5 @@ +"""Execution backends for local and sandboxed agent workspaces.""" + +from .backend import CommandOutcome, RemoteStat, WorkspaceBackend + +__all__ = ["CommandOutcome", "RemoteStat", "WorkspaceBackend"] diff --git a/src/execution/ags.py b/src/execution/ags.py new file mode 100644 index 0000000..9677dc8 --- /dev/null +++ b/src/execution/ags.py @@ -0,0 +1,832 @@ +from __future__ import annotations + +import asyncio +import base64 +import concurrent.futures +import json +import os +import posixpath +import re +import shlex +import shutil +import sys +import tarfile +import tempfile +import threading +import uuid +from dataclasses import asdict, dataclass, field +from pathlib import Path, PurePosixPath +from typing import Any, Coroutine, TypeVar + +from .backend import CommandOutcome, RemoteStat + + +T = TypeVar("T") +TASK_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*$") +DEFAULT_AGS_IMAGE_REPOSITORY = "swebenchdocker.tencentcloudcr.com/swebench/nl2repo" +DEFAULT_AGS_RUNTIME_IMAGE = "swebenchdocker.tencentcloudcr.com/swebench/swehub:swerex-runtime" +AGS_ARCHIVE_MAX_COMPRESSED_BYTES = max( + 1, int(os.environ.get("CLAWD_AGS_ARCHIVE_MAX_COMPRESSED_BYTES", str(1024**3))) +) +AGS_ARCHIVE_MAX_MEMBERS = max( + 1, int(os.environ.get("CLAWD_AGS_ARCHIVE_MAX_MEMBERS", "100000")) +) +AGS_ARCHIVE_MAX_FILES = max( + 1, int(os.environ.get("CLAWD_AGS_ARCHIVE_MAX_FILES", "50000")) +) +AGS_ARCHIVE_MAX_FILE_BYTES = max( + 1, + int( + os.environ.get( + "CLAWD_AGS_ARCHIVE_MAX_FILE_BYTES", str(256 * 1024 * 1024) + ) + ), +) +AGS_ARCHIVE_MAX_TOTAL_BYTES = max( + 1, + int( + os.environ.get( + "CLAWD_AGS_ARCHIVE_MAX_TOTAL_BYTES", str(2 * 1024 * 1024 * 1024) + ) + ), +) + + +def _build_sandbox_command( + command: str, + *, + timeout_s: int, + runtime_mount_path: str, +) -> list[str]: + """Run a task command without leaking the mounted SWE-ReX virtualenv. + + The AGS runtime server is launched from ``/swerex``. Its virtualenv + can therefore be the first entry in PATH even though the task image has a + different Python version. Keep the image environment, but remove only + runtime-owned PATH entries before starting the task command. + + GNU timeout is deliberately inside the sandbox. It owns the task process + group and terminates descendants as well, so a timed-out pip/apt command + cannot keep running and block later tool calls. + """ + runtime_root = posixpath.join(runtime_mount_path.rstrip("/") or "/", "swerex") + wrapper = r''' +runtime_root=$1 +timeout_seconds=$2 +user_command=$3 + +clean_path= +old_ifs=$IFS +IFS=: +for path_entry in ${PATH:-}; do + case "$path_entry" in + "$runtime_root"|"$runtime_root"/*) continue ;; + esac + if [ -z "$clean_path" ]; then + clean_path=$path_entry + else + clean_path=$clean_path:$path_entry + fi +done +IFS=$old_ifs +if [ -z "$clean_path" ]; then + clean_path=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +fi +export PATH=$clean_path + +case "${VIRTUAL_ENV:-}" in + "$runtime_root"|"$runtime_root"/*) unset VIRTUAL_ENV ;; +esac +case "${PYTHONHOME:-}" in + "$runtime_root"|"$runtime_root"/*) unset PYTHONHOME ;; +esac + +if command -v python3 >/dev/null 2>&1; then + CLAWD_PYTHON=$(command -v python3) +elif command -v python >/dev/null 2>&1; then + CLAWD_PYTHON=$(command -v python) +else + CLAWD_PYTHON= +fi +export CLAWD_PYTHON + +if [ -x /usr/bin/timeout ]; then + timeout_bin=/usr/bin/timeout +elif [ -x /bin/timeout ]; then + timeout_bin=/bin/timeout +else + echo "Clawd harness error: GNU timeout is unavailable in the sandbox image" >&2 + exit 125 +fi + +exec "$timeout_bin" --signal=TERM --kill-after=5s "${timeout_seconds}s" \ + /bin/bash -c "$user_command" +'''.strip() + return [ + "/bin/bash", + "-c", + wrapper, + "clawd-sandbox-command", + runtime_root, + str(timeout_s), + command, + ] + + +def nl2repo_ags_image(task: str, *, version: str = "1.0") -> str: + normalized = task.strip().lower() + if not TASK_RE.fullmatch(normalized): + raise ValueError(f"invalid NL2Repo task name: {task!r}") + return f"{DEFAULT_AGS_IMAGE_REPOSITORY}:{normalized}-{version}" + + +def _first_env(*names: str, default: str = "") -> str: + for name in names: + value = os.environ.get(name) + if value is not None and value.strip(): + return value + return default + + +def load_env_file(path: str | Path, *, override: bool = False) -> None: + source = Path(path).expanduser() + if not source.is_file(): + raise FileNotFoundError(f"AGS env file not found: {source}") + aliases = { + "secret_id": ("AGS_SECRET_ID", "TENCENTCLOUD_SECRET_ID"), + "secret_key": ("AGS_SECRET_KEY", "TENCENTCLOUD_SECRET_KEY"), + "region": ("AGS_REGION",), + } + for raw in source.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith(("#", "[")) or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip().removeprefix("export ").strip() + value = value.strip().strip('"').strip("'") + if not key: + continue + if override or key not in os.environ: + os.environ[key] = value + for alias in aliases.get(key, ()): + if override or alias not in os.environ: + os.environ[alias] = value + + +def discover_ags_env_file() -> Path | None: + configured = _first_env("AGS_ENV_FILE", "AGS_ENV_FILE_PATH") + candidates: list[Path] = [] + if configured: + candidates.append(Path(configured).expanduser()) + candidates.append(Path.cwd() / ".env") + repo = Path(__file__).resolve().parents[2] + candidates.append(repo.parent / "sandbox" / "ags" / ".env") + for candidate in candidates: + if candidate.is_file(): + return candidate.resolve() + return None + + +@dataclass +class AGSSettings: + secret_id: str = "" + secret_key: str = "" + http_endpoint: str = "ags.tencentcloudapi.com" + region: str = "ap-guangzhou" + domain: str = "ap-guangzhou.tencentags.com" + role_arn: str = "" + skip_ssl_verify: bool = False + tool_id: str = "" + image: str = field(default_factory=lambda: nl2repo_ags_image("retrying")) + image_registry_type: str = "enterprise" + cpu: str = "2" + memory: str = "4Gi" + port: int = 8000 + timeout: str = "3h" + startup_timeout: float = 600.0 + runtime_timeout: float = 700.0 + network_mode: str = "PUBLIC" + mount_name: str = "rex" + mount_image: str = DEFAULT_AGS_RUNTIME_IMAGE + mount_image_registry_type: str = "enterprise" + mount_path: str = "/nix" + image_subpath: str = "/nix" + mount_readonly: bool = False + swerex_root: str = "" + + @classmethod + def from_env( + cls, + *, + image: str, + env_file: str | Path | None = None, + timeout: str | None = None, + cpu: str | None = None, + memory: str | None = None, + ) -> "AGSSettings": + selected_env = Path(env_file).expanduser() if env_file else discover_ags_env_file() + if selected_env is not None: + load_env_file(selected_env) + return cls( + secret_id=_first_env("AGS_SECRET_ID", "TENCENTCLOUD_SECRET_ID"), + secret_key=_first_env("AGS_SECRET_KEY", "TENCENTCLOUD_SECRET_KEY"), + http_endpoint=_first_env("SLIME_AGENT_AGS_HTTP_ENDPOINT", default=cls.http_endpoint), + region=_first_env("AGS_REGION", "SLIME_AGENT_AGS_REGION", default=cls.region), + domain=_first_env("AGS_DOMAIN", "SLIME_AGENT_AGS_DOMAIN", default=cls.domain), + role_arn=_first_env("AGS_ROLE_ARN", "TENCENTCLOUD_ROLE_ARN", default=cls.role_arn), + skip_ssl_verify=_first_env("SLIME_AGENT_AGS_SKIP_SSL_VERIFY", default="0").lower() + in {"1", "true", "yes"}, + tool_id=_first_env("AGS_TOOL_ID", "SLIME_AGENT_AGS_TOOL_ID"), + image=image, + image_registry_type=_first_env( + "SLIME_AGENT_AGS_IMAGE_REGISTRY_TYPE", default=cls.image_registry_type + ), + cpu=cpu or _first_env("SLIME_AGENT_AGS_CPU", default=cls.cpu), + memory=memory or _first_env("SLIME_AGENT_AGS_MEMORY", default=cls.memory), + port=int(_first_env("SLIME_AGENT_AGS_PORT", default=str(cls.port))), + timeout=timeout or _first_env("SLIME_AGENT_AGS_TIMEOUT", default=cls.timeout), + startup_timeout=float( + _first_env("SLIME_AGENT_AGS_BOOT_TIMEOUT_SEC", default=str(cls.startup_timeout)) + ), + runtime_timeout=float( + _first_env("SLIME_AGENT_AGS_RUNTIME_TIMEOUT_SEC", default=str(cls.runtime_timeout)) + ), + network_mode=_first_env( + "SLIME_AGENT_AGS_NETWORK_MODE", "AGS_NETWORK_MODE", default=cls.network_mode + ).upper(), + mount_name=_first_env("SLIME_AGENT_AGS_MOUNT_NAME", default=cls.mount_name), + mount_image=_first_env("SLIME_AGENT_AGS_MOUNT_IMAGE", default=cls.mount_image), + mount_image_registry_type=_first_env( + "SLIME_AGENT_AGS_MOUNT_IMAGE_REGISTRY_TYPE", + default=cls.mount_image_registry_type, + ), + mount_path=_first_env("SLIME_AGENT_AGS_MOUNT_PATH", default=cls.mount_path), + image_subpath=_first_env("SLIME_AGENT_AGS_IMAGE_SUBPATH", default=cls.image_subpath), + mount_readonly=_first_env("SLIME_AGENT_AGS_MOUNT_READONLY", default="0").lower() + in {"1", "true", "yes"}, + swerex_root=_first_env("SWE_REX_ROOT"), + ) + + def validate(self) -> None: + missing = [name for name in ("secret_id", "secret_key") if not getattr(self, name)] + if missing: + raise RuntimeError( + "missing AGS credentials: " + + ", ".join(missing) + + "; configure AGS_SECRET_ID and AGS_SECRET_KEY or --ags-env-file" + ) + if self.network_mode not in {"PUBLIC", "SANDBOX", "INTERNAL_SERVICE"}: + raise RuntimeError(f"invalid AGS network mode: {self.network_mode!r}") + + def deployment_kwargs(self) -> dict[str, Any]: + data = asdict(self) + data.pop("swerex_root", None) + return data + + +def ensure_swerex_importable(settings: AGSSettings | None = None) -> None: + candidates: list[Path] = [] + if settings is not None and settings.swerex_root: + candidates.append(Path(settings.swerex_root).expanduser()) + configured = _first_env("SWE_REX_ROOT") + if configured: + candidates.append(Path(configured).expanduser()) + repo = Path(__file__).resolve().parents[2] + candidates.append(repo.parent / "sandbox" / "SWE-ReX" / "src") + for candidate in reversed(candidates): + resolved = candidate.resolve() + if resolved.is_dir() and str(resolved) not in sys.path: + sys.path.insert(0, str(resolved)) + try: + from swerex.deployment.ags import TencentAGSDeployment # noqa: F401 + except ModuleNotFoundError as exc: + if exc.name and not exc.name.startswith("swerex"): + raise RuntimeError( + f"SWE-ReX AGS dependency {exc.name!r} is unavailable; " + "install Clawd with the ags extra" + ) from exc + raise RuntimeError( + "SWE-ReX with Tencent AGS support is unavailable; install the customized checkout " + "or set SWE_REX_ROOT to its src directory" + ) from exc + + +class AGSWorkspaceBackend: + """Long-lived synchronous facade over SWE-ReX's async AGS deployment.""" + + workspace_root = "/workspace" + + def __init__(self, settings: AGSSettings) -> None: + settings.validate() + self.settings = settings + self.sandbox_id = "" + self._loop: asyncio.AbstractEventLoop | None = None + self._thread: threading.Thread | None = None + self._deployment: Any = None + self._started = False + self._closed = False + + def start(self) -> "AGSWorkspaceBackend": + if self._started: + return self + ensure_swerex_importable(self.settings) + self._loop = asyncio.new_event_loop() + self._thread = threading.Thread(target=self._run_loop, name="clawd-ags", daemon=True) + self._thread.start() + try: + self._submit(self._start_async(), timeout=self.settings.startup_timeout + 90) + except BaseException: + if self._loop is not None: + self._loop.call_soon_threadsafe(self._loop.stop) + if self._thread is not None: + self._thread.join(timeout=10) + if self._loop is not None: + self._loop.close() + self._loop = None + self._thread = None + raise + self._started = True + return self + + def _run_loop(self) -> None: + assert self._loop is not None + asyncio.set_event_loop(self._loop) + self._loop.run_forever() + + async def _start_async(self) -> None: + from swerex.deployment.ags import TencentAGSDeployment + + deployment = TencentAGSDeployment(**self.settings.deployment_kwargs()) + self._deployment = deployment + try: + await deployment.start() + except BaseException: + try: + await asyncio.shield(deployment.stop()) + finally: + self._deployment = None + raise + self.sandbox_id = str(deployment.instance_id or "") + + def _submit( + self, + coroutine: Coroutine[Any, Any, T], + *, + timeout: float | None = None, + operation: str = "AGS operation", + ) -> T: + if self._loop is None: + raise RuntimeError("AGS backend is not started") + future = asyncio.run_coroutine_threadsafe(coroutine, self._loop) + try: + return future.result(timeout=timeout) + except concurrent.futures.TimeoutError as exc: + # Since Python 3.11, concurrent.futures.TimeoutError aliases the + # built-in TimeoutError. A completed coroutine may itself have + # raised a remote CommandTimeoutError; preserve that exception and + # only cancel when result() actually exhausted its wait deadline. + if future.done(): + raise + future.cancel() + if timeout is None: + detail = "the configured deadline" + else: + detail = f"{timeout:g}s" + raise TimeoutError( + f"{operation} timed out after {detail}; the pending request was cancelled" + ) from exc + + def resolve_path(self, path: str, *, cwd: str, local_root: Path) -> str: + if not isinstance(path, str) or not path: + raise ValueError("path must be a non-empty string") + normalized_input = path + local = str(local_root.resolve()) + if normalized_input.startswith("/") and not normalized_input.startswith( + self.workspace_root + ): + normalized_input = str(Path(normalized_input).expanduser().resolve()) + if normalized_input == local or normalized_input.startswith(local + os.sep): + suffix = normalized_input[len(local) :].replace(os.sep, "/") + normalized_input = self.workspace_root + suffix + if not normalized_input.startswith("/"): + normalized_input = posixpath.join(cwd, normalized_input) + resolved = posixpath.normpath(normalized_input) + if resolved != self.workspace_root and not resolved.startswith(self.workspace_root + "/"): + raise ValueError(f"path is outside the AGS workspace: {path}") + return resolved + + def exec( + self, + command: str, + *, + cwd: str, + timeout_s: int, + env: dict[str, str] | None = None, + ) -> CommandOutcome: + if not self._started or self._deployment is None: + raise RuntimeError("AGS backend is not started") + + sandbox_command = _build_sandbox_command( + command, + timeout_s=timeout_s, + runtime_mount_path=self.settings.mount_path, + ) + + async def execute() -> Any: + from swerex.runtime.abstract import Command + + return await self._deployment.runtime.execute( + Command( + command=sandbox_command, + shell=False, + check=False, + timeout=timeout_s + 10, + cwd=cwd, + env=env, + merge_output_streams=False, + ) + ) + + try: + result = self._submit( + execute(), + timeout=timeout_s + 20, + operation=f"sandbox command ({timeout_s}s limit)", + ) + except TimeoutError as exc: + return CommandOutcome( + exit_code=124, + stderr=str(exc) or f"Sandbox command timed out after {timeout_s}s", + ) + stderr = result.stderr or "" + if int(result.exit_code or 0) == 124 and "timed out" not in stderr.lower(): + timeout_message = f"Clawd sandbox command timed out after {timeout_s}s and was terminated" + stderr = f"{stderr.rstrip()}\n{timeout_message}".lstrip() + return CommandOutcome( + exit_code=int(result.exit_code or 0), + stdout=result.stdout or "", + stderr=stderr, + ) + + def run_json_helper( + self, script: str, payload: dict[str, Any], *, timeout_s: int = 120 + ) -> Any: + encoded = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("ascii") + command = f"python3 -c {shlex.quote(script)} {shlex.quote(encoded)}" + result = self.exec(command, cwd=self.workspace_root, timeout_s=timeout_s) + if result.exit_code != 0: + raise RuntimeError(result.stderr or result.stdout or "remote Python helper failed") + return json.loads(result.stdout) + + def stat(self, path: str) -> RemoteStat: + script = ( + "import base64,json,os,sys; p=json.loads(base64.b64decode(sys.argv[1]))['path']; " + "e=os.path.exists(p); s=os.stat(p) if e else None; " + "print(json.dumps({'path':p,'exists':e,'is_file':os.path.isfile(p)," + "'is_dir':os.path.isdir(p),'size':s.st_size if s else 0," + "'mtime_ns':s.st_mtime_ns if s else 0}))" + ) + return RemoteStat(**self.run_json_helper(script, {"path": path})) + + def read_text(self, path: str) -> str: + async def read() -> Any: + from swerex.runtime.abstract import ReadFileRequest + + return await self._deployment.runtime.read_file( + ReadFileRequest(path=path, encoding="utf-8", errors="replace") + ) + + return str(self._submit(read(), timeout=self.settings.runtime_timeout).content) + + def read_bytes(self, path: str) -> bytes: + result = self.exec( + f"base64 < {shlex.quote(path)}", + cwd=self.workspace_root, + timeout_s=120, + ) + if result.exit_code != 0: + raise RuntimeError(result.stderr or f"failed to read {path}") + return base64.b64decode("".join(result.stdout.splitlines())) + + def write_text(self, path: str, content: str) -> None: + temporary = f"{path}.clawd-tmp-{uuid.uuid4().hex}" + + async def write() -> Any: + from swerex.runtime.abstract import WriteFileRequest + + return await self._deployment.runtime.write_file( + WriteFileRequest(path=temporary, content=content) + ) + + self._submit(write(), timeout=self.settings.runtime_timeout) + result = self.exec( + f"mkdir -p {shlex.quote(posixpath.dirname(path))} && mv -f {shlex.quote(temporary)} {shlex.quote(path)}", + cwd=self.workspace_root, + timeout_s=120, + ) + if result.exit_code != 0: + raise RuntimeError(result.stderr or f"failed to install {path}") + + def upload_tree(self, local_path: Path, remote_path: str) -> None: + source = local_path.resolve() + + async def upload() -> Any: + from swerex.runtime.abstract import UploadRequest + + return await self._deployment.runtime.upload( + UploadRequest(source_path=str(source), target_path=remote_path) + ) + + self._submit(upload(), timeout=max(self.settings.runtime_timeout, 600)) + + def download_tree(self, remote_path: str, local_path: Path) -> None: + destination = local_path.resolve() + destination.mkdir(parents=True, exist_ok=True) + archive = f"/tmp/clawd-export-{uuid.uuid4().hex}.tar.gz" + parent = posixpath.dirname(remote_path) + name = posixpath.basename(remote_path) + create = self.exec( + f"tar -C {shlex.quote(parent)} -czf {shlex.quote(archive)} {shlex.quote(name)}", + cwd=self.workspace_root, + timeout_s=600, + ) + if create.exit_code != 0: + raise RuntimeError(create.stderr or "failed to archive remote workspace") + try: + size = self.stat(archive).size + if size > AGS_ARCHIVE_MAX_COMPRESSED_BYTES: + raise ValueError( + "sandbox workspace archive exceeds compressed-size limit " + f"({size} > {AGS_ARCHIVE_MAX_COMPRESSED_BYTES} bytes)" + ) + chunk_size = 384 * 1024 + packed = bytearray() + for offset in range(0, size, chunk_size): + command = ( + f"tail -c +{offset + 1} {shlex.quote(archive)} | " + f"head -c {min(chunk_size, size - offset)} | base64" + ) + result = self.exec(command, cwd=self.workspace_root, timeout_s=120) + if result.exit_code != 0: + raise RuntimeError(result.stderr or "failed to download remote workspace") + packed.extend(base64.b64decode("".join(result.stdout.splitlines()))) + with tempfile.NamedTemporaryFile(suffix=".tar.gz") as handle: + handle.write(packed) + handle.flush() + with tarfile.open(handle.name, "r:gz") as tar: + self._safe_extract(tar, destination) + extracted = destination / name + if extracted.is_dir(): + for item in extracted.iterdir(): + target = destination / item.name + if target.exists(): + if target.is_dir(): + shutil.rmtree(target) + else: + target.unlink() + item.rename(target) + extracted.rmdir() + finally: + self.exec(f"rm -f {shlex.quote(archive)}", cwd=self.workspace_root, timeout_s=60) + + @staticmethod + def _safe_extract(archive: tarfile.TarFile, destination: Path) -> None: + """Extract an AGS workspace archive without allowing path escapes. + + GNU tar represents additional names for the same inode as hard links and + repositories commonly contain internal symbolic links. Rejecting every + link therefore turns valid workspaces into infrastructure failures. We + validate the complete archive before extracting it instead: links are + accepted only when their targets stay inside the archive and hard links + resolve to a regular archived file. Internal symbolic links are then + materialized so the downloaded workspace can safely enter the stricter + score-context pipeline, which intentionally rejects all live symlinks. + """ + root = destination.resolve() + destination.mkdir(parents=True, exist_ok=True) + members = archive.getmembers() + if len(members) > AGS_ARCHIVE_MAX_MEMBERS: + raise ValueError( + "sandbox workspace archive exceeds member-count limit " + f"({len(members)} > {AGS_ARCHIVE_MAX_MEMBERS})" + ) + entries: dict[PurePosixPath, tarfile.TarInfo] = {} + normalized: list[tuple[tarfile.TarInfo, PurePosixPath]] = [] + logical_file_count = 0 + logical_total_bytes = 0 + + def charge_materialized_file(size: int, *, entry: str) -> None: + nonlocal logical_file_count, logical_total_bytes + if size < 0 or size > AGS_ARCHIVE_MAX_FILE_BYTES: + raise ValueError( + f"sandbox workspace archive entry exceeds file-size limit: {entry} " + f"({size} > {AGS_ARCHIVE_MAX_FILE_BYTES} bytes)" + ) + logical_file_count += 1 + logical_total_bytes += size + if logical_file_count > AGS_ARCHIVE_MAX_FILES: + raise ValueError( + "sandbox workspace archive exceeds file-count limit " + f"({logical_file_count} > {AGS_ARCHIVE_MAX_FILES})" + ) + if logical_total_bytes > AGS_ARCHIVE_MAX_TOTAL_BYTES: + raise ValueError( + "sandbox workspace archive exceeds expanded-size limit " + f"({logical_total_bytes} > {AGS_ARCHIVE_MAX_TOTAL_BYTES} bytes)" + ) + + def archive_path(value: str, *, relative_to: PurePosixPath | None = None) -> PurePosixPath: + if not value or "\x00" in value or PurePosixPath(value).is_absolute(): + raise ValueError(f"unsafe path in sandbox archive: {value}") + + parts = list(relative_to.parts if relative_to is not None else ()) + for part in value.split("/"): + if part in {"", "."}: + continue + if part == "..": + if not parts: + raise ValueError(f"unsafe path in sandbox archive: {value}") + parts.pop() + continue + parts.append(part) + if not parts: + raise ValueError(f"unsafe path in sandbox archive: {value}") + return PurePosixPath(*parts) + + def ensure_local_path(member: tarfile.TarInfo, path: PurePosixPath) -> None: + target = root.joinpath(*path.parts) + resolved = target.resolve(strict=False) + try: + resolved.relative_to(root) + except ValueError as error: + raise ValueError( + f"unsafe path in sandbox archive: {member.name}" + ) from error + + # Do not let a pre-existing link in the destination redirect an + # otherwise lexical-safe archive member. The normal caller uses a + # fresh temporary directory, but keeping the primitive safe makes it + # reusable and closes a subtle overwrite vector. + cursor = root + for part in path.parts: + cursor /= part + if cursor.is_symlink(): + raise ValueError(f"unsafe path in sandbox archive: {member.name}") + + for member in members: + path = archive_path(member.name) + ensure_local_path(member, path) + if not (member.isdir() or member.isreg() or member.issym() or member.islnk()): + raise ValueError(f"unsafe entry in sandbox archive: {member.name}") + if member.isreg(): + charge_materialized_file(int(member.size), entry=member.name) + + previous = entries.get(path) + if previous is not None and not (previous.isdir() and member.isdir()): + raise ValueError(f"unsafe duplicate entry in sandbox archive: {member.name}") + entries[path] = member + normalized.append((member, path)) + + for member, path in normalized: + parent = path.parent + while parent != PurePosixPath("."): + ancestor = entries.get(parent) + if ancestor is not None and not ancestor.isdir(): + raise ValueError(f"unsafe entry in sandbox archive: {member.name}") + parent = parent.parent + + if member.issym(): + # A symbolic link is relative to the link's containing directory. + link_path = archive_path(member.linkname, relative_to=path.parent) + target = entries.get(link_path) + target_is_implicit_directory = any( + link_path in candidate.parents for candidate in entries + ) + if target is None and not target_is_implicit_directory: + raise ValueError(f"unsafe entry in sandbox archive: {member.name}") + elif member.islnk(): + # Tar hard-link targets are relative to the archive root. Only a + # regular member may be linked: linking to another link or to a + # directory makes extraction order/security ambiguous. + link_path = archive_path(member.linkname) + target = entries.get(link_path) + if target is None or not target.isreg(): + raise ValueError(f"unsafe entry in sandbox archive: {member.name}") + charge_materialized_file(int(target.size), entry=member.name) + + # Python 3.12's data filter performs a second, extraction-time realpath + # check and strips unsafe ownership/mode bits. The complete validation + # above is also sufficient for supported Python 3.10/3.11 runtimes where + # extraction filters are not available. + if hasattr(tarfile, "data_filter"): + archive.extractall(destination, filter="data") + else: # pragma: no cover - exercised only on Python < 3.12 + archive.extractall(destination) + + # Resolve every link before changing any of them. This catches dangling + # links and cycles deterministically, and makes each copy source stable + # even when one link targets another link. + materializations: list[tuple[Path, Path]] = [] + for member, path in normalized: + if not member.issym(): + continue + link = root.joinpath(*path.parts) + try: + target = link.resolve(strict=True) + target.relative_to(root) + except (OSError, RuntimeError, ValueError) as error: + raise ValueError( + f"unsafe entry in sandbox archive: {member.name}" + ) from error + if not (target.is_file() or target.is_dir()): + raise ValueError(f"unsafe entry in sandbox archive: {member.name}") + if target.is_dir(): + try: + link.relative_to(target) + except ValueError: + pass + else: + # Copying a directory into one of its own descendants would + # recurse forever (for example ``pkg/link -> ..``). + raise ValueError(f"unsafe entry in sandbox archive: {member.name}") + materializations.append((link, target)) + + # Children first ensures a copied directory contains no remaining live + # symlinks. Hard links require no conversion: lstat already exposes them + # as ordinary regular files to score-context validation. + materializations.sort(key=lambda item: len(item[0].parts), reverse=True) + for link, target in materializations: + if target.is_dir(): + copied_files: list[Path] = [] + for current, directory_names, file_names in os.walk( + target, topdown=True, followlinks=False + ): + current_path = Path(current) + for name in directory_names: + child = current_path / name + if child.is_symlink(): + raise ValueError( + f"unsafe unresolved link in sandbox archive: {child}" + ) + for name in file_names: + child = current_path / name + if child.is_symlink() or not child.is_file(): + raise ValueError( + f"unsafe unresolved link in sandbox archive: {child}" + ) + copied_files.append(child) + for child in copied_files: + charge_materialized_file( + int(child.stat().st_size), entry=str(link) + ) + else: + charge_materialized_file(int(target.stat().st_size), entry=str(link)) + link.unlink() + if target.is_dir(): + shutil.copytree(target, link, symlinks=False) + else: + shutil.copy2(target, link) + + def reset_workspace(self) -> None: + result = self.exec( + ( + "mkdir -p /workspace && " + "rm -rf -- /workspace/* /workspace/.[!.]* /workspace/..?*" + ), + cwd="/", + timeout_s=120, + ) + if result.exit_code != 0: + raise RuntimeError(result.stderr or "failed to reset AGS workspace") + + def close(self) -> None: + if self._closed: + return + self._closed = True + try: + if self._deployment is not None and self._loop is not None: + # Under a large reward fan-out AGS stop requests can queue for + # longer than the old fixed 120-second deadline. Give cleanup + # enough time to finish, while keeping shutdown bounded. + cleanup_timeout = min(max(self.settings.runtime_timeout, 120), 600) + self._submit( + self._deployment.stop(), + timeout=cleanup_timeout, + operation="AGS sandbox cleanup", + ) + finally: + self._deployment = None + if self._loop is not None: + self._loop.call_soon_threadsafe(self._loop.stop) + if self._thread is not None: + self._thread.join(timeout=10) + if self._loop is not None: + self._loop.close() + self._loop = None + self._thread = None + + def __enter__(self) -> "AGSWorkspaceBackend": + return self.start() + + def __exit__(self, exc_type, exc, tb) -> None: + self.close() diff --git a/src/execution/backend.py b/src/execution/backend.py new file mode 100644 index 0000000..c96a115 --- /dev/null +++ b/src/execution/backend.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Protocol + + +@dataclass(frozen=True) +class CommandOutcome: + exit_code: int + stdout: str = "" + stderr: str = "" + + +@dataclass(frozen=True) +class RemoteStat: + path: str + exists: bool + is_file: bool = False + is_dir: bool = False + size: int = 0 + mtime_ns: int = 0 + + @property + def fingerprint(self) -> tuple[int, int]: + return self.mtime_ns, self.size + + +class WorkspaceBackend(Protocol): + """Synchronous interface used by Clawd's synchronous tool dispatcher.""" + + workspace_root: str + sandbox_id: str + + def resolve_path(self, path: str, *, cwd: str, local_root: Path) -> str: ... + + def exec( + self, + command: str, + *, + cwd: str, + timeout_s: int, + env: dict[str, str] | None = None, + ) -> CommandOutcome: ... + + def stat(self, path: str) -> RemoteStat: ... + + def read_text(self, path: str) -> str: ... + + def read_bytes(self, path: str) -> bytes: ... + + def write_text(self, path: str, content: str) -> None: ... + + def run_json_helper( + self, script: str, payload: dict[str, Any], *, timeout_s: int = 120 + ) -> Any: ... + + def upload_tree(self, local_path: Path, remote_path: str) -> None: ... + + def download_tree(self, remote_path: str, local_path: Path) -> None: ... + + def close(self) -> None: ... diff --git a/src/peer/__init__.py b/src/peer/__init__.py new file mode 100644 index 0000000..6e75ffd --- /dev/null +++ b/src/peer/__init__.py @@ -0,0 +1,20 @@ +"""Peer-native collaboration runtime without a privileged LLM lead.""" + +from .models import PeerParticipant, PeerRunConfig, PeerRunRecord +from .store import PeerStore + +__all__ = [ + "PeerParticipant", + "PeerRunConfig", + "PeerRunRecord", + "PeerRuntime", + "PeerStore", +] + + +def __getattr__(name: str): + if name == "PeerRuntime": + from .runtime import PeerRuntime + + return PeerRuntime + raise AttributeError(name) diff --git a/src/peer/backend.py b/src/peer/backend.py new file mode 100644 index 0000000..bfc570f --- /dev/null +++ b/src/peer/backend.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable, Protocol + +from ..agent.conversation import Conversation +from ..tool_system.agent_loop import AgentLoopResult, ToolEvent, run_agent_loop +from ..tool_system.context import ToolContext +from ..tool_system.registry import ToolRegistry + + +@dataclass(frozen=True) +class PeerSessionSpec: + run_id: str + peer_id: str + peer_name: str + session_id: str + workspace_path: str + model: str | None + + +@dataclass +class PeerSessionHandle: + spec: PeerSessionSpec + conversation: Conversation + provider: Any = None + boundary_index: int = 0 + + +@dataclass(frozen=True) +class PeerBoundaryResult: + response_text: str = "" + usage: dict[str, Any] | None = None + num_turns: int = 0 + cancelled: bool = False + + +class PeerSessionBackend(Protocol): + name: str + + def create_session( + self, spec: PeerSessionSpec, persisted: dict[str, Any] | None = None + ) -> PeerSessionHandle: ... + + def run_boundary( + self, + session: PeerSessionHandle, + prompt: str, + registry: ToolRegistry, + context: ToolContext, + *, + max_turns: int, + max_output_tokens: int, + should_stop: Callable[[], bool], + on_event: Callable[[ToolEvent], None] | None = None, + ) -> PeerBoundaryResult: ... + + def serialize_session(self, session: PeerSessionHandle) -> dict[str, Any]: ... + + def close_session(self, session: PeerSessionHandle) -> None: ... + + +class AgentLoopPeerBackend: + """Adapter from persistent peers to Clawd's current agent loop.""" + + name = "clawd-agent-loop" + + def __init__(self, provider_factory: Callable[[PeerSessionSpec], Any]) -> None: + self.provider_factory = provider_factory + + def create_session( + self, spec: PeerSessionSpec, persisted: dict[str, Any] | None = None + ) -> PeerSessionHandle: + conversation_data = (persisted or {}).get("conversation") + conversation = ( + Conversation.from_dict(conversation_data) + if isinstance(conversation_data, dict) + else Conversation(max_history=500) + ) + return PeerSessionHandle( + spec=spec, + conversation=conversation, + provider=self.provider_factory(spec), + ) + + def run_boundary( + self, + session: PeerSessionHandle, + prompt: str, + registry: ToolRegistry, + context: ToolContext, + *, + max_turns: int, + max_output_tokens: int, + should_stop: Callable[[], bool], + on_event: Callable[[ToolEvent], None] | None = None, + ) -> PeerBoundaryResult: + session.boundary_index += 1 + session.conversation.add_user_message(prompt) + result: AgentLoopResult = run_agent_loop( + conversation=session.conversation, + provider=session.provider, + tool_registry=registry, + tool_context=context, + max_turns=max_turns, + max_output_tokens=max_output_tokens, + stream=False, + verbose=False, + on_event=on_event, + should_stop=should_stop, + ) + return PeerBoundaryResult( + response_text=result.response_text, + usage=result.usage, + num_turns=result.num_turns, + cancelled=result.cancelled, + ) + + def serialize_session(self, session: PeerSessionHandle) -> dict[str, Any]: + return { + "session_id": session.spec.session_id, + "run_id": session.spec.run_id, + "peer_id": session.spec.peer_id, + "model": session.spec.model, + "boundary_index": session.boundary_index, + "conversation": session.conversation.to_dict(), + } + + def close_session(self, session: PeerSessionHandle) -> None: + return None + + +ScriptHandler = Callable[ + [PeerSessionHandle, str, ToolRegistry, ToolContext], + PeerBoundaryResult | None, +] + + +class ScriptedPeerBackend: + """Deterministic injectable backend used by protocol and smoke tests.""" + + name = "scripted" + + def __init__(self, handler: ScriptHandler) -> None: + self.handler = handler + + def create_session( + self, spec: PeerSessionSpec, persisted: dict[str, Any] | None = None + ) -> PeerSessionHandle: + boundary = int((persisted or {}).get("boundary_index", 0) or 0) + return PeerSessionHandle( + spec=spec, + conversation=Conversation(max_history=500), + boundary_index=boundary, + ) + + def run_boundary( + self, + session: PeerSessionHandle, + prompt: str, + registry: ToolRegistry, + context: ToolContext, + *, + max_turns: int, + max_output_tokens: int, + should_stop: Callable[[], bool], + on_event: Callable[[ToolEvent], None] | None = None, + ) -> PeerBoundaryResult: + if should_stop(): + return PeerBoundaryResult(response_text="[Run stopped]", cancelled=True) + session.boundary_index += 1 + session.conversation.add_user_message(prompt) + result = self.handler(session, prompt, registry, context) + return result or PeerBoundaryResult(response_text="idle", num_turns=1) + + def serialize_session(self, session: PeerSessionHandle) -> dict[str, Any]: + return { + "session_id": session.spec.session_id, + "run_id": session.spec.run_id, + "peer_id": session.spec.peer_id, + "boundary_index": session.boundary_index, + "conversation": session.conversation.to_dict(), + } + + def close_session(self, session: PeerSessionHandle) -> None: + return None diff --git a/src/peer/control.py b/src/peer/control.py new file mode 100644 index 0000000..55f8649 --- /dev/null +++ b/src/peer/control.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import threading +import time +from typing import Any + +from .policy import CommunicationPolicy +from .store import PeerStore +from .workspace import PeerWorkspaceManager + + +class PeerRunControl: + def __init__( + self, + run_id: str, + store: PeerStore, + policy: CommunicationPolicy, + workspace: PeerWorkspaceManager, + *, + timeout_seconds: float, + token_budget: int | None, + turn_budget: int | None, + ) -> None: + self.run_id = run_id + self.store = store + self.policy = policy + self.workspace = workspace + self.timeout_seconds = timeout_seconds + self.token_budget = token_budget + self.turn_budget = turn_budget + self.started_monotonic = time.monotonic() + self.stop_event = threading.Event() + self._lock = threading.RLock() + self._reason: str | None = None + + @property + def reason(self) -> str | None: + with self._lock: + return self._reason + + def should_stop(self) -> bool: + return self.stop_event.is_set() + + def remaining_seconds(self) -> float: + return max(0.0, self.timeout_seconds - (time.monotonic() - self.started_monotonic)) + + def request_stop(self, reason: str) -> None: + with self._lock: + if self._reason is None: + self._reason = reason + self.stop_event.set() + self.store.signal_bus.notify_run(self.run_id) + + def submit(self, peer_id: str, revision: str, summary: str) -> dict[str, Any]: + validation = self.workspace.validate_revision(revision) + submission, accepted = self.store.attempt_submission( + self.run_id, peer_id, revision, summary, validation + ) + if submission.status == "accepted": + self.request_stop("submitted") + return { + "status": submission.status, + "attempt": submission.to_dict(), + "accepted_submission": accepted, + } + + def record_usage( + self, peer_id: str, usage_delta: dict[str, int] + ) -> tuple[dict[str, int], dict[str, int]]: + run_usage, peer_usage = self.store.update_usage( + self.run_id, peer_id, usage_delta + ) + if self.token_budget is not None and run_usage["total_tokens"] >= self.token_budget: + self.request_stop("budget_exhausted") + if self.turn_budget is not None and run_usage["turns"] >= self.turn_budget: + self.request_stop("budget_exhausted") + return run_usage, peer_usage diff --git a/src/peer/models.py b/src/peer/models.py new file mode 100644 index 0000000..6181ac7 --- /dev/null +++ b/src/peer/models.py @@ -0,0 +1,304 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from typing import Any, ClassVar + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _from_dict(cls: type[Any], data: dict[str, Any]) -> Any: + return cls(**{key: data[key] for key in cls.__dataclass_fields__ if key in data}) + + +@dataclass(frozen=True) +class PeerRunConfig: + repo_path: str + mission: str + peers: int + communication: str = "p2p" + workspace_mode: str = "worktree" + provider: str = "scripted" + model: str | None = None + timeout_seconds: float = 300.0 + max_turns: int = 30 + max_output_tokens: int = 4096 + token_budget: int | None = None + turn_budget: int | None = None + output_dir: str | None = None + coordinator_peer: str | None = None + acceptance_command: list[str] | None = None + cleanup_worktrees: bool = True + + CONDITIONS: ClassVar[set[str]] = { + "solo", + "independent", + "none", + "artifact-only", + "star", + "p2p", + } + WORKSPACE_MODES: ClassVar[set[str]] = {"shared", "worktree"} + + def validate(self) -> None: + if not isinstance(self.mission, str) or not self.mission.strip(): + raise ValueError("mission must be a non-empty string") + if not isinstance(self.repo_path, str) or not self.repo_path.strip(): + raise ValueError("repo_path must be a non-empty path") + if not isinstance(self.provider, str) or not self.provider.strip(): + raise ValueError("provider must be a non-empty string") + if self.peers < 1 or self.peers > 32: + raise ValueError("peers must be between 1 and 32") + if self.communication not in self.CONDITIONS: + raise ValueError( + "communication must be one of: " + ", ".join(sorted(self.CONDITIONS)) + ) + if self.communication == "solo" and self.peers != 1: + raise ValueError("solo communication requires exactly one peer") + if self.communication != "solo" and self.peers < 2: + raise ValueError(f"{self.communication} communication requires at least two peers") + if self.workspace_mode not in self.WORKSPACE_MODES: + raise ValueError("workspace_mode must be shared or worktree") + if self.timeout_seconds <= 0 or self.timeout_seconds > 86_400: + raise ValueError("timeout_seconds must be between 0 and 86400") + if self.max_turns < 1 or self.max_turns > 100_000: + raise ValueError("max_turns must be between 1 and 100000") + if self.max_output_tokens < 1: + raise ValueError("max_output_tokens must be positive") + if self.token_budget is not None and self.token_budget < 1: + raise ValueError("token_budget must be positive") + if self.turn_budget is not None and self.turn_budget < 1: + raise ValueError("turn_budget must be positive") + if self.communication == "star" and self.coordinator_peer is not None: + value = self.coordinator_peer.strip() + if not value: + raise ValueError("coordinator_peer must be non-empty") + if self.acceptance_command is not None and ( + not self.acceptance_command + or not all(isinstance(item, str) and item for item in self.acceptance_command) + ): + raise ValueError("acceptance_command must be a non-empty argv list") + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass +class PeerRunRecord: + STATUSES: ClassVar[set[str]] = { + "created", + "running", + "submitted", + "completed", + "cancelled", + "timed_out", + "budget_exhausted", + "failed", + } + + run_id: str + mission: str + repo_path: str + base_revision: str + peer_count: int + communication: str + workspace_mode: str + provider: str + model: str | None + timeout_seconds: float + max_turns: int + max_output_tokens: int + token_budget: int | None + turn_budget: int | None + output_dir: str + coordinator_peer_id: str | None = None + acceptance_command: list[str] | None = None + status: str = "created" + stop_reason: str | None = None + accepted_submission: dict[str, Any] | None = None + usage: dict[str, int] = field( + default_factory=lambda: { + "input_tokens": 0, + "output_tokens": 0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "total_tokens": 0, + "turns": 0, + "model_calls": 0, + "tool_calls": 0, + } + ) + started_at: str | None = None + completed_at: str | None = None + created_at: str = field(default_factory=utc_now) + updated_at: str = field(default_factory=utc_now) + schema_version: int = 1 + + def __post_init__(self) -> None: + if self.status not in self.STATUSES: + raise ValueError(f"invalid peer run status: {self.status}") + + def set_status(self, status: str, *, reason: str | None = None) -> None: + if status not in self.STATUSES: + raise ValueError(f"invalid peer run status: {status}") + self.status = status + self.stop_reason = reason + self.updated_at = utc_now() + if status == "running": + self.started_at = self.started_at or self.updated_at + if status in { + "completed", + "cancelled", + "timed_out", + "budget_exhausted", + "failed", + }: + self.completed_at = self.updated_at + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "PeerRunRecord": + return _from_dict(cls, data) + + +@dataclass +class PeerParticipant: + STATUSES: ClassVar[set[str]] = { + "created", + "running", + "idle", + "stopping", + "stopped", + "failed", + } + + peer_id: str + run_id: str + name: str + session_id: str + workspace_mode: str + workspace_path: str + status: str = "created" + start_monotonic_ns: int | None = None + started_at: str | None = None + idle_at: str | None = None + wake_at: str | None = None + stopped_at: str | None = None + error_at: str | None = None + last_error: str | None = None + usage: dict[str, int] = field( + default_factory=lambda: { + "input_tokens": 0, + "output_tokens": 0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "total_tokens": 0, + "turns": 0, + "model_calls": 0, + "tool_calls": 0, + } + ) + created_at: str = field(default_factory=utc_now) + updated_at: str = field(default_factory=utc_now) + schema_version: int = 1 + + def __post_init__(self) -> None: + if self.status not in self.STATUSES: + raise ValueError(f"invalid peer status: {self.status}") + + def set_status(self, status: str, *, error: str | None = None) -> None: + if status not in self.STATUSES: + raise ValueError(f"invalid peer status: {status}") + now = utc_now() + self.status = status + self.updated_at = now + if status == "running": + self.started_at = self.started_at or now + elif status == "idle": + self.idle_at = now + elif status == "stopped": + self.stopped_at = now + elif status == "failed": + self.error_at = now + self.last_error = error + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "PeerParticipant": + return _from_dict(cls, data) + + +@dataclass +class PeerMessage: + message_id: str + run_id: str + sender_id: str + recipient_id: str + payload: Any + payload_size_bytes: int + summary: str | None = None + broadcast_id: str | None = None + idempotency_key: str | None = None + status: str = "delivered" + created_at: str = field(default_factory=utc_now) + delivered_at: str = field(default_factory=utc_now) + consumed_at: str | None = None + schema_version: int = 1 + + def consume(self) -> None: + if self.status == "delivered": + self.status = "consumed" + self.consumed_at = utc_now() + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "PeerMessage": + return _from_dict(cls, data) + + +@dataclass +class PeerBroadcast: + broadcast_id: str + run_id: str + sender_id: str + recipients: list[str] + message_ids: list[str] + payload_size_bytes: int + idempotency_key: str | None = None + created_at: str = field(default_factory=utc_now) + schema_version: int = 1 + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "PeerBroadcast": + return _from_dict(cls, data) + + +@dataclass +class PeerSubmission: + attempt_id: str + run_id: str + peer_id: str + revision: str + summary: str + status: str + validation: dict[str, Any] + created_at: str = field(default_factory=utc_now) + schema_version: int = 1 + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "PeerSubmission": + return _from_dict(cls, data) diff --git a/src/peer/policy.py b/src/peer/policy.py new file mode 100644 index 0000000..93f9658 --- /dev/null +++ b/src/peer/policy.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +class PolicyRejected(ValueError): + pass + + +@dataclass(frozen=True) +class CommunicationPolicy: + condition: str + peer_ids: tuple[str, ...] + coordinator_peer_id: str | None = None + + MESSAGE_CONDITIONS = {"star", "p2p"} + + def exposes_message_tools(self) -> bool: + return self.condition in self.MESSAGE_CONDITIONS + + def can_send(self, sender_id: str, recipient_id: str) -> bool: + if sender_id not in self.peer_ids or recipient_id not in self.peer_ids: + return False + if sender_id == recipient_id: + return False + if self.condition == "p2p": + return True + if self.condition == "star": + coordinator = self.coordinator_peer_id + return coordinator is not None and coordinator in {sender_id, recipient_id} + return False + + def require_send(self, sender_id: str, recipient_id: str) -> None: + if sender_id not in self.peer_ids: + raise PolicyRejected(f"sender is not a participant in this run: {sender_id}") + if recipient_id not in self.peer_ids: + raise PolicyRejected(f"recipient is not a participant in this run: {recipient_id}") + if sender_id == recipient_id: + raise PolicyRejected("peers cannot send messages to themselves") + if not self.can_send(sender_id, recipient_id): + raise PolicyRejected( + f"communication edge rejected by {self.condition} policy: " + f"{sender_id} -> {recipient_id}" + ) + + def broadcast_recipients(self, sender_id: str) -> list[str]: + if sender_id not in self.peer_ids: + raise PolicyRejected(f"sender is not a participant in this run: {sender_id}") + return [ + peer_id + for peer_id in self.peer_ids + if peer_id != sender_id and self.can_send(sender_id, peer_id) + ] diff --git a/src/peer/runner.py b/src/peer/runner.py new file mode 100644 index 0000000..96c2f9a --- /dev/null +++ b/src/peer/runner.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any, Callable + +from ..config import get_default_provider, get_provider_config +from ..providers import get_provider_class, normalize_provider_name +from ..runner import _provider_settings +from ..tool_system.defaults import build_default_registry +from ..tool_system.registry import ToolRegistry +from .backend import AgentLoopPeerBackend, PeerSessionBackend, PeerSessionSpec +from .models import PeerRunConfig +from .runtime import PeerRuntime + + +def build_peer_provider_factory( + provider_name: str | None, model: str | None +) -> tuple[str, str | None, Callable[[PeerSessionSpec], Any]]: + selected = normalize_provider_name(provider_name or get_default_provider()) + provider_config = get_provider_config(selected) + api_key, base_url, selected_model = _provider_settings( + selected, provider_config, model + ) + if not api_key: + raise ValueError( + f"API key is not configured for {selected}; configure it before a real peer run" + ) + provider_class = get_provider_class(selected) + + def factory(spec: PeerSessionSpec) -> Any: + return provider_class( + api_key=api_key, + base_url=base_url, + model=spec.model or selected_model, + ) + + return selected, selected_model, factory + + +def run_peer_collaboration( + mission: str, + *, + repo: str | Path, + peers: int, + communication: str, + workspace_mode: str, + provider_name: str | None = None, + model: str | None = None, + timeout_seconds: float = 300.0, + max_turns: int = 30, + max_output_tokens: int = 4096, + token_budget: int | None = None, + turn_budget: int | None = None, + output_dir: str | Path | None = None, + coordinator_peer: str | None = None, + acceptance_command: list[str] | None = None, + cleanup_worktrees: bool = True, + backend: PeerSessionBackend | None = None, + base_registry: ToolRegistry | None = None, + run_id: str | None = None, +) -> dict[str, Any]: + if not isinstance(mission, str) or not mission.strip(): + raise ValueError("mission must be a non-empty string") + selected_provider = provider_name or "scripted" + selected_model = model + selected_backend = backend + if selected_backend is None: + selected_provider, selected_model, factory = build_peer_provider_factory( + provider_name, model + ) + selected_backend = AgentLoopPeerBackend(factory) + registry = base_registry or build_default_registry(include_user_tools=False) + config = PeerRunConfig( + repo_path=str(Path(repo).expanduser().resolve()), + mission=mission.strip(), + peers=peers, + communication=communication, + workspace_mode=workspace_mode, + provider=selected_provider, + model=selected_model, + timeout_seconds=timeout_seconds, + max_turns=max_turns, + max_output_tokens=max_output_tokens, + token_budget=token_budget, + turn_budget=turn_budget, + output_dir=(str(Path(output_dir).expanduser().resolve()) if output_dir else None), + coordinator_peer=coordinator_peer, + acceptance_command=acceptance_command, + cleanup_worktrees=cleanup_worktrees, + ) + return PeerRuntime(selected_backend, registry).run(config, run_id=run_id) diff --git a/src/peer/runtime.py b/src/peer/runtime.py new file mode 100644 index 0000000..4bc4df2 --- /dev/null +++ b/src/peer/runtime.py @@ -0,0 +1,660 @@ +from __future__ import annotations + +import hashlib +import threading +import time +import uuid +from pathlib import Path +from typing import Any + +from ..tool_system.context import ToolContext +from ..tool_system.permissions import ToolPermissionContext +from ..tool_system.protocol import ToolCall, ToolResult +from ..tool_system.registry import ToolRegistry +from ..tool_system.tools.tool_search import ToolSearchTool +from .backend import PeerBoundaryResult, PeerSessionBackend, PeerSessionSpec +from .control import PeerRunControl +from .models import PeerParticipant, PeerRunConfig, PeerRunRecord, utc_now +from .policy import CommunicationPolicy +from .store import PeerSignalBus, PeerStore +from .tools import ( + PeerBroadcastTool, + PeerListTool, + PeerReadMessagesTool, + PeerSendMessageTool, + PeerSubmitTool, +) +from .workspace import PeerWorkspaceManager + + +_FORBIDDEN_PEER_TOOLS = { + "Agent", + "AskUserQuestion", + "EnterWorktree", + "ExitWorktree", + "ReadMessages", + "RemoteTrigger", + "SendMessage", + "SendUserMessage", + "TaskCreate", + "TaskGet", + "TaskList", + "TaskOutput", + "TaskRetry", + "TaskStop", + "TaskUpdate", + "ToolSearch", + "TeamCancel", + "TeamCreate", + "TeamDelete", + "TeamIntegrate", + "TeamResume", + "TeamRun", + "TeammateCreate", + "TeammateResume", + "TeammateStop", +} + + +class GuardedPeerRegistry(ToolRegistry): + def __init__(self, tools: list[Any], control: PeerRunControl): + super().__init__(tools) + self.control = control + + def dispatch(self, call: ToolCall, context: ToolContext) -> ToolResult: + if self.control.should_stop(): + return ToolResult( + name=call.name, + output={"error": "peer run has stopped; tool calls are no longer allowed"}, + is_error=True, + tool_use_id=call.tool_use_id, + ) + if call.name.casefold() in {"sendmessage", "readmessages", "broadcast"} and self.get(call.name) is None: + peer_id = context.peer_id or "unknown" + reason = ( + f"{call.name} is unavailable under " + f"{self.control.policy.condition} communication policy" + ) + self.control.store.record_policy_rejection( + self.control.run_id, + peer_id, + None, + call.name, + reason, + ) + return ToolResult( + name=call.name, + output={"error": reason}, + is_error=True, + tool_use_id=call.tool_use_id, + ) + return super().dispatch(call, context) + + +class PeerRuntime: + """Non-LLM supervisor for fixed-size peer-native collaboration runs.""" + + def __init__(self, backend: PeerSessionBackend, base_registry: ToolRegistry) -> None: + self.backend = backend + self.base_registry = base_registry + self._controls: dict[str, PeerRunControl] = {} + self._controls_lock = threading.Lock() + + def run(self, config: PeerRunConfig, *, run_id: str | None = None) -> dict[str, Any]: + config.validate() + started = time.monotonic() + selected_run_id = run_id or uuid.uuid4().hex[:16] + repo_path = Path(config.repo_path).expanduser().resolve() + if not repo_path.is_dir(): + raise ValueError(f"repo_path is not a directory: {repo_path}") + output_base = ( + Path(config.output_dir).expanduser().resolve() + if config.output_dir + else repo_path / ".clawd" / "peer-runs" + ) + self._exclude_control_state(repo_path, output_base) + signal_bus = PeerSignalBus() + store = PeerStore(output_base, signal_bus=signal_bus) + workspace = PeerWorkspaceManager( + repo_path, + selected_run_id, + config.workspace_mode, + cleanup_worktrees=config.cleanup_worktrees, + ) + peer_ids = tuple( + f"{selected_run_id[:8]}-p{index}" for index in range(1, config.peers + 1) + ) + coordinator = self._resolve_coordinator(config, peer_ids) + policy = CommunicationPolicy(config.communication, peer_ids, coordinator) + run = PeerRunRecord( + run_id=selected_run_id, + mission=config.mission, + repo_path=str(repo_path), + base_revision=workspace.base_revision, + peer_count=config.peers, + communication=config.communication, + workspace_mode=config.workspace_mode, + provider=config.provider, + model=config.model, + timeout_seconds=config.timeout_seconds, + max_turns=config.max_turns, + max_output_tokens=config.max_output_tokens, + token_budget=config.token_budget, + turn_budget=config.turn_budget, + output_dir=str(output_base), + coordinator_peer_id=coordinator, + acceptance_command=config.acceptance_command, + ) + manifest = self._manifest(config, run, peer_ids) + store.create_run(run, manifest) + participants: list[PeerParticipant] = [] + try: + for index, peer_id in enumerate(peer_ids, start=1): + name = f"peer-{index}" + workspace_path = workspace.prepare(peer_id, name) + participant = PeerParticipant( + peer_id=peer_id, + run_id=selected_run_id, + name=name, + session_id=uuid.uuid4().hex, + workspace_mode=config.workspace_mode, + workspace_path=str(workspace_path), + ) + participants.append(participant) + store.save_participant(participant) + store.save_session( + selected_run_id, + participant.session_id, + { + "session_id": participant.session_id, + "run_id": selected_run_id, + "peer_id": peer_id, + "boundary_index": 0, + "conversation": {"messages": [], "max_history": 500}, + }, + ) + store.append_event( + selected_run_id, + "peer.created", + { + "peer_id": peer_id, + "name": name, + "session_id": participant.session_id, + "workspace_path": str(workspace_path), + }, + ) + except Exception as exc: + run.set_status("failed", reason=f"workspace setup failed: {exc}") + store.save_run(run) + store.append_event( + selected_run_id, + "peer_run.failed", + {"phase": "workspace_setup", "error": str(exc)}, + ) + workspace.cleanup() + raise + + control = PeerRunControl( + selected_run_id, + store, + policy, + workspace, + timeout_seconds=config.timeout_seconds, + token_budget=config.token_budget, + turn_budget=config.turn_budget, + ) + with self._controls_lock: + self._controls[selected_run_id] = control + run.set_status("running") + store.save_run(run) + store.append_event( + selected_run_id, + "peer_run.started", + { + "peer_count": config.peers, + "communication": config.communication, + "workspace_mode": config.workspace_mode, + }, + ) + + barrier = threading.Barrier(config.peers) + threads = [ + threading.Thread( + target=self._run_peer, + name=f"clawd-peer-{participant.name}", + daemon=True, + args=(participant, config, store, control, barrier), + ) + for participant in participants + ] + for thread in threads: + thread.start() + + if not control.stop_event.wait(config.timeout_seconds): + control.request_stop("timeout") + shutdown_grace_seconds = min( + 60.0, max(10.0, config.timeout_seconds * 0.2) + ) + shutdown_deadline = time.monotonic() + shutdown_grace_seconds + for thread in threads: + thread.join(timeout=max(0.0, shutdown_deadline - time.monotonic())) + orphan_threads = [thread.name for thread in threads if thread.is_alive()] + if orphan_threads: + store.append_event( + selected_run_id, + "peer_run.orphan_threads", + {"threads": orphan_threads}, + ) + + current = store.load_run(selected_run_id) + if current is None: + raise RuntimeError("peer run state disappeared") + reason = control.reason or "all_peers_stopped" + acceptance: dict[str, Any] | None = None + if current.accepted_submission is not None and config.acceptance_command: + acceptance = workspace.run_acceptance( + str(current.accepted_submission["revision"]), + config.acceptance_command, + timeout_seconds=min(600.0, config.timeout_seconds), + ) + store.append_event( + selected_run_id, + "acceptance.completed", + { + "command": acceptance["command"], + "exit_code": acceptance["exit_code"], + "stdout_size": len(str(acceptance.get("stdout") or "").encode()), + "stderr_size": len(str(acceptance.get("stderr") or "").encode()), + }, + ) + current_participants = store.list_participants(selected_run_id) + attribution = workspace.attribution(current_participants) + retained_worktrees = workspace.cleanup() + terminal_status = self._terminal_status(current, reason, orphan_threads) + current.set_status(terminal_status, reason=reason) + store.save_run(current) + wall_time = time.monotonic() - started + result = { + "schema_version": 1, + "run_id": selected_run_id, + "status": current.status, + "stop_reason": reason, + "accepted_submission": current.accepted_submission, + "run": current.to_dict(), + "participants": [peer.to_dict() for peer in store.list_participants(selected_run_id)], + "messages": [message.to_dict() for message in store.list_messages(selected_run_id)], + "broadcasts": [item.to_dict() for item in store.list_broadcasts(selected_run_id)], + "submissions": [item.to_dict() for item in store.list_submissions(selected_run_id)], + "workspace_attribution": attribution, + "acceptance": acceptance, + "usage": current.usage, + "wall_time_seconds": round(wall_time, 6), + "orphan_threads": orphan_threads, + "retained_worktrees": retained_worktrees, + "manifest_path": str(store.run_dir(selected_run_id) / "manifest.json"), + "events_path": str(store.run_dir(selected_run_id) / "events.jsonl"), + } + result_path = store.save_result(selected_run_id, result) + result["result_path"] = str(result_path) + store.save_result(selected_run_id, result) + store.append_event( + selected_run_id, + f"peer_run.{terminal_status}", + { + "stop_reason": reason, + "accepted_submission": current.accepted_submission, + "wall_time_seconds": result["wall_time_seconds"], + "usage": current.usage, + "acceptance_exit_code": ( + acceptance.get("exit_code") if acceptance is not None else None + ), + }, + ) + with self._controls_lock: + self._controls.pop(selected_run_id, None) + return result + + def cancel(self, run_id: str, reason: str = "cancelled") -> bool: + with self._controls_lock: + control = self._controls.get(run_id) + if control is None: + return False + control.request_stop(reason) + return True + + def _run_peer( + self, + participant: PeerParticipant, + config: PeerRunConfig, + store: PeerStore, + control: PeerRunControl, + barrier: threading.Barrier, + ) -> None: + session = None + try: + barrier.wait(timeout=min(30.0, config.timeout_seconds)) + participant, _ = store.mutate_participant( + participant.run_id, + participant.peer_id, + lambda peer: self._set_peer_running(peer), + ) + store.append_event( + participant.run_id, + "peer.started", + { + "peer_id": participant.peer_id, + "name": participant.name, + "session_id": participant.session_id, + }, + ) + spec = PeerSessionSpec( + run_id=participant.run_id, + peer_id=participant.peer_id, + peer_name=participant.name, + session_id=participant.session_id, + workspace_path=participant.workspace_path, + model=config.model, + ) + session = self.backend.create_session( + spec, store.load_session(participant.run_id, participant.session_id) + ) + context = self._peer_context(participant, store, control) + registry = self._peer_registry(control) + prompt = config.mission + first_boundary = True + while not control.should_stop(): + latest = store.load_participant(participant.run_id, participant.peer_id) + if latest is None: + raise RuntimeError("peer participant state disappeared") + remaining_turns = config.max_turns - int(latest.usage.get("turns", 0)) + if remaining_turns <= 0: + raise RuntimeError("peer max_turns exhausted without a submission") + if not first_boundary: + participant, _ = store.mutate_participant( + participant.run_id, + participant.peer_id, + lambda peer: peer.set_status("idle"), + ) + store.append_event( + participant.run_id, + "peer.idle", + {"peer_id": participant.peer_id}, + ) + woke = store.wait_for_unread( + participant.run_id, + participant.peer_id, + control.remaining_seconds(), + stop_event=control.stop_event, + ) + if control.should_stop(): + break + if not woke: + continue + incoming = store.consume_messages( + participant.run_id, participant.peer_id + ) + participant, _ = store.mutate_participant( + participant.run_id, + participant.peer_id, + lambda peer: self._set_peer_woken(peer), + ) + store.append_event( + participant.run_id, + "peer.woken", + { + "peer_id": participant.peer_id, + "message_ids": [message.message_id for message in incoming], + }, + ) + prompt = self._incoming_prompt(incoming) + first_boundary = False + counters = {"model_calls": 0, "tool_calls": 0} + + def count_event(event: Any) -> None: + if event.kind == "model_response": + counters["model_calls"] += 1 + elif event.kind == "tool_use": + counters["tool_calls"] += 1 + + result: PeerBoundaryResult = self.backend.run_boundary( + session, + prompt, + registry, + context, + max_turns=remaining_turns, + max_output_tokens=config.max_output_tokens, + should_stop=control.should_stop, + on_event=count_event, + ) + store.save_session( + participant.run_id, + participant.session_id, + self.backend.serialize_session(session), + ) + usage = result.usage or {} + control.record_usage( + participant.peer_id, + { + "input_tokens": int(usage.get("input_tokens", 0) or 0), + "output_tokens": int(usage.get("output_tokens", 0) or 0), + "cache_creation_input_tokens": int( + usage.get("cache_creation_input_tokens", 0) or 0 + ), + "cache_read_input_tokens": int( + usage.get("cache_read_input_tokens", 0) or 0 + ), + "turns": int(result.num_turns), + "model_calls": counters["model_calls"], + "tool_calls": counters["tool_calls"], + }, + ) + if result.cancelled or control.should_stop(): + break + if result.response_text == "[Max tool turns reached]": + raise RuntimeError(result.response_text) + except threading.BrokenBarrierError as exc: + self._fail_peer(store, participant, f"peer start barrier failed: {exc}") + control.request_stop("startup_failed") + except Exception as exc: + self._fail_peer(store, participant, str(exc)) + statuses = [peer.status for peer in store.list_participants(participant.run_id)] + if statuses and all(status == "failed" for status in statuses): + control.request_stop("all_peers_failed") + finally: + if session is not None: + try: + self.backend.close_session(session) + except Exception: + pass + latest = store.load_participant(participant.run_id, participant.peer_id) + if latest is not None and latest.status != "failed": + latest, _ = store.mutate_participant( + participant.run_id, + participant.peer_id, + lambda peer: peer.set_status("stopped"), + ) + store.append_event( + participant.run_id, + "peer.stopped", + { + "peer_id": participant.peer_id, + "reason": control.reason, + }, + ) + + def _peer_registry(self, control: PeerRunControl) -> GuardedPeerRegistry: + tools: list[Any] = [] + for spec in self.base_registry.list_specs(): + if spec.name in _FORBIDDEN_PEER_TOOLS: + continue + tool = self.base_registry.get(spec.name) + if tool is not None: + tools.append(tool) + tools.extend([PeerListTool(), PeerSubmitTool()]) + if control.policy.exposes_message_tools(): + tools.extend( + [PeerSendMessageTool(), PeerReadMessagesTool(), PeerBroadcastTool()] + ) + registry = GuardedPeerRegistry(tools, control) + registry.register(ToolSearchTool(registry)) + return registry + + @staticmethod + def _peer_context( + participant: PeerParticipant, store: PeerStore, control: PeerRunControl + ) -> ToolContext: + workspace = Path(participant.workspace_path) + context = ToolContext( + workspace_root=workspace, + cwd=workspace, + permission_context=ToolPermissionContext.from_iterables( + workspace_root=workspace + ), + actor_id=participant.peer_id, + model_override=control.store.load_run(participant.run_id).model, # type: ignore[union-attr] + peer_store=store, + peer_run_id=participant.run_id, + peer_id=participant.peer_id, + peer_control=control, + system_prompt_extra=PeerRuntime.peer_system_context(participant), + ) + return context + + @staticmethod + def peer_system_context(participant: PeerParticipant) -> str: + return ( + "## Peer Collaboration Context\n" + "You are an equal coding peer in a peer-native collaboration run. " + "No participant has supervisory authority over another participant. " + "You may inspect the repository, decide what useful work to pursue, and coordinate " + "through the communication tools exposed by the run protocol.\n" + f"Your stable identity is `{participant.name}` (`{participant.peer_id}`). " + "Use PeerList to inspect the common roster. Any participant may call PeerSubmit " + "with a verifiable final Git revision. Remain available for later peer messages " + "after a local work interval ends." + ) + + @staticmethod + def _incoming_prompt(messages: list[Any]) -> str: + lines = ["New peer messages were delivered while this session was available:"] + for message in messages: + lines.append( + f"- message_id={message.message_id} from={message.sender_id} " + f"summary={message.summary or ''}\n payload={message.payload!r}" + ) + lines.append( + "Continue working from the persistent session state. You may respond, adapt the " + "repository, become available again, or submit a final revision." + ) + return "\n".join(lines) + + @staticmethod + def _set_peer_running(peer: PeerParticipant) -> None: + peer.start_monotonic_ns = peer.start_monotonic_ns or time.monotonic_ns() + peer.set_status("running") + + @staticmethod + def _set_peer_woken(peer: PeerParticipant) -> None: + peer.wake_at = utc_now() + peer.set_status("running") + + @staticmethod + def _fail_peer(store: PeerStore, participant: PeerParticipant, error: str) -> None: + try: + failed, _ = store.mutate_participant( + participant.run_id, + participant.peer_id, + lambda peer: peer.set_status("failed", error=error), + ) + store.append_event( + participant.run_id, + "peer.failed", + {"peer_id": failed.peer_id, "error": error}, + ) + except Exception: + return + + @staticmethod + def _resolve_coordinator( + config: PeerRunConfig, peer_ids: tuple[str, ...] + ) -> str | None: + if config.communication != "star": + return None + requested = (config.coordinator_peer or "peer-1").strip() + if requested in peer_ids: + return requested + if requested.startswith("peer-"): + try: + index = int(requested.split("-", 1)[1]) - 1 + except ValueError: + index = -1 + if 0 <= index < len(peer_ids): + return peer_ids[index] + raise ValueError(f"unknown star coordinator peer: {requested}") + + def _manifest( + self, + config: PeerRunConfig, + run: PeerRunRecord, + peer_ids: tuple[str, ...], + ) -> dict[str, Any]: + noncommunication_tools = [ + spec.name + for spec in self.base_registry.list_specs() + if spec.name not in _FORBIDDEN_PEER_TOOLS + ] + noncommunication_tools.append("ToolSearch") + return { + "schema_version": 1, + "run_id": run.run_id, + "created_at": run.created_at, + "config": config.to_dict(), + "repo_path": run.repo_path, + "repo_revision": run.base_revision, + "mission_sha256": hashlib.sha256(config.mission.encode()).hexdigest(), + "mission": config.mission, + "peer_ids": list(peer_ids), + "coordinator_peer_id": run.coordinator_peer_id, + "backend": self.backend.name, + "noncommunication_tools": noncommunication_tools, + "communication_tools": ( + ["PeerList", "PeerSubmit", "SendMessage", "ReadMessages", "Broadcast"] + if config.communication in {"star", "p2p"} + else ["PeerList", "PeerSubmit"] + ), + "process_isolation": False, + } + + @staticmethod + def _terminal_status( + run: PeerRunRecord, reason: str, orphan_threads: list[str] + ) -> str: + if orphan_threads: + return "failed" + if run.accepted_submission is not None: + return "completed" + if reason == "timeout": + return "timed_out" + if reason == "budget_exhausted": + return "budget_exhausted" + if reason in {"cancelled", "user_cancelled"}: + return "cancelled" + return "failed" + + @staticmethod + def _exclude_control_state(repo_path: Path, output_base: Path) -> None: + try: + relative = output_base.relative_to(repo_path).as_posix().rstrip("/") + "/" + except ValueError: + return + exclude = repo_path / ".git" / "info" / "exclude" + if not exclude.parent.is_dir(): + return + existing = exclude.read_text(encoding="utf-8") if exclude.is_file() else "" + lines = {line.strip() for line in existing.splitlines()} + if relative in lines: + return + with exclude.open("a", encoding="utf-8") as handle: + if existing and not existing.endswith("\n"): + handle.write("\n") + handle.write(relative + "\n") diff --git a/src/peer/store.py b/src/peer/store.py new file mode 100644 index 0000000..b434052 --- /dev/null +++ b/src/peer/store.py @@ -0,0 +1,731 @@ +from __future__ import annotations + +import json +import os +import threading +import time +import uuid +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Callable, Iterator + +try: + import fcntl +except ImportError: # pragma: no cover - Windows fallback + fcntl = None # type: ignore[assignment] + +from .models import ( + PeerBroadcast, + PeerMessage, + PeerParticipant, + PeerRunRecord, + PeerSubmission, + utc_now, +) +from .policy import CommunicationPolicy, PolicyRejected + + +_LOCKS: dict[str, threading.RLock] = {} +_LOCKS_GUARD = threading.Lock() + + +def _thread_lock(path: Path) -> threading.RLock: + key = str(path.resolve()) + with _LOCKS_GUARD: + return _LOCKS.setdefault(key, threading.RLock()) + + +@contextmanager +def _locked_path(path: Path) -> Iterator[None]: + path.parent.mkdir(parents=True, exist_ok=True) + with _thread_lock(path): + with path.open("a+", encoding="utf-8") as handle: + if fcntl is not None: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX) + try: + yield + finally: + if fcntl is not None: + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + + +class PeerSignalBus: + """In-process event delivery for the first thread-based peer backend.""" + + def __init__(self) -> None: + self._condition = threading.Condition() + self._generations: dict[tuple[str, str], int] = {} + + def generation(self, run_id: str, peer_id: str) -> int: + with self._condition: + return self._generations.get((run_id, peer_id), 0) + + def notify(self, run_id: str, peer_id: str) -> None: + with self._condition: + key = (run_id, peer_id) + self._generations[key] = self._generations.get(key, 0) + 1 + self._condition.notify_all() + + def notify_run(self, run_id: str) -> None: + with self._condition: + for key in list(self._generations): + if key[0] == run_id: + self._generations[key] += 1 + self._condition.notify_all() + + def wait( + self, + run_id: str, + peer_id: str, + generation: int, + timeout: float | None, + stop_event: threading.Event | None = None, + ) -> bool: + deadline = None if timeout is None else time.monotonic() + max(0.0, timeout) + with self._condition: + while self._generations.get((run_id, peer_id), 0) == generation: + if stop_event is not None and stop_event.is_set(): + return False + remaining = None if deadline is None else deadline - time.monotonic() + if remaining is not None and remaining <= 0: + return False + self._condition.wait(remaining) + return True + + +class PeerStore: + """Filesystem-backed peer state with run-wide atomic mutations.""" + + MAX_PAYLOAD_BYTES = 64 * 1024 + + def __init__(self, base_dir: str | Path, *, signal_bus: PeerSignalBus | None = None): + self.base_dir = Path(base_dir).expanduser().resolve() + self.signal_bus = signal_bus or PeerSignalBus() + + def run_dir(self, run_id: str) -> Path: + return self.base_dir / run_id + + def _state_lock(self, run_id: str) -> Path: + return self.run_dir(run_id) / ".state.lock" + + def create_run(self, run: PeerRunRecord, manifest: dict[str, Any]) -> None: + directory = self.run_dir(run.run_id) + if directory.exists(): + raise ValueError(f"peer run already exists: {run.run_id}") + for name in ("participants", "sessions", "messages", "broadcasts", "submissions"): + (directory / name).mkdir(parents=True, exist_ok=True) + self._write_json(directory / "run.json", run.to_dict()) + self._write_json(directory / "manifest.json", manifest) + (directory / "events.jsonl").touch() + self.append_event(run.run_id, "peer_run.created", {"run": run.to_dict()}) + + def load_run(self, run_id: str) -> PeerRunRecord | None: + path = self.run_dir(run_id) / "run.json" + if not path.is_file(): + return None + return PeerRunRecord.from_dict(self._read_json(path)) + + def save_run(self, run: PeerRunRecord) -> None: + with _locked_path(self._state_lock(run.run_id)): + self._write_json_unlocked(self.run_dir(run.run_id) / "run.json", run.to_dict()) + + def mutate_run( + self, run_id: str, mutator: Callable[[PeerRunRecord], Any] + ) -> tuple[PeerRunRecord, Any]: + with _locked_path(self._state_lock(run_id)): + run = self._load_run_unlocked(run_id) + result = mutator(run) + self._write_json_unlocked(self.run_dir(run_id) / "run.json", run.to_dict()) + return run, result + + def save_participant(self, participant: PeerParticipant) -> None: + with _locked_path(self._state_lock(participant.run_id)): + self._write_json_unlocked( + self._participant_path(participant.run_id, participant.peer_id), + participant.to_dict(), + ) + + def load_participant(self, run_id: str, peer_id: str) -> PeerParticipant | None: + path = self._participant_path(run_id, peer_id) + if not path.is_file(): + return None + return PeerParticipant.from_dict(self._read_json(path)) + + def list_participants(self, run_id: str) -> list[PeerParticipant]: + directory = self.run_dir(run_id) / "participants" + if not directory.is_dir(): + return [] + peers = [ + PeerParticipant.from_dict(self._read_json(path)) + for path in sorted(directory.glob("*.json")) + ] + peers.sort(key=lambda peer: (peer.name, peer.peer_id)) + return peers + + def find_participant(self, run_id: str, identity: str) -> PeerParticipant | None: + normalized = identity.strip().casefold() + for peer in self.list_participants(run_id): + if peer.peer_id == identity or peer.name.casefold() == normalized: + return peer + return None + + def mutate_participant( + self, + run_id: str, + peer_id: str, + mutator: Callable[[PeerParticipant], Any], + ) -> tuple[PeerParticipant, Any]: + with _locked_path(self._state_lock(run_id)): + path = self._participant_path(run_id, peer_id) + if not path.is_file(): + raise ValueError(f"unknown peer: {peer_id}") + participant = PeerParticipant.from_dict(self._read_json(path)) + result = mutator(participant) + self._write_json_unlocked(path, participant.to_dict()) + return participant, result + + def save_session(self, run_id: str, session_id: str, value: dict[str, Any]) -> None: + with _locked_path(self._state_lock(run_id)): + self._write_json_unlocked( + self.run_dir(run_id) / "sessions" / f"{session_id}.json", value + ) + + def load_session(self, run_id: str, session_id: str) -> dict[str, Any] | None: + path = self.run_dir(run_id) / "sessions" / f"{session_id}.json" + return self._read_json(path) if path.is_file() else None + + def append_event( + self, + run_id: str, + event_type: str, + data: dict[str, Any] | None = None, + *, + created_at: str | None = None, + monotonic_ns: int | None = None, + ) -> dict[str, Any]: + event = self._new_event( + run_id, + event_type, + data, + created_at=created_at, + monotonic_ns=monotonic_ns, + ) + with _locked_path(self._state_lock(run_id)): + self._append_event_unlocked(run_id, event) + return event + + def list_events(self, run_id: str) -> list[dict[str, Any]]: + path = self.run_dir(run_id) / "events.jsonl" + if not path.is_file(): + return [] + result: list[dict[str, Any]] = [] + with path.open(encoding="utf-8") as handle: + for line in handle: + try: + value = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(value, dict): + result.append(value) + return result + + def record_policy_rejection( + self, + run_id: str, + sender_id: str, + recipient_id: str | None, + operation: str, + reason: str, + ) -> None: + self.append_event( + run_id, + "policy.rejected", + { + "peer_id": sender_id, + "recipient_id": recipient_id, + "operation": operation, + "reason": reason, + }, + ) + + def send_message( + self, + run_id: str, + sender_id: str, + recipient: str, + payload: Any, + *, + policy: CommunicationPolicy, + summary: str | None = None, + idempotency_key: str | None = None, + broadcast_id: str | None = None, + ) -> PeerMessage: + encoded = self._validate_payload(payload) + if summary is not None and (not isinstance(summary, str) or len(summary) > 1_000): + raise ValueError("summary must be a string of at most 1000 characters") + sender_peer = self.find_participant(run_id, sender_id) + recipient_peer = self.find_participant(run_id, recipient) + recipient_id = recipient_peer.peer_id if recipient_peer is not None else recipient + if sender_peer is None: + reason = f"sender is not a participant in this run: {sender_id}" + self.record_policy_rejection(run_id, sender_id, recipient_id, "send", reason) + raise PolicyRejected(reason) + if recipient_peer is None: + reason = f"recipient is not a participant in this run: {recipient_id}" + self.record_policy_rejection(run_id, sender_id, recipient_id, "send", reason) + raise PolicyRejected(reason) + try: + policy.require_send(sender_id, recipient_id) + except PolicyRejected as exc: + self.record_policy_rejection( + run_id, sender_id, recipient_id, "send", str(exc) + ) + raise + + with _locked_path(self._state_lock(run_id)): + if idempotency_key: + previous = self._find_idempotent_message_unlocked( + run_id, sender_id, recipient_id, idempotency_key, broadcast_id + ) + if previous is not None: + return previous + now = utc_now() + message = PeerMessage( + message_id=uuid.uuid4().hex, + run_id=run_id, + sender_id=sender_id, + recipient_id=recipient_id, + payload=payload, + payload_size_bytes=len(encoded), + summary=summary, + idempotency_key=idempotency_key, + broadcast_id=broadcast_id, + created_at=now, + delivered_at=now, + ) + self._write_json_unlocked(self._message_path(run_id, message.message_id), message.to_dict()) + metadata = self._message_metadata(message) + self._append_event_unlocked( + run_id, self._new_event(run_id, "message.created", metadata) + ) + self._append_event_unlocked( + run_id, self._new_event(run_id, "message.delivered", metadata) + ) + self.signal_bus.notify(run_id, recipient_id) + return message + + def broadcast( + self, + run_id: str, + sender_id: str, + payload: Any, + *, + policy: CommunicationPolicy, + summary: str | None = None, + idempotency_key: str | None = None, + ) -> PeerBroadcast: + encoded = self._validate_payload(payload) + if self.find_participant(run_id, sender_id) is None: + reason = f"sender is not a participant in this run: {sender_id}" + self.record_policy_rejection(run_id, sender_id, None, "broadcast", reason) + raise PolicyRejected(reason) + recipients = policy.broadcast_recipients(sender_id) + if not recipients: + reason = f"broadcast has no allowed recipients under {policy.condition} policy" + self.record_policy_rejection(run_id, sender_id, None, "broadcast", reason) + raise PolicyRejected(reason) + + created_messages: list[PeerMessage] = [] + with _locked_path(self._state_lock(run_id)): + if idempotency_key: + previous = self._find_broadcast_unlocked(run_id, sender_id, idempotency_key) + if previous is not None: + return previous + broadcast_id = uuid.uuid4().hex + for recipient_id in recipients: + now = utc_now() + message = PeerMessage( + message_id=uuid.uuid4().hex, + run_id=run_id, + sender_id=sender_id, + recipient_id=recipient_id, + payload=payload, + payload_size_bytes=len(encoded), + summary=summary, + broadcast_id=broadcast_id, + idempotency_key=idempotency_key, + created_at=now, + delivered_at=now, + ) + self._write_json_unlocked( + self._message_path(run_id, message.message_id), message.to_dict() + ) + metadata = self._message_metadata(message) + self._append_event_unlocked( + run_id, self._new_event(run_id, "message.created", metadata) + ) + self._append_event_unlocked( + run_id, self._new_event(run_id, "message.delivered", metadata) + ) + created_messages.append(message) + broadcast = PeerBroadcast( + broadcast_id=broadcast_id, + run_id=run_id, + sender_id=sender_id, + recipients=recipients, + message_ids=[message.message_id for message in created_messages], + payload_size_bytes=len(encoded), + idempotency_key=idempotency_key, + ) + self._write_json_unlocked( + self._broadcast_path(run_id, broadcast_id), broadcast.to_dict() + ) + self._append_event_unlocked( + run_id, + self._new_event( + run_id, + "broadcast.created", + { + "broadcast_id": broadcast_id, + "sender_id": sender_id, + "recipients": recipients, + "message_ids": broadcast.message_ids, + "payload_size_bytes": len(encoded), + }, + ), + ) + for recipient_id in recipients: + self.signal_bus.notify(run_id, recipient_id) + return broadcast + + def consume_messages(self, run_id: str, recipient_id: str) -> list[PeerMessage]: + consumed: list[PeerMessage] = [] + with _locked_path(self._state_lock(run_id)): + for message in self._list_messages_unlocked(run_id): + if message.recipient_id != recipient_id or message.status != "delivered": + continue + message.consume() + self._write_json_unlocked( + self._message_path(run_id, message.message_id), message.to_dict() + ) + self._append_event_unlocked( + run_id, + self._new_event( + run_id, + "message.consumed", + { + **self._message_metadata(message), + "consumed_at": message.consumed_at, + }, + ), + ) + consumed.append(message) + return consumed + + def list_messages( + self, + run_id: str, + *, + recipient_id: str | None = None, + status: str | None = None, + ) -> list[PeerMessage]: + messages = self._list_messages_unlocked(run_id) + if recipient_id is not None: + messages = [item for item in messages if item.recipient_id == recipient_id] + if status is not None: + messages = [item for item in messages if item.status == status] + return messages + + def has_unread(self, run_id: str, recipient_id: str) -> bool: + return bool(self.list_messages(run_id, recipient_id=recipient_id, status="delivered")) + + def wait_for_unread( + self, + run_id: str, + recipient_id: str, + timeout: float | None, + *, + stop_event: threading.Event | None = None, + ) -> bool: + generation = self.signal_bus.generation(run_id, recipient_id) + if self.has_unread(run_id, recipient_id): + return True + self.signal_bus.wait(run_id, recipient_id, generation, timeout, stop_event) + return self.has_unread(run_id, recipient_id) + + def attempt_submission( + self, + run_id: str, + peer_id: str, + revision: str, + summary: str, + validation: dict[str, Any], + ) -> tuple[PeerSubmission, dict[str, Any] | None]: + with _locked_path(self._state_lock(run_id)): + run = self._load_run_unlocked(run_id) + if not self._participant_path(run_id, peer_id).is_file(): + raise ValueError(f"submitting peer is not a participant in this run: {peer_id}") + attempt_id = uuid.uuid4().hex + already = run.accepted_submission + if already is not None: + submission = PeerSubmission( + attempt_id=attempt_id, + run_id=run_id, + peer_id=peer_id, + revision=revision, + summary=summary, + status="already_submitted", + validation=validation, + ) + accepted = dict(already) + elif not validation.get("valid"): + submission = PeerSubmission( + attempt_id=attempt_id, + run_id=run_id, + peer_id=peer_id, + revision=revision, + summary=summary, + status="rejected", + validation=validation, + ) + accepted = None + else: + submission = PeerSubmission( + attempt_id=attempt_id, + run_id=run_id, + peer_id=peer_id, + revision=str(validation.get("resolved_revision") or revision), + summary=summary, + status="accepted", + validation=validation, + ) + accepted = submission.to_dict() + run.accepted_submission = accepted + run.set_status("submitted", reason="accepted peer submission") + self._write_json_unlocked(self.run_dir(run_id) / "run.json", run.to_dict()) + self._write_json_unlocked( + self.run_dir(run_id) / "submissions" / f"{attempt_id}.json", + submission.to_dict(), + ) + event_type = { + "accepted": "submit.accepted", + "rejected": "submit.rejected", + "already_submitted": "submit.already_submitted", + }[submission.status] + self._append_event_unlocked( + run_id, self._new_event(run_id, "submit.attempted", submission.to_dict()) + ) + self._append_event_unlocked( + run_id, self._new_event(run_id, event_type, submission.to_dict()) + ) + return submission, accepted + + def list_submissions(self, run_id: str) -> list[PeerSubmission]: + directory = self.run_dir(run_id) / "submissions" + values = [ + PeerSubmission.from_dict(self._read_json(path)) + for path in sorted(directory.glob("*.json")) + ] if directory.is_dir() else [] + values.sort(key=lambda item: (item.created_at, item.attempt_id)) + return values + + def list_broadcasts(self, run_id: str) -> list[PeerBroadcast]: + directory = self.run_dir(run_id) / "broadcasts" + values = [ + PeerBroadcast.from_dict(self._read_json(path)) + for path in sorted(directory.glob("*.json")) + ] if directory.is_dir() else [] + values.sort(key=lambda item: (item.created_at, item.broadcast_id)) + return values + + def update_usage( + self, + run_id: str, + peer_id: str, + usage_delta: dict[str, int], + ) -> tuple[dict[str, int], dict[str, int]]: + keys = ( + "input_tokens", + "output_tokens", + "cache_creation_input_tokens", + "cache_read_input_tokens", + "total_tokens", + "turns", + "model_calls", + "tool_calls", + ) + with _locked_path(self._state_lock(run_id)): + run = self._load_run_unlocked(run_id) + participant_path = self._participant_path(run_id, peer_id) + participant = PeerParticipant.from_dict(self._read_json(participant_path)) + for key in keys: + value = int(usage_delta.get(key, 0) or 0) + run.usage[key] = int(run.usage.get(key, 0) or 0) + value + participant.usage[key] = int(participant.usage.get(key, 0) or 0) + value + run.usage["total_tokens"] = sum( + int(run.usage.get(key, 0) or 0) + for key in ( + "input_tokens", + "output_tokens", + "cache_creation_input_tokens", + "cache_read_input_tokens", + ) + ) + participant.usage["total_tokens"] = sum( + int(participant.usage.get(key, 0) or 0) + for key in ( + "input_tokens", + "output_tokens", + "cache_creation_input_tokens", + "cache_read_input_tokens", + ) + ) + run.updated_at = utc_now() + participant.updated_at = utc_now() + self._write_json_unlocked(self.run_dir(run_id) / "run.json", run.to_dict()) + self._write_json_unlocked(participant_path, participant.to_dict()) + return dict(run.usage), dict(participant.usage) + + def save_result(self, run_id: str, result: dict[str, Any]) -> Path: + path = self.run_dir(run_id) / "result.json" + self._write_json(path, result) + return path + + def load_manifest(self, run_id: str) -> dict[str, Any]: + return self._read_json(self.run_dir(run_id) / "manifest.json") + + def _load_run_unlocked(self, run_id: str) -> PeerRunRecord: + path = self.run_dir(run_id) / "run.json" + if not path.is_file(): + raise ValueError(f"unknown peer run: {run_id}") + return PeerRunRecord.from_dict(self._read_json(path)) + + def _participant_path(self, run_id: str, peer_id: str) -> Path: + return self.run_dir(run_id) / "participants" / f"{peer_id}.json" + + def _message_path(self, run_id: str, message_id: str) -> Path: + return self.run_dir(run_id) / "messages" / f"{message_id}.json" + + def _broadcast_path(self, run_id: str, broadcast_id: str) -> Path: + return self.run_dir(run_id) / "broadcasts" / f"{broadcast_id}.json" + + def _list_messages_unlocked(self, run_id: str) -> list[PeerMessage]: + directory = self.run_dir(run_id) / "messages" + values = [ + PeerMessage.from_dict(self._read_json(path)) + for path in directory.glob("*.json") + ] if directory.is_dir() else [] + values.sort(key=lambda message: (message.created_at, message.message_id)) + return values + + def _find_idempotent_message_unlocked( + self, + run_id: str, + sender_id: str, + recipient_id: str, + key: str, + broadcast_id: str | None, + ) -> PeerMessage | None: + for message in self._list_messages_unlocked(run_id): + if ( + message.sender_id == sender_id + and message.recipient_id == recipient_id + and message.idempotency_key == key + and message.broadcast_id == broadcast_id + ): + return message + return None + + def _find_broadcast_unlocked( + self, run_id: str, sender_id: str, key: str + ) -> PeerBroadcast | None: + directory = self.run_dir(run_id) / "broadcasts" + if not directory.is_dir(): + return None + for path in directory.glob("*.json"): + value = PeerBroadcast.from_dict(self._read_json(path)) + if value.sender_id == sender_id and value.idempotency_key == key: + return value + return None + + @staticmethod + def _validate_payload(payload: Any) -> bytes: + if payload is None: + raise ValueError("message payload cannot be null") + try: + encoded = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode( + "utf-8" + ) + except (TypeError, ValueError) as exc: + raise ValueError("message payload must be JSON serializable") from exc + if len(encoded) > PeerStore.MAX_PAYLOAD_BYTES: + raise ValueError( + f"message payload exceeds {PeerStore.MAX_PAYLOAD_BYTES} bytes" + ) + return encoded + + @staticmethod + def _message_metadata(message: PeerMessage) -> dict[str, Any]: + return { + "message_id": message.message_id, + "sender_id": message.sender_id, + "recipient_id": message.recipient_id, + "broadcast_id": message.broadcast_id, + "payload_size_bytes": message.payload_size_bytes, + "status": message.status, + "created_at": message.created_at, + "delivered_at": message.delivered_at, + } + + @staticmethod + def _new_event( + run_id: str, + event_type: str, + data: dict[str, Any] | None = None, + *, + created_at: str | None = None, + monotonic_ns: int | None = None, + ) -> dict[str, Any]: + return { + "event_id": uuid.uuid4().hex, + "run_id": run_id, + "type": event_type, + "created_at": created_at or utc_now(), + "monotonic_ns": monotonic_ns if monotonic_ns is not None else time.monotonic_ns(), + "data": data or {}, + } + + def _append_event_unlocked(self, run_id: str, event: dict[str, Any]) -> None: + path = self.run_dir(run_id) / "events.jsonl" + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(event, ensure_ascii=False, default=str) + "\n") + handle.flush() + os.fsync(handle.fileno()) + + @staticmethod + def _read_json(path: Path) -> dict[str, Any]: + with path.open(encoding="utf-8") as handle: + value = json.load(handle) + if not isinstance(value, dict): + raise ValueError(f"expected JSON object in {path}") + return value + + @staticmethod + def _write_json(path: Path, value: dict[str, Any]) -> None: + with _locked_path(path.with_name(f".{path.name}.lock")): + PeerStore._write_json_unlocked(path, value) + + @staticmethod + def _write_json_unlocked(path: Path, value: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") + try: + with temporary.open("w", encoding="utf-8") as handle: + json.dump(value, handle, ensure_ascii=False, indent=2, default=str) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) diff --git a/src/peer/tools.py b/src/peer/tools.py new file mode 100644 index 0000000..d36d14a --- /dev/null +++ b/src/peer/tools.py @@ -0,0 +1,254 @@ +from __future__ import annotations + +from typing import Any + +from ..tool_system.context import ToolContext +from ..tool_system.errors import ToolInputError +from ..tool_system.protocol import ToolResult +from ..tool_system.registry import ToolSpec +from .policy import PolicyRejected + + +def _peer_context(context: ToolContext) -> tuple[Any, str, str, Any]: + if ( + context.peer_store is None + or context.peer_run_id is None + or context.peer_id is None + or context.peer_control is None + ): + raise ToolInputError("peer tool requires an active peer run context") + return context.peer_store, context.peer_run_id, context.peer_id, context.peer_control + + +class PeerListTool: + def spec(self) -> ToolSpec: + return ToolSpec( + name="PeerList", + description="List the equal participants and coarse lifecycle status in this peer run.", + input_schema={"type": "object", "additionalProperties": False, "properties": {}}, + is_read_only=True, + strict=True, + max_result_size_chars=100_000, + ) + + def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: + store, run_id, peer_id, control = _peer_context(context) + peers = [ + { + "peer_id": peer.peer_id, + "name": peer.name, + "status": peer.status, + "session_id": peer.session_id, + } + for peer in store.list_participants(run_id) + ] + return ToolResult( + name="PeerList", + output={ + "run_id": run_id, + "self_peer_id": peer_id, + "communication": control.policy.condition, + "peers": peers, + }, + ) + + +class PeerSendMessageTool: + def spec(self) -> ToolSpec: + return ToolSpec( + name="SendMessage", + description=( + "Send a durable direct message to an allowed peer. The transport enforces " + "the run's communication graph; idempotency_key makes retries return the same delivery." + ), + input_schema={ + "type": "object", + "additionalProperties": False, + "properties": { + "to": {"type": "string"}, + "message": {}, + "summary": {"type": "string"}, + "idempotency_key": {"type": "string"}, + }, + "required": ["to", "message"], + }, + is_read_only=False, + strict=True, + max_result_size_chars=100_000, + ) + + def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: + store, run_id, peer_id, control = _peer_context(context) + recipient = tool_input.get("to") + if not isinstance(recipient, str) or not recipient.strip(): + raise ToolInputError("to must be a non-empty peer ID or name") + try: + message = store.send_message( + run_id, + peer_id, + recipient.strip(), + tool_input.get("message"), + policy=control.policy, + summary=tool_input.get("summary"), + idempotency_key=tool_input.get("idempotency_key"), + ) + except (ValueError, PolicyRejected) as exc: + raise ToolInputError(str(exc)) from exc + return ToolResult( + name="SendMessage", + output={ + "message_id": message.message_id, + "sender_id": message.sender_id, + "recipient_id": message.recipient_id, + "status": message.status, + "created_at": message.created_at, + "delivered_at": message.delivered_at, + }, + ) + + +class PeerReadMessagesTool: + def spec(self) -> ToolSpec: + return ToolSpec( + name="ReadMessages", + description=( + "Wait without busy polling, then atomically consume unread messages. " + "Each delivered message is returned for execution exactly once." + ), + input_schema={ + "type": "object", + "additionalProperties": False, + "properties": { + "wait_seconds": {"type": "number"}, + "include_consumed": {"type": "boolean"}, + }, + }, + is_read_only=False, + strict=True, + max_result_size_chars=200_000, + ) + + def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: + store, run_id, peer_id, control = _peer_context(context) + wait_seconds = tool_input.get("wait_seconds", 0) + if isinstance(wait_seconds, bool) or not isinstance(wait_seconds, (int, float)): + raise ToolInputError("wait_seconds must be numeric") + if wait_seconds < 0 or wait_seconds > 300: + raise ToolInputError("wait_seconds must be between 0 and 300") + if wait_seconds and not store.has_unread(run_id, peer_id): + store.wait_for_unread( + run_id, + peer_id, + float(wait_seconds), + stop_event=control.stop_event, + ) + messages = store.consume_messages(run_id, peer_id) + if tool_input.get("include_consumed"): + seen = {message.message_id for message in messages} + messages.extend( + message + for message in store.list_messages( + run_id, recipient_id=peer_id, status="consumed" + ) + if message.message_id not in seen + ) + names = { + peer.peer_id: peer.name for peer in store.list_participants(run_id) + } + return ToolResult( + name="ReadMessages", + output={ + "messages": [ + { + "message_id": message.message_id, + "sender_id": message.sender_id, + "from": names.get(message.sender_id, message.sender_id), + "summary": message.summary, + "message": message.payload, + "broadcast_id": message.broadcast_id, + "status": message.status, + "created_at": message.created_at, + "delivered_at": message.delivered_at, + "consumed_at": message.consumed_at, + } + for message in messages + ] + }, + ) + + +class PeerBroadcastTool: + def spec(self) -> ToolSpec: + return ToolSpec( + name="Broadcast", + description=( + "Send one durable message to every other peer allowed by the communication graph. " + "Supply idempotency_key when retrying a broadcast." + ), + input_schema={ + "type": "object", + "additionalProperties": False, + "properties": { + "message": {}, + "summary": {"type": "string"}, + "idempotency_key": {"type": "string"}, + }, + "required": ["message"], + }, + is_read_only=False, + strict=True, + max_result_size_chars=100_000, + ) + + def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: + store, run_id, peer_id, control = _peer_context(context) + try: + broadcast = store.broadcast( + run_id, + peer_id, + tool_input.get("message"), + policy=control.policy, + summary=tool_input.get("summary"), + idempotency_key=tool_input.get("idempotency_key"), + ) + except (ValueError, PolicyRejected) as exc: + raise ToolInputError(str(exc)) from exc + return ToolResult(name="Broadcast", output=broadcast.to_dict()) + + +class PeerSubmitTool: + def spec(self) -> ToolSpec: + return ToolSpec( + name="PeerSubmit", + description=( + "Atomically submit a final Git revision for the run. Any peer may submit; " + "the first valid revision wins and stops the run." + ), + input_schema={ + "type": "object", + "additionalProperties": False, + "properties": { + "revision": {"type": "string"}, + "summary": {"type": "string"}, + }, + "required": ["revision", "summary"], + }, + is_read_only=False, + strict=True, + max_result_size_chars=200_000, + ) + + def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: + _, _, peer_id, control = _peer_context(context) + revision = tool_input.get("revision") + summary = tool_input.get("summary") + if not isinstance(revision, str) or not revision.strip(): + raise ToolInputError("revision must be a non-empty Git revision") + if not isinstance(summary, str) or not summary.strip(): + raise ToolInputError("summary must be a non-empty string") + output = control.submit(peer_id, revision.strip(), summary.strip()) + return ToolResult( + name="PeerSubmit", + output=output, + is_error=output["status"] == "rejected", + ) diff --git a/src/peer/trace.py b/src/peer/trace.py new file mode 100644 index 0000000..a758731 --- /dev/null +++ b/src/peer/trace.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import json +import time +from typing import Any + +from ..teammate.trace import redact_trace_value + + +_EVENT_TYPES = { + "run_started": "agent_loop.started", + "run_completed": "agent_loop.completed", + "run_failed": "agent_loop.failed", + "run_cancelled": "agent_loop.cancelled", + "model_started": "model.started", + "model_response": "model.response", + "model_error": "model.failed", + "tool_use": "tool.started", + "tool_result": "tool.completed", + "tool_error": "tool.failed", +} + + +class PeerTraceRecorder: + def __init__(self, context: Any): + self.context = context + + def record(self, event: Any) -> None: + if self.context.peer_store is None or self.context.peer_run_id is None: + return + event_type = _EVENT_TYPES.get(event.kind, str(event.kind).replace("_", ".")) + if event.kind == "tool_result" and event.is_error: + event_type = "tool.failed" + data: dict[str, Any] = { + "peer_id": self.context.peer_id, + "actor_id": self.context.peer_id, + } + for source in ( + "turn", + "model", + "finish_reason", + "content", + "usage", + "tool_name", + "tool_input", + "tool_output", + "tool_use_id", + "duration_ms", + "error", + ): + value = getattr(event, source, None) + if value is not None: + data[source] = value + if getattr(event, "is_error", False): + data["is_error"] = True + safe = json.loads( + json.dumps(redact_trace_value(data), ensure_ascii=False, default=str) + ) + self.context.peer_store.append_event( + self.context.peer_run_id, + event_type, + safe, + created_at=event.created_at, + monotonic_ns=time.monotonic_ns(), + ) diff --git a/src/peer/workspace.py b/src/peer/workspace.py new file mode 100644 index 0000000..fbdeb97 --- /dev/null +++ b/src/peer/workspace.py @@ -0,0 +1,228 @@ +from __future__ import annotations + +import shutil +import subprocess +import tempfile +from pathlib import Path +from typing import Any + +from .models import PeerParticipant + + +class PeerWorkspaceManager: + """Prepare peer workspaces without performing hidden integration.""" + + def __init__( + self, + repo_path: str | Path, + run_id: str, + workspace_mode: str, + *, + cleanup_worktrees: bool = True, + ) -> None: + self.repo_path = Path(repo_path).expanduser().resolve() + self.run_id = run_id + self.workspace_mode = workspace_mode + self.cleanup_worktrees = cleanup_worktrees + self.repo_root = self._repo_root() + self.base_revision = self._git( + ["rev-parse", "HEAD"], cwd=self.repo_root + ).stdout.strip() + self.worktree_root = ( + self.repo_root.parent + / ".clawd-peer-worktrees" + / f"{self.repo_root.name}-{run_id}" + ) + self._workspaces: dict[str, Path] = {} + + def _repo_root(self) -> Path: + completed = self._git( + ["rev-parse", "--show-toplevel"], cwd=self.repo_path, check=False + ) + if completed.returncode != 0: + raise ValueError("peer collaboration requires a Git repository") + root = Path(completed.stdout.strip()).resolve() + if root != self.repo_path: + raise ValueError("repo_path must be the Git repository root") + return root + + def prepare(self, peer_id: str, name: str) -> Path: + if self.workspace_mode == "shared": + self._workspaces[peer_id] = self.repo_root + return self.repo_root + if self.workspace_mode != "worktree": + raise ValueError("workspace_mode must be shared or worktree") + path = self.worktree_root / f"{name}-{peer_id}" + path.parent.mkdir(parents=True, exist_ok=True) + completed = self._git( + ["worktree", "add", "--detach", str(path), self.base_revision], + cwd=self.repo_root, + check=False, + ) + if completed.returncode != 0: + raise RuntimeError(completed.stderr.strip() or "failed to create peer worktree") + self._workspaces[peer_id] = path.resolve() + return path.resolve() + + def workspace_for(self, peer_id: str) -> Path: + try: + return self._workspaces[peer_id] + except KeyError as exc: + raise ValueError(f"workspace has not been prepared for peer: {peer_id}") from exc + + def validate_revision(self, revision: str) -> dict[str, Any]: + if not isinstance(revision, str) or not revision.strip(): + return {"valid": False, "reason": "revision must be a non-empty string"} + requested = revision.strip() + resolved = self._git( + ["rev-parse", "--verify", "--end-of-options", f"{requested}^{{commit}}"], + cwd=self.repo_root, + check=False, + ) + if resolved.returncode != 0: + return { + "valid": False, + "reason": "revision is not a commit in the allowed repository", + "revision": requested, + } + commit = resolved.stdout.strip() + allowed_heads: dict[str, str] = {} + for peer_id, workspace in self._workspaces.items(): + head = self._git(["rev-parse", "HEAD"], cwd=workspace, check=False) + if head.returncode == 0: + allowed_heads[peer_id] = head.stdout.strip() + main_head = self._git(["rev-parse", "HEAD"], cwd=self.repo_root, check=False) + if main_head.returncode == 0: + allowed_heads["shared"] = main_head.stdout.strip() + reachable_from: list[str] = [] + for identity, head in allowed_heads.items(): + reachable = self._git( + ["merge-base", "--is-ancestor", commit, head], + cwd=self.repo_root, + check=False, + ) + if reachable.returncode == 0: + reachable_from.append(identity) + if not reachable_from: + return { + "valid": False, + "reason": "revision is not reachable from an allowed peer workspace", + "revision": requested, + "resolved_revision": commit, + "allowed_heads": allowed_heads, + } + return { + "valid": True, + "revision": requested, + "resolved_revision": commit, + "reachable_from": sorted(reachable_from), + "allowed_heads": allowed_heads, + } + + def attribution(self, participants: list[PeerParticipant]) -> list[dict[str, Any]]: + result: list[dict[str, Any]] = [] + for participant in participants: + workspace = Path(participant.workspace_path) + head_result = self._git(["rev-parse", "HEAD"], cwd=workspace, check=False) + head = head_result.stdout.strip() if head_result.returncode == 0 else None + commits: list[str] = [] + if head: + listed = self._git( + ["rev-list", "--reverse", f"{self.base_revision}..{head}"], + cwd=self.repo_root, + check=False, + ) + if listed.returncode == 0: + commits = [line for line in listed.stdout.splitlines() if line] + result.append( + { + "peer_id": participant.peer_id, + "workspace_path": participant.workspace_path, + "workspace_mode": participant.workspace_mode, + "base_revision": self.base_revision, + "head_revision": head, + "commits": commits, + } + ) + return result + + def run_acceptance( + self, + revision: str, + command: list[str], + *, + timeout_seconds: float = 300.0, + ) -> dict[str, Any]: + validation_root = Path( + tempfile.mkdtemp(prefix=f"clawd-peer-acceptance-{self.run_id}-") + ).resolve() + checkout = validation_root / "checkout" + added = self._git( + ["worktree", "add", "--detach", str(checkout), revision], + cwd=self.repo_root, + check=False, + ) + if added.returncode != 0: + shutil.rmtree(validation_root, ignore_errors=True) + return { + "command": command, + "exit_code": 125, + "stdout": "", + "stderr": added.stderr.strip() or "failed to prepare acceptance worktree", + } + try: + completed = subprocess.run( + command, + cwd=checkout, + capture_output=True, + text=True, + timeout=timeout_seconds, + ) + return { + "command": command, + "exit_code": completed.returncode, + "stdout": completed.stdout, + "stderr": completed.stderr, + } + except subprocess.TimeoutExpired as exc: + return { + "command": command, + "exit_code": 124, + "stdout": exc.stdout or "", + "stderr": exc.stderr or "acceptance command timed out", + } + finally: + self._git( + ["worktree", "remove", "--force", str(checkout)], + cwd=self.repo_root, + check=False, + ) + shutil.rmtree(validation_root, ignore_errors=True) + + def cleanup(self) -> list[str]: + retained: list[str] = [] + if self.workspace_mode != "worktree" or not self.cleanup_worktrees: + return retained + for path in self._workspaces.values(): + completed = self._git( + ["worktree", "remove", "--force", str(path)], + cwd=self.repo_root, + check=False, + ) + if completed.returncode != 0 and path.exists(): + retained.append(str(path)) + if self.worktree_root.is_dir() and not any(self.worktree_root.iterdir()): + self.worktree_root.rmdir() + self._git(["worktree", "prune"], cwd=self.repo_root, check=False) + return retained + + @staticmethod + def _git( + args: list[str], *, cwd: Path, check: bool = True + ) -> subprocess.CompletedProcess[str]: + completed = subprocess.run( + ["git", *args], cwd=cwd, capture_output=True, text=True + ) + if check and completed.returncode != 0: + raise RuntimeError(completed.stderr.strip() or f"git {' '.join(args)} failed") + return completed diff --git a/src/providers/__init__.py b/src/providers/__init__.py index cc4f81d..49374f9 100644 --- a/src/providers/__init__.py +++ b/src/providers/__init__.py @@ -7,6 +7,17 @@ from .base import BaseProvider, ChatMessage, ChatResponse +PROVIDER_ALIASES = { + "qwen3.5": "qwen", +} + + +def normalize_provider_name(provider_name: str) -> str: + """Return the canonical provider key for a provider/profile alias.""" + normalized = provider_name.strip().lower() + return PROVIDER_ALIASES.get(normalized, normalized) + + # Provider metadata for login/UI class ProviderInfo(TypedDict): label: str @@ -89,6 +100,15 @@ class ProviderInfo(TypedDict): "zai/glm-3-turbo", ], }, + "qwen": { + "label": "Qwen 3.5 (Tencent TI-ONE)", + "default_base_url": ( + "https://ms-mnhdj86z-100034032793-sw.gw.ap-zhongwei.ti.tencentcs.com/" + "ms-mnhdj86z/v1" + ), + "default_model": "ms-mnhdj86z", + "available_models": ["ms-mnhdj86z"], + }, "minimax": { "label": "Minimax AI", "default_base_url": "https://api.minimaxi.com/anthropic", @@ -111,6 +131,7 @@ class ProviderInfo(TypedDict): def get_provider_info(provider_name: str) -> ProviderInfo: """Get provider info by name.""" + provider_name = normalize_provider_name(provider_name) if provider_name not in PROVIDER_INFO: raise ValueError(f"Unknown provider: {provider_name}") return PROVIDER_INFO[provider_name] @@ -118,6 +139,7 @@ def get_provider_info(provider_name: str) -> ProviderInfo: def get_provider_class(provider_name: str): """Get provider class by name.""" + provider_name = normalize_provider_name(provider_name) if provider_name == "anthropic": from .anthropic_provider import AnthropicProvider @@ -130,6 +152,10 @@ def get_provider_class(provider_name: str): from .glm_provider import GLMProvider return GLMProvider + if provider_name == "qwen": + from .qwen_provider import QwenProvider + + return QwenProvider if provider_name == "minimax": from .minimax_provider import MinimaxProvider @@ -149,4 +175,6 @@ def get_provider_class(provider_name: str): "get_provider_info", "PROVIDER_INFO", "AVAILABLE_PROVIDERS", + "PROVIDER_ALIASES", + "normalize_provider_name", ] diff --git a/src/providers/anthropic_provider.py b/src/providers/anthropic_provider.py index 2ba7048..95e0250 100644 --- a/src/providers/anthropic_provider.py +++ b/src/providers/anthropic_provider.py @@ -64,12 +64,18 @@ def _build_chat_response(self, response: Any) -> ChatResponse: }) usage = getattr(response, "usage", None) + def usage_value(name: str) -> int: + value = getattr(usage, name, 0) + return int(value) if isinstance(value, (int, float)) else 0 + return ChatResponse( content=content_text, model=getattr(response, "model", self.model or ""), usage={ - "input_tokens": getattr(usage, "input_tokens", 0), - "output_tokens": getattr(usage, "output_tokens", 0), + "input_tokens": usage_value("input_tokens"), + "output_tokens": usage_value("output_tokens"), + "cache_creation_input_tokens": usage_value("cache_creation_input_tokens"), + "cache_read_input_tokens": usage_value("cache_read_input_tokens"), }, finish_reason=str(getattr(response, "stop_reason", "stop")), tool_uses=tool_uses if tool_uses else None, diff --git a/src/providers/openai_compatible.py b/src/providers/openai_compatible.py index e4dd2fd..a6956a6 100644 --- a/src/providers/openai_compatible.py +++ b/src/providers/openai_compatible.py @@ -48,6 +48,11 @@ class OpenAICompatibleProvider(BaseProvider): The client is created lazily on first use. """ + # OpenAI-compatible gateways do not uniformly support stream_options. + # Providers that do must opt in so non-streaming and legacy endpoints are + # not sent an unsupported parameter. + STREAM_INCLUDE_USAGE = False + def __init__( self, api_key: str, @@ -83,12 +88,29 @@ def client(self) -> Any: def _build_usage_dict(self, usage: Any) -> dict[str, Any]: if usage is None: return {} + prompt_details = getattr(usage, "prompt_tokens_details", None) + if isinstance(prompt_details, dict): + cached_tokens = prompt_details.get("cached_tokens", 0) + else: + cached_tokens = getattr(prompt_details, "cached_tokens", 0) return { "input_tokens": getattr(usage, "prompt_tokens", 0), "output_tokens": getattr(usage, "completion_tokens", 0), "total_tokens": getattr(usage, "total_tokens", 0), + "cache_read_input_tokens": cached_tokens or 0, } + def _build_request_kwargs(self, kwargs: dict[str, Any]) -> dict[str, Any]: + """Build provider-specific request options after common argument filtering.""" + return {k: v for k, v in kwargs.items() if k not in ["model", "tools"]} + + def _build_stream_request_kwargs(self, kwargs: dict[str, Any]) -> dict[str, Any]: + """Build streaming options, requesting the terminal usage chunk when supported.""" + options = self._build_request_kwargs(kwargs) + if self.STREAM_INCLUDE_USAGE: + options.setdefault("stream_options", {"include_usage": True}) + return options + def chat( self, messages: list[MessageInput], @@ -121,7 +143,7 @@ def chat( model=model, messages=provider_messages, **extra_kwargs, - **{k: v for k, v in kwargs.items() if k not in ["model", "tools"]}, + **self._build_request_kwargs(kwargs), ) # Extract content @@ -192,7 +214,7 @@ def chat_stream( messages=provider_messages, stream=True, **extra_kwargs, - **{k: v for k, v in kwargs.items() if k not in ["model", "tools"]}, + **self._build_stream_request_kwargs(kwargs), ) for chunk in stream: @@ -220,7 +242,7 @@ def chat_stream_response( messages=provider_messages, stream=True, **extra_kwargs, - **{k: v for k, v in kwargs.items() if k not in ["model", "tools"]}, + **self._build_stream_request_kwargs(kwargs), ) content_parts: list[str] = [] diff --git a/src/providers/qwen_provider.py b/src/providers/qwen_provider.py new file mode 100644 index 0000000..2e904bf --- /dev/null +++ b/src/providers/qwen_provider.py @@ -0,0 +1,88 @@ +"""Qwen 3.5 provider backed by a Tencent TI-ONE OpenAI-compatible service.""" + +from __future__ import annotations + +import os +import uuid +from typing import Any, Optional + +try: + from openai import OpenAI # type: ignore +except ModuleNotFoundError: # pragma: no cover + OpenAI = None + +from .openai_compatible import OpenAICompatibleProvider + + +class QwenProvider(OpenAICompatibleProvider): + """Qwen provider for the configured Tencent TI-ONE deployment.""" + + # SGLang/TI-ONE returns aggregate prompt/completion counts in a final + # choices=[] stream chunk only when include_usage is requested. + STREAM_INCLUDE_USAGE = True + + DEFAULT_BASE_URL = ( + "https://ms-mnhdj86z-100034032793-sw.gw.ap-zhongwei.ti.tencentcs.com/" + "ms-mnhdj86z/v1" + ) + DEFAULT_MODEL = "ms-mnhdj86z" + ROUTING_HEADER = "X-Clawd-Route-Key" + ROUTING_KEY_ENV = "QWEN_ROUTING_KEY" + + def __init__( + self, + api_key: str, + base_url: Optional[str] = None, + model: Optional[str] = None, + routing_key: Optional[str] = None, + enable_thinking: Optional[bool] = None, + ) -> None: + resolved_routing_key = routing_key or os.environ.get(self.ROUTING_KEY_ENV) + self.routing_key = resolved_routing_key or uuid.uuid4().hex + if not self.routing_key.strip() or any( + character in self.routing_key for character in "\r\n" + ): + raise ValueError("Qwen routing key must be a non-empty HTTP header value") + self.enable_thinking = enable_thinking + super().__init__( + api_key=api_key, + base_url=base_url or self.DEFAULT_BASE_URL, + model=model or self.DEFAULT_MODEL, + ) + + def _create_client(self) -> Any: + if OpenAI is None: # pragma: no cover + raise ModuleNotFoundError( + "openai package is not installed. Install project dependencies to use QwenProvider." + ) + # TI-ONE's public gateway expects the AuthToken as the complete + # Authorization header value. A value beginning with "Bearer " is also + # preserved, so either gateway authentication form can be configured. + return OpenAI( + api_key=self.api_key, + base_url=self.base_url, + default_headers={ + "Authorization": self.api_key, + self.ROUTING_HEADER: self.routing_key, + }, + ) + + def _build_request_kwargs(self, kwargs: dict[str, Any]) -> dict[str, Any]: + options = super()._build_request_kwargs(kwargs) + enable_thinking = self.enable_thinking + if enable_thinking is None: + thinking_value = os.environ.get("QWEN_ENABLE_THINKING", "0") + enable_thinking = thinking_value.strip().casefold() in { + "1", + "true", + "yes", + "on", + } + options.setdefault( + "extra_body", + {"chat_template_kwargs": {"enable_thinking": enable_thinking}}, + ) + return options + + def get_available_models(self) -> list[str]: + return [self.DEFAULT_MODEL] diff --git a/src/repl/core.py b/src/repl/core.py index 4018243..b56d33a 100644 --- a/src/repl/core.py +++ b/src/repl/core.py @@ -79,6 +79,7 @@ def __init__(self, text: str): from src.providers.anthropic_provider import AnthropicProvider from src.providers.base import ChatMessage from src.providers.minimax_provider import MinimaxProvider +from src.teammate.runtime import TeammateRuntime from src.tool_system.context import ToolContext from src.tool_system.defaults import build_default_registry from src.tool_system.protocol import ToolCall @@ -129,6 +130,7 @@ def __init__(self, provider_name: str = "glm", stream: bool = False): self.tool_registry = build_default_registry() self.tool_context = ToolContext(workspace_root=Path.cwd()) + self.tool_context.teammate_runtime = TeammateRuntime(self.provider, self.tool_registry) self.tool_context.ask_user = self._ask_user_questions # Permission handler with status control for proper input handling self._current_status = None @@ -1313,6 +1315,7 @@ def _handle_relogin(self): model=config.get("default_model") ) self.provider_name = provider + self.tool_context.teammate_runtime = TeammateRuntime(self.provider, self.tool_registry) self.console.print("[green]✓ Provider reinitialized. You can continue chatting![/green]\n") diff --git a/src/runner.py b/src/runner.py new file mode 100644 index 0000000..a258c9d --- /dev/null +++ b/src/runner.py @@ -0,0 +1,247 @@ +"""Non-interactive agent execution.""" + +from __future__ import annotations + +import os +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +from .agent.conversation import Conversation +from .config import get_default_provider, get_provider_config +from .providers import get_provider_class, normalize_provider_name +from .teammate.runtime import TeammateRuntime +from .tool_system.agent_loop import ( + AgentLoopResult, + TextChunkHandler, + ToolEventHandler, + run_agent_loop, +) +from .tool_system.context import ToolContext +from .tool_system.defaults import build_default_registry + + +_PROVIDER_ENV: dict[str, dict[str, tuple[str, ...]]] = { + "anthropic": { + "api_key": ("ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"), + "base_url": ("ANTHROPIC_BASE_URL",), + "model": ("ANTHROPIC_MODEL",), + }, + "openai": { + "api_key": ("OPENAI_API_KEY",), + "base_url": ("OPENAI_BASE_URL",), + "model": ("OPENAI_MODEL",), + }, + "glm": { + "api_key": ("ZAI_API_KEY", "ZHIPUAI_API_KEY"), + "base_url": ("ZAI_BASE_URL", "ZHIPUAI_BASE_URL"), + "model": ("ZAI_MODEL", "ZHIPUAI_MODEL"), + }, + "qwen": { + "api_key": ("QWEN_API_KEY", "TENCENT_TIONE_AUTH_TOKEN"), + "base_url": ("QWEN_BASE_URL",), + "model": ("QWEN_MODEL",), + }, + "minimax": { + "api_key": ("MINIMAX_API_KEY",), + "base_url": ("MINIMAX_BASE_URL",), + "model": ("MINIMAX_MODEL",), + }, +} + + +def _first_env( + names: tuple[str, ...], env_overrides: Mapping[str, str] | None = None +) -> str | None: + for name in names: + value = (env_overrides or {}).get(name) or os.environ.get(name) + if value: + return value + return None + + +def _provider_settings( + provider_name: str, + config: dict[str, Any], + model_override: str | None, + env_overrides: Mapping[str, str] | None = None, +) -> tuple[str | None, str | None, str | None]: + env = _PROVIDER_ENV.get(provider_name, {}) + api_key = _first_env(env.get("api_key", ()), env_overrides) or config.get( + "api_key" + ) + base_url = _first_env(env.get("base_url", ()), env_overrides) or config.get( + "base_url" + ) + selected_model = ( + model_override + or _first_env(env.get("model", ()), env_overrides) + or config.get("default_model") + ) + return api_key, base_url, selected_model + + +def _build_runtime_context( + workspace: str | Path, + provider_name: str | None, + model: str | None, + *, + teammate_max_turns: int = 30, + teammate_min_timeout_s: float | None = None, + max_output_tokens: int = 4096, + workspace_backend: Any | None = None, + provider_env: Mapping[str, str] | None = None, + include_team_tools: bool = True, +) -> tuple[Any, Any, ToolContext, TeammateRuntime]: + workspace_root = Path(workspace).expanduser().resolve() + if not workspace_root.is_dir(): + raise ValueError(f"workspace is not a directory: {workspace_root}") + + selected_provider = normalize_provider_name(provider_name or get_default_provider()) + config = get_provider_config(selected_provider) + api_key, base_url, selected_model = _provider_settings( + selected_provider, config, model, provider_env + ) + if not api_key: + raise ValueError( + f"API key is not configured for {selected_provider}; use its environment " + "variable or run `clawd login`" + ) + + provider_class = get_provider_class(selected_provider) + provider_kwargs: dict[str, Any] = { + "api_key": api_key, + "base_url": base_url, + "model": selected_model, + } + if selected_provider == "qwen" and provider_env is not None: + if routing_key := provider_env.get("QWEN_ROUTING_KEY"): + provider_kwargs["routing_key"] = routing_key + if "QWEN_ENABLE_THINKING" in provider_env: + provider_kwargs["enable_thinking"] = provider_env[ + "QWEN_ENABLE_THINKING" + ].strip().casefold() in {"1", "true", "yes", "on"} + provider: Any = provider_class( + **provider_kwargs, + ) + registry = build_default_registry( + include_user_tools=workspace_backend is None, + workspace_backend=workspace_backend, + include_team_tools=include_team_tools, + ) + context = ToolContext( + workspace_root=workspace_root, + workspace_backend=workspace_backend, + execution_workspace_root=( + str(getattr(workspace_backend, "workspace_root", "/workspace")) + if workspace_backend is not None + else None + ), + ) + runtime = TeammateRuntime( + provider, + registry, + max_turns=teammate_max_turns, + max_output_tokens=max_output_tokens, + allowed_models={selected_model} if selected_model else None, + minimum_timeout_s=teammate_min_timeout_s, + ) + context.teammate_runtime = runtime + if model: + context.model_override = model + return provider, registry, context, runtime + + +def run_prompt( + prompt: str, + *, + workspace: str | Path = ".", + provider_name: str | None = None, + model: str | None = None, + max_turns: int = 100, + teammate_max_turns: int = 30, + teammate_min_timeout_s: float | None = None, + max_output_tokens: int = 4096, + stream: bool = False, + on_event: ToolEventHandler | None = None, + on_text_chunk: TextChunkHandler | None = None, + workspace_backend: Any | None = None, + provider_env: Mapping[str, str] | None = None, + include_team_tools: bool = True, +) -> AgentLoopResult: + """Run one prompt to completion without starting the interactive REPL.""" + if not isinstance(prompt, str) or not prompt.strip(): + raise ValueError("prompt must be non-empty") + if max_turns < 1: + raise ValueError("max_turns must be at least 1") + if teammate_max_turns < 1: + raise ValueError("teammate_max_turns must be at least 1") + if teammate_min_timeout_s is not None and teammate_min_timeout_s < 1: + raise ValueError("teammate_min_timeout_s must be at least 1 when provided") + if max_output_tokens < 1: + raise ValueError("max_output_tokens must be at least 1") + + provider, registry, context, _ = _build_runtime_context( + workspace, + provider_name, + model, + teammate_max_turns=teammate_max_turns, + teammate_min_timeout_s=teammate_min_timeout_s, + max_output_tokens=max_output_tokens, + workspace_backend=workspace_backend, + provider_env=provider_env, + include_team_tools=include_team_tools, + ) + + conversation = Conversation() + conversation.add_user_message(prompt.strip()) + return run_agent_loop( + conversation=conversation, + provider=provider, + tool_registry=registry, + tool_context=context, + max_turns=max_turns, + max_output_tokens=max_output_tokens, + stream=stream, + verbose=False, + on_event=on_event, + on_text_chunk=on_text_chunk, + ) + + +def resume_team( + *, + workspace: str | Path = ".", + provider_name: str | None = None, + model: str | None = None, + max_turns: int = 30, + max_workers: int | None = None, + timeout_s: float | None = None, + token_budget: int | None = None, + turn_budget: int | None = None, + max_retries: int | None = None, + lease_timeout_s: int | None = None, + retry_failed: bool = True, + retry_cancelled: bool = True, +) -> dict[str, Any]: + """Resume the active persisted team without a lead model round trip.""" + _, _, context, runtime = _build_runtime_context( + workspace, + provider_name, + model, + teammate_max_turns=max_turns, + ) + if context.team is None: + raise ValueError("no active team") + return runtime.run_team( + context, + resume=True, + retry_failed=retry_failed, + retry_cancelled=retry_cancelled, + max_workers=max_workers, + timeout_s=timeout_s, + token_budget=token_budget, + turn_budget=turn_budget, + max_retries=max_retries, + lease_timeout_s=lease_timeout_s, + ) diff --git a/src/teammate/__init__.py b/src/teammate/__init__.py new file mode 100644 index 0000000..8a515cc --- /dev/null +++ b/src/teammate/__init__.py @@ -0,0 +1,6 @@ +"""Persistent domain models for teammate workflows.""" + +from .models import AgentRecord, Message, Team, TeamTask +from .store import TeamStore + +__all__ = ["AgentRecord", "Message", "Team", "TeamTask", "TeamStore"] diff --git a/src/teammate/control.py b/src/teammate/control.py new file mode 100644 index 0000000..615222b --- /dev/null +++ b/src/teammate/control.py @@ -0,0 +1,339 @@ +"""Human and lead control operations for persistent teammate teams.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from .models import AgentRecord, Team, TeamTask, utc_now +from .store import TeamStore + + +def _active_team(store: TeamStore) -> Team: + team = store.load_active_team() + if team is None: + raise ValueError("no active team") + return team + + +def _resolve_task(tasks: dict[str, TeamTask], identity: str) -> TeamTask: + if identity in tasks: + return tasks[identity] + normalized = identity.strip().lower() + matches = [task for task in tasks.values() if (task.key or "").lower() == normalized] + if len(matches) != 1: + raise ValueError(f"unknown or ambiguous task: {identity}") + return matches[0] + + +def mark_teammate_stopped( + store: TeamStore, + team_id: str, + agent_id: str, +) -> AgentRecord: + """Persist the terminal worker state and emit its event exactly once.""" + changed = False + + def mutate(agent: AgentRecord) -> None: + nonlocal changed + if agent.status != "cancelled": + agent.transition_to("cancelled") + if agent.stopped_at is None: + agent.stopped_at = utc_now() + changed = True + + agent = store.mutate_agent(team_id, agent_id, mutate) + if agent is None: + raise ValueError(f"unknown teammate: {agent_id}") + if changed: + store.append_event( + team_id, + "agent.stopped", + { + "agent_id": agent.agent_id, + "name": agent.name, + "reason": agent.stop_reason or "worker stopped by lead", + "task_policy": agent.stop_task_policy or "requeue", + }, + ) + return agent + + +def stop_teammate( + store: TeamStore, + identity: str, + *, + task_policy: str = "requeue", + reason: str | None = None, +) -> dict[str, Any]: + """Request one worker to stop without cancelling its team.""" + if task_policy not in {"requeue", "cancel"}: + raise ValueError("task_policy must be requeue or cancel") + team = _active_team(store) + agent = store.find_agent(team.team_id, identity) + if agent is None: + raise ValueError(f"unknown teammate: {identity}") + already_requested = False + + def request_stop(current: AgentRecord) -> None: + nonlocal already_requested + if current.status == "completed": + raise ValueError("completed teammates cannot be stopped") + if current.stop_requested_at: + already_requested = True + return + current.stop_requested_at = utc_now() + current.stop_reason = (reason or "stopped by lead").strip() + current.stop_task_policy = task_policy + if current.status not in {"stopping", "cancelled"}: + current.transition_to("stopping") + + updated = store.mutate_agent(team.team_id, agent.agent_id, request_stop) + if updated is None: + raise ValueError(f"unknown teammate: {identity}") + agent = updated + if already_requested: + return { + "team_id": team.team_id, + "agent_id": agent.agent_id, + "name": agent.name, + "status": agent.status, + "task_policy": agent.stop_task_policy, + "already_requested": True, + } + + requested_at = agent.stop_requested_at or utc_now() + stop_reason = agent.stop_reason or "stopped by lead" + store.append_event( + team.team_id, + "agent.stop_requested", + { + "agent_id": agent.agent_id, + "name": agent.name, + "reason": stop_reason, + "task_policy": task_policy, + }, + ) + + changes: dict[str, list[str]] = { + "active": [], + "requeued": [], + "cancelled": [], + } + + def mutate(tasks: dict[str, TeamTask]) -> None: + for task in tasks.values(): + if task.owner != agent.agent_id or task.status == "completed": + continue + if task.status == "in_progress": + changes["active"].append(task.id) + continue + if task_policy == "requeue": + if task.status in {"failed", "cancelled"}: + task.transition_to("pending") + task.owner = None + task.lease_id = None + task.lease_expires_at = None + task.completed_at = None + task.last_error = stop_reason + task.updated_at = requested_at + changes["requeued"].append(task.id) + else: + already_cancelled = ( + task.status == "cancelled" + and task.lease_id is None + and task.lease_expires_at is None + and task.last_error == stop_reason + ) + if already_cancelled: + continue + if task.status != "cancelled": + task.transition_to("cancelled") + task.lease_id = None + task.lease_expires_at = None + task.completed_at = requested_at + task.last_error = stop_reason + changes["cancelled"].append(task.id) + + store.mutate_tasks(team.team_id, mutate) + for task_id in changes["requeued"]: + store.append_event( + team.team_id, + "task.requeued", + {"task_id": task_id, "agent_id": agent.agent_id, "reason": stop_reason}, + ) + for task_id in changes["cancelled"]: + store.append_event( + team.team_id, + "task.cancelled", + {"task_id": task_id, "agent_id": agent.agent_id, "reason": stop_reason}, + ) + + if not changes["active"]: + agent = mark_teammate_stopped(store, team.team_id, agent.agent_id) + + return { + "team_id": team.team_id, + "agent_id": agent.agent_id, + "name": agent.name, + "status": agent.status, + "task_policy": task_policy, + "active_task_ids": changes["active"], + "requeued_task_ids": changes["requeued"], + "cancelled_task_ids": changes["cancelled"], + "already_requested": False, + } + + +def resume_teammate(store: TeamStore, identity: str) -> dict[str, Any]: + """Make a fully stopped worker available for newly assigned work.""" + team = _active_team(store) + if team.status == "completed": + raise ValueError("workers in a completed team cannot be resumed") + agent = store.find_agent(team.team_id, identity) + if agent is None: + raise ValueError(f"unknown teammate: {identity}") + + def resume(current: AgentRecord) -> None: + if current.status == "stopping": + raise ValueError("teammate is still stopping") + if current.status != "cancelled": + raise ValueError("only cancelled teammates can be resumed") + current.transition_to("running") + current.transition_to("idle") + current.stop_requested_at = None + current.stop_reason = None + current.stop_task_policy = None + current.stopped_at = None + + updated = store.mutate_agent(team.team_id, agent.agent_id, resume) + if updated is None: + raise ValueError(f"unknown teammate: {identity}") + agent = updated + store.append_event( + team.team_id, + "agent.resumed", + {"agent_id": agent.agent_id, "name": agent.name}, + ) + return { + "team_id": team.team_id, + "agent_id": agent.agent_id, + "name": agent.name, + "status": agent.status, + } + + +def reassign_task( + store: TeamStore, + task_identity: str, + teammate_identity: str, +) -> dict[str, Any]: + """Assign a non-running task to an available teammate.""" + team = _active_team(store) + if team.status == "completed": + raise ValueError("tasks in a completed team cannot be reassigned") + agent = store.find_agent(team.team_id, teammate_identity) + if agent is None: + raise ValueError(f"unknown teammate: {teammate_identity}") + if agent.status not in {"created", "running", "idle"}: + raise ValueError(f"teammate is not available: {agent.status}") + + result: dict[str, Any] = {} + + def mutate(tasks: dict[str, TeamTask]) -> None: + task = _resolve_task(tasks, task_identity) + if task.status == "completed": + raise ValueError("completed tasks cannot be reassigned") + if task.status == "in_progress": + raise ValueError("stop the current worker before reassigning an active task") + previous_owner = task.owner + previous_status = task.status + if task.status in {"failed", "cancelled"}: + task.transition_to("pending") + task.owner = agent.agent_id + task.lease_id = None + task.lease_expires_at = None + task.completed_at = None + task.last_error = None + task.updated_at = utc_now() + result.update( + { + "task_id": task.id, + "task_key": task.key, + "previous_owner": previous_owner, + "previous_status": previous_status, + } + ) + + store.mutate_tasks(team.team_id, mutate) + store.append_event( + team.team_id, + "task.reassigned", + { + **result, + "agent_id": agent.agent_id, + "agent_name": agent.name, + }, + ) + return { + "team_id": team.team_id, + **result, + "owner": agent.agent_id, + "owner_name": agent.name, + "status": "pending", + } + + +def cancel_team(store: TeamStore, reason: str | None = None) -> dict[str, Any]: + team = _active_team(store) + if team.status == "completed": + raise ValueError("completed teams cannot be cancelled") + team.cancel_requested_at = utc_now() + if team.status != "cancelled": + team.transition_to("cancelled") + store.save_team(team) + store.append_event( + team.team_id, + "team.cancel_requested", + {"reason": reason or "cancelled by user"}, + ) + return {"team_id": team.team_id, "status": team.status} + + +def list_teams(workspace_root: str | Path) -> list[dict[str, Any]]: + store = TeamStore(Path(workspace_root)) + if not store.teams_dir.exists(): + return [] + teams: list[dict[str, Any]] = [] + active = store.load_active_team() + active_id = active.team_id if active else None + for directory in sorted(store.teams_dir.iterdir()): + if not directory.is_dir(): + continue + team = store.load_team(directory.name) + if team is None: + continue + teams.append( + { + "team_id": team.team_id, + "team_name": team.team_name, + "status": team.status, + "active": team.team_id == active_id, + "updated_at": team.updated_at, + } + ) + return teams + + +def team_status(workspace_root: str | Path, team_id: str | None = None) -> dict[str, Any]: + store = TeamStore(Path(workspace_root)) + team = store.load_team(team_id) if team_id else store.load_active_team() + if team is None: + raise ValueError("team not found") + return { + "team": team.to_dict(), + "agents": [agent.to_dict() for agent in store.list_agents(team.team_id)], + "tasks": list(store.load_tasks(team.team_id).values()), + "message_count": len(store.list_messages(team.team_id)), + "event_count": len(store.list_events(team.team_id)), + } diff --git a/src/teammate/models.py b/src/teammate/models.py new file mode 100644 index 0000000..af1518f --- /dev/null +++ b/src/teammate/models.py @@ -0,0 +1,328 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from typing import Any, ClassVar + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _require_status(status: str, allowed: set[str], kind: str) -> None: + if status not in allowed: + choices = ", ".join(sorted(allowed)) + raise ValueError(f"invalid {kind} status {status!r}; expected one of: {choices}") + + +@dataclass +class Team: + STATUSES: ClassVar[set[str]] = {"created", "running", "completed", "failed", "cancelled"} + TRANSITIONS: ClassVar[dict[str, set[str]]] = { + "created": {"running", "cancelled"}, + "running": {"completed", "failed", "cancelled"}, + "failed": {"running", "cancelled"}, + # A persistent team may receive another task after a previously completed + # batch. Reopening keeps the same team identity, history, and usage. + "completed": {"running"}, + "cancelled": {"running"}, + } + + team_id: str + team_name: str + lead_agent_id: str + description: str | None = None + agent_type: str | None = None + status: str = "created" + protocol_version: int = 1 + # ``status`` is the original coarse-grained state and remains the compatibility + # surface for v1 callers. Protocol v2 persists its finer state machine here so + # a process restart cannot accidentally turn verification/repair into success. + lifecycle_state: str | None = None + settings: dict[str, Any] = field(default_factory=dict) + usage: dict[str, int] = field(default_factory=dict) + cancel_requested_at: str | None = None + started_at: str | None = None + completed_at: str | None = None + created_at: str = field(default_factory=utc_now) + updated_at: str = field(default_factory=utc_now) + schema_version: int = 3 + + LIFECYCLE_STATES: ClassVar[set[str]] = { + "draft", + "ready", + "running", + "awaiting_verification", + "verifying", + "repair_required", + "paused", + "completed", + "failed", + "cancelled", + "aborted", + "budget_exhausted", + } + STATUS_LIFECYCLE: ClassVar[dict[str, str]] = { + "created": "draft", + "running": "running", + "completed": "completed", + "failed": "failed", + "cancelled": "cancelled", + } + + def __post_init__(self) -> None: + _require_status(self.status, self.STATUSES, "team") + if self.protocol_version < 1: + raise ValueError("team protocol_version must be at least 1") + if self.lifecycle_state is None: + self.lifecycle_state = self.STATUS_LIFECYCLE[self.status] + elif self.lifecycle_state not in self.LIFECYCLE_STATES: + choices = ", ".join(sorted(self.LIFECYCLE_STATES)) + raise ValueError( + f"invalid team lifecycle state {self.lifecycle_state!r}; " + f"expected one of: {choices}" + ) + + def transition_to(self, status: str) -> None: + _require_status(status, self.STATUSES, "team") + if status != self.status and status not in self.TRANSITIONS[self.status]: + raise ValueError(f"cannot transition team from {self.status!r} to {status!r}") + changed = status != self.status + self.status = status + if changed: + self.lifecycle_state = self.STATUS_LIFECYCLE[status] + self.updated_at = utc_now() + + def set_lifecycle_state(self, state: str) -> None: + if state not in self.LIFECYCLE_STATES: + choices = ", ".join(sorted(self.LIFECYCLE_STATES)) + raise ValueError( + f"invalid team lifecycle state {state!r}; expected one of: {choices}" + ) + self.lifecycle_state = state + self.updated_at = utc_now() + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Team": + values = {key: data[key] for key in cls.__dataclass_fields__ if key in data} + if "lifecycle_state" not in values: + values["lifecycle_state"] = cls.STATUS_LIFECYCLE.get( + str(values.get("status") or "created"), "draft" + ) + values["schema_version"] = 3 + return cls(**values) + + +@dataclass +class AgentRecord: + STATUSES: ClassVar[set[str]] = { + "created", + "running", + "idle", + "stopping", + "completed", + "failed", + "cancelled", + } + TRANSITIONS: ClassVar[dict[str, set[str]]] = { + "created": {"running", "stopping", "cancelled"}, + "running": {"idle", "stopping", "completed", "failed", "cancelled"}, + "idle": {"running", "stopping", "completed", "cancelled"}, + "stopping": {"cancelled"}, + "failed": {"running", "stopping", "cancelled"}, + # Completed teammates are persistent and can be reused when their team is + # reopened for a later task. + "completed": {"running"}, + "cancelled": {"running"}, + } + + agent_id: str + team_id: str + name: str + role: str + session_id: str + model: str | None = None + instructions: str = "" + tools: list[str] = field(default_factory=list) + workspace_mode: str = "shared" + workspace_path: str | None = None + auto_integrate: bool = False + status: str = "created" + stop_requested_at: str | None = None + stop_reason: str | None = None + stop_task_policy: str | None = None + stopped_at: str | None = None + created_at: str = field(default_factory=utc_now) + updated_at: str = field(default_factory=utc_now) + schema_version: int = 3 + + def __post_init__(self) -> None: + _require_status(self.status, self.STATUSES, "agent") + if self.workspace_mode not in {"shared", "worktree"}: + raise ValueError("workspace_mode must be 'shared' or 'worktree'") + if self.stop_task_policy not in {None, "requeue", "cancel"}: + raise ValueError("stop_task_policy must be 'requeue' or 'cancel'") + + def transition_to(self, status: str) -> None: + _require_status(status, self.STATUSES, "agent") + if status != self.status and status not in self.TRANSITIONS[self.status]: + raise ValueError(f"cannot transition agent from {self.status!r} to {status!r}") + self.status = status + self.updated_at = utc_now() + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "AgentRecord": + values = {key: data[key] for key in cls.__dataclass_fields__ if key in data} + values["schema_version"] = 3 + return cls(**values) + + +@dataclass +class TeamTask: + STATUSES: ClassVar[set[str]] = {"pending", "in_progress", "completed", "failed", "cancelled"} + TRANSITIONS: ClassVar[dict[str, set[str]]] = { + "pending": {"in_progress", "completed", "cancelled"}, + "in_progress": {"pending", "completed", "failed", "cancelled"}, + "failed": {"pending", "in_progress", "cancelled"}, + "completed": set(), + "cancelled": {"pending"}, + } + + id: str + subject: str + description: str + key: str | None = None + activeForm: str = "" + status: str = "pending" + # v2 distinguishes a worker's delivery (produced) from harness acceptance. + # The legacy status remains ``completed`` for both states so v1 tools and task + # dependency stores continue to round-trip unchanged. + lifecycle_state: str | None = None + owner: str | None = None + blocks: list[str] = field(default_factory=list) + blockedBy: list[str] = field(default_factory=list) + metadata: dict[str, Any] = field(default_factory=dict) + owned_files: list[str] = field(default_factory=list) + provides_interfaces: list[str] = field(default_factory=list) + depends_on_interfaces: list[str] = field(default_factory=list) + acceptance_checks: list[str] = field(default_factory=list) + output: str = "" + attempt: int = 0 + max_retries: int = 0 + lease_id: str | None = None + lease_expires_at: str | None = None + started_at: str | None = None + completed_at: str | None = None + last_error: str | None = None + created_at: str = field(default_factory=utc_now) + updated_at: str = field(default_factory=utc_now) + schema_version: int = 4 + + LIFECYCLE_STATES: ClassVar[set[str]] = { + "pending", + "in_progress", + "produced", + "accepted", + "failed", + "cancelled", + } + STATUS_LIFECYCLE: ClassVar[dict[str, str]] = { + "pending": "pending", + "in_progress": "in_progress", + "completed": "produced", + "failed": "failed", + "cancelled": "cancelled", + } + + def __post_init__(self) -> None: + _require_status(self.status, self.STATUSES, "task") + if self.lifecycle_state is None: + self.lifecycle_state = self.STATUS_LIFECYCLE[self.status] + elif self.lifecycle_state not in self.LIFECYCLE_STATES: + choices = ", ".join(sorted(self.LIFECYCLE_STATES)) + raise ValueError( + f"invalid task lifecycle state {self.lifecycle_state!r}; " + f"expected one of: {choices}" + ) + + def transition_to(self, status: str) -> None: + _require_status(status, self.STATUSES, "task") + if status != self.status and status not in self.TRANSITIONS[self.status]: + raise ValueError(f"cannot transition task from {self.status!r} to {status!r}") + changed = status != self.status + self.status = status + if changed: + self.lifecycle_state = self.STATUS_LIFECYCLE[status] + self.updated_at = utc_now() + + def set_lifecycle_state(self, state: str) -> None: + if state not in self.LIFECYCLE_STATES: + choices = ", ".join(sorted(self.LIFECYCLE_STATES)) + raise ValueError( + f"invalid task lifecycle state {state!r}; expected one of: {choices}" + ) + self.lifecycle_state = state + self.updated_at = utc_now() + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "TeamTask": + values = {key: data[key] for key in cls.__dataclass_fields__ if key in data} + if "lifecycle_state" not in values: + values["lifecycle_state"] = cls.STATUS_LIFECYCLE.get( + str(values.get("status") or "pending"), "pending" + ) + values["schema_version"] = 4 + return cls(**values) + + +@dataclass +class Message: + STATUSES: ClassVar[set[str]] = {"queued", "delivered", "consumed", "failed"} + TRANSITIONS: ClassVar[dict[str, set[str]]] = { + "queued": {"delivered", "failed"}, + "delivered": {"consumed", "failed"}, + "consumed": set(), + "failed": set(), + } + + message_id: str + team_id: str + sender_id: str + recipient_id: str + content: Any + summary: str | None = None + status: str = "queued" + created_at: str = field(default_factory=utc_now) + delivered_at: str | None = None + consumed_at: str | None = None + schema_version: int = 1 + + def __post_init__(self) -> None: + _require_status(self.status, self.STATUSES, "message") + + def transition_to(self, status: str) -> None: + _require_status(status, self.STATUSES, "message") + if status != self.status and status not in self.TRANSITIONS[self.status]: + raise ValueError(f"cannot transition message from {self.status!r} to {status!r}") + now = utc_now() + self.status = status + if status == "delivered": + self.delivered_at = now + elif status == "consumed": + self.consumed_at = now + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Message": + return cls(**{key: data[key] for key in cls.__dataclass_fields__ if key in data}) diff --git a/src/teammate/runtime.py b/src/teammate/runtime.py new file mode 100644 index 0000000..a2972c2 --- /dev/null +++ b/src/teammate/runtime.py @@ -0,0 +1,2685 @@ +from __future__ import annotations + +import os +import shlex +import shutil +import subprocess +import sys +import tempfile +import time +import uuid +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass, replace +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +from ..agent.conversation import Conversation +from ..tool_system.agent_loop import ToolEvent, run_agent_loop +from ..tool_system.context import ToolContext +from ..tool_system.ownership import task_test_scratch_prefix_for_id +from ..tool_system.permissions import ToolPermissionContext +from ..tool_system.registry import ToolRegistry +from .control import mark_teammate_stopped +from .models import AgentRecord, Message, Team, TeamTask, utc_now +from .store import TeamStore, execution_budget_manifest_errors +from .worktree import TeammateWorktreeManager + + +_MANDATORY_TEAMMATE_TOOLS = ( + "SendMessage", + "ReadMessages", + "TaskGet", + "TaskList", + "TaskUpdate", + "StructuredOutput", +) +_FORBIDDEN_TEAMMATE_TOOLS = { + "Agent", + "TeamCreate", + "TeamConfigure", + "TeamPlan", + "TeammateCreate", + "TeamRun", + "TeamVerify", + "TeamResume", + "TeamCancel", + "TeamAbort", + "TeamReplan", + "TeamIntegrate", + "TeamDelete", + "TeammateStop", + "TeammateResume", + "TaskRetry", +} +_FROZEN_RUN_OPTION_KEYS = ( + "max_workers", + "timeout_s", + "token_budget", + "turn_budget", + "max_retries", + "lease_timeout_s", +) + + +@dataclass(frozen=True) +class TeamRunOptions: + max_workers: int = 1 + max_batches: int | None = None + timeout_s: float | None = None + token_budget: int | None = None + turn_budget: int | None = None + max_retries: int = 0 + lease_timeout_s: int = 900 + + @classmethod + def build(cls, persisted: dict[str, Any], overrides: dict[str, Any]) -> "TeamRunOptions": + source = persisted + manifest = persisted.get("execution_manifest") + if isinstance(manifest, dict) and isinstance(manifest.get("execution"), dict): + source = manifest["execution"] + values: dict[str, Any] = {} + for name in cls.__dataclass_fields__: + if overrides.get(name) is not None: + values[name] = overrides[name] + elif name != "max_batches" and source.get(name) is not None: + values[name] = source[name] + return cls(**values) + + def to_dict(self) -> dict[str, Any]: + return { + "max_workers": self.max_workers, + "max_batches": self.max_batches, + "timeout_s": self.timeout_s, + "token_budget": self.token_budget, + "turn_budget": self.turn_budget, + "max_retries": self.max_retries, + "lease_timeout_s": self.lease_timeout_s, + } + + +@dataclass(frozen=True) +class TaskOutcome: + status: str + task_id: str + input_tokens: int = 0 + output_tokens: int = 0 + turns: int = 0 + error: str | None = None + repair_required: bool = False + infrastructure: bool = False + + +class TeammateRuntime: + """Persistent teammate scheduler with recovery, budgets, and optional parallelism.""" + + def __init__( + self, + provider: Any, + registry: ToolRegistry, + *, + max_turns: int = 30, + max_output_tokens: int = 4096, + allowed_models: set[str] | None = None, + minimum_timeout_s: float | None = None, + ): + self.provider = provider + self.registry = registry + self.max_turns = max_turns + self.max_output_tokens = max_output_tokens + self.allowed_models = { + model.strip() for model in (allowed_models or set()) if model.strip() + } + self.minimum_timeout_s = minimum_timeout_s + + def validate_model(self, model: str | None) -> str | None: + """Reject teammate model overrides unsupported by this endpoint.""" + if model is None: + return None + normalized = model.strip() + if self.allowed_models and normalized not in self.allowed_models: + allowed = ", ".join(sorted(self.allowed_models)) + raise ValueError( + f"unsupported teammate model {normalized!r}; omit model to inherit the " + f"lead model or choose one of: {allowed}" + ) + return normalized + + def validate_tools(self, names: list[str]) -> list[str]: + canonical: list[str] = [] + seen: set[str] = set() + for name in names: + tool = self.registry.get(name) + if tool is None: + raise ValueError(f"unknown tool in teammate allowlist: {name}") + spec_name = tool.spec().name + if spec_name in _FORBIDDEN_TEAMMATE_TOOLS: + raise ValueError(f"teammates cannot use team-management tool: {spec_name}") + key = spec_name.lower() + if key not in seen: + canonical.append(spec_name) + seen.add(key) + return canonical + + def run_team( + self, + lead_context: ToolContext, + *, + resume: bool = False, + retry_failed: bool = False, + retry_cancelled: bool = False, + max_workers: int | None = None, + max_batches: int | None = None, + timeout_s: float | None = None, + token_budget: int | None = None, + turn_budget: int | None = None, + max_retries: int | None = None, + lease_timeout_s: int | None = None, + ) -> dict[str, Any]: + team = lead_context.team_store.load_active_team() + if team is None: + return {"status": "failed", "error": "no active team"} + if self._is_v2(team) and team.lifecycle_state == "aborted": + return { + "status": "aborted", + "team_id": team.team_id, + "lifecycle_state": "aborted", + "error": ( + "protocol v2 abort is terminal; create a new top-level rollout " + "instead of resuming or replacing this team" + ), + "executed_task_ids": [], + "usage": team.usage, + } + if self._is_v2(team) and team.lifecycle_state == "budget_exhausted": + return self._budget_exhausted_result( + lead_context, + team, + "the frozen rollout execution budget was already exhausted", + [], + ) + if self._is_v2(team): + plan = team.settings.get("team_plan") + plan = plan if isinstance(plan, dict) else {} + manifest = team.settings.get("execution_manifest") + manifest = manifest if isinstance(manifest, dict) else {} + if int(plan.get("revision") or 0) > 0: + budget_manifest_errors = execution_budget_manifest_errors( + plan, manifest, usage=team.usage + ) + if budget_manifest_errors: + lead_context.team_store.append_event( + team.team_id, + "team.execution_budget_manifest_invalid", + { + "plan_hash": self._active_plan_hash(team), + "errors": budget_manifest_errors, + }, + ) + blocked = self._blocked_result( + lead_context, + team, + "execution budget manifest failed runtime integrity checks: " + + "; ".join(budget_manifest_errors), + [], + ) + blocked.update( + { + "failure_domain": "harness", + "budget_manifest_errors": budget_manifest_errors, + "workspace_preserved": True, + "next_required_action": ( + "Do not execute this plan or edit the manifest in place; " + "report the corrupted harness state and preserve the workspace." + ), + } + ) + return blocked + if team.status == "completed": + lead_context.reload_team_state() + all_tasks_completed = all( + task.get("status") == "completed" + for task in lead_context.tasks.values() + ) + all_v2_tasks_accepted = bool(lead_context.tasks) and all( + task.get("status") == "completed" + and task.get("lifecycle_state") == "accepted" + for task in lead_context.tasks.values() + ) + validation_passed = ( + (self._quality_policy(team).get("validation") or {}).get("status") + == "passed" + ) + if ( + all_tasks_completed + and (not self._is_strict(team) or validation_passed) + and (not self._is_v2(team) or all_v2_tasks_accepted) + ): + return self._result(lead_context, team, []) + if self._is_v2(team): + return self._blocked_result( + lead_context, + team, + "protocol v2 completed teams cannot be reopened implicitly; " + "preserve this terminal team for scoring and start a new top-level " + "rollout instead of submitting another TeamPlan", + [], + ) + team.transition_to("running") + team.completed_at = None + lead_context.team_store.save_team(team) + lead_context.team_store.append_event( + team.team_id, + "team.reopened", + { + "reason": ( + "strict validation is pending" + if all_tasks_completed + else "unfinished tasks were added after completion" + ), + "unfinished_task_ids": [ + task_id + for task_id, task in lead_context.tasks.items() + if task.get("status") != "completed" + ], + }, + ) + lead_context.reload_team_state() + if team.status == "cancelled" and not resume: + return {"status": "cancelled", "error": "team is cancelled", "team_id": team.team_id} + + lead_context.reload_team_state() + requested_execution = { + "max_workers": max_workers, + "timeout_s": timeout_s, + "token_budget": token_budget, + "turn_budget": turn_budget, + "max_retries": max_retries, + "lease_timeout_s": lease_timeout_s, + } + manifest_mismatches = self._execution_override_mismatches( + team, requested_execution + ) + if manifest_mismatches: + lead_context.team_store.append_event( + team.team_id, + "team.execution_manifest_mismatch", + { + "plan_hash": self._active_plan_hash(team), + "source": "TeamResume" if resume else "TeamRun", + "mismatches": manifest_mismatches, + }, + ) + rendered = "; ".join( + f"{item['field']}: planned {item['planned']!r}, " + f"requested {item['requested']!r}" + for item in manifest_mismatches + ) + blocked = self._blocked_result( + lead_context, + team, + "frozen TeamPlan execution manifest mismatch: " + rendered, + [], + ) + blocked["execution_manifest_mismatches"] = manifest_mismatches + blocked["next_required_action"] = ( + "Call TeamReplan first to checkpoint the workspace, then submit one " + "complete replacement TeamPlan with the new execution settings. Do not " + "override the frozen manifest in TeamRun or TeamResume." + ) + return blocked + strict_errors = self._strict_plan_errors( + lead_context, + team, + require_parallel_start=not self._quality_policy(team).get("plan_accepted", False), + ) + if strict_errors: + if self._is_v2(team) and team.lifecycle_state != "repair_required": + team = self._set_lifecycle( + lead_context, team, "draft", event=False + ) + lead_context.team_store.append_event( + team.team_id, + "team.plan_rejected", + {"errors": strict_errors}, + ) + return self._blocked_result( + lead_context, + team, + "strict team plan rejected: " + "; ".join(strict_errors), + [], + ) + if self._is_strict(team): + team = lead_context.team_store.load_team(team.team_id) or team + quality = self._quality_policy(team) + if not quality.get("plan_accepted"): + quality["plan_accepted"] = True + quality["plan_accepted_at"] = utc_now() + team.settings["quality_gates"] = quality + manifest = team.settings.get("execution_manifest") + if self._is_v2(team) and isinstance(manifest, dict): + manifest = dict(manifest) + manifest["status"] = "accepted" + manifest["accepted_at"] = utc_now() + team.settings["execution_manifest"] = manifest + lead_context.team_store.save_team(team) + lead_context.team_store.append_event( + team.team_id, + "team.plan_accepted", + {"task_count": len(lead_context.tasks)}, + ) + if self._is_v2(team): + team = self._set_lifecycle(lead_context, team, "ready") + if any( + task.get("status") != "completed" + for task in lead_context.tasks.values() + ): + validation = dict(quality.get("validation") or {}) + if validation.get("status") == "passed": + quality["validation"] = { + "status": "pending", + "reason": "team tasks changed after validation", + } + team.settings["quality_gates"] = quality + lead_context.team_store.save_team(team) + + option_source = ( + self._frozen_execution(team) if self._is_v2(team) else team.settings + ) + options = TeamRunOptions.build( + option_source, + { + "max_workers": max_workers, + "max_batches": max_batches, + "timeout_s": timeout_s, + "token_budget": token_budget, + "turn_budget": turn_budget, + "max_retries": max_retries, + "lease_timeout_s": lease_timeout_s, + }, + ) + requested_timeout_s = options.timeout_s + if self.minimum_timeout_s is not None and ( + options.timeout_s is None or options.timeout_s < self.minimum_timeout_s + ): + options = replace(options, timeout_s=self.minimum_timeout_s) + lead_context.team_store.append_event( + team.team_id, + "team.options_adjusted", + { + "timeout_s": { + "requested": requested_timeout_s, + "effective": self.minimum_timeout_s, + "reason": "runtime minimum", + } + }, + ) + persisted_options = options.to_dict() + persisted_options.pop("max_batches", None) + reconciliation_reasons = self._execution_setting_mismatches( + team, persisted_options + ) + if self._is_v2(team): + for key in ( + *_FROZEN_RUN_OPTION_KEYS, + "max_batches", + "verify_timeout_s", + "auto_verify", + ): + team.settings.pop(key, None) + manifest = team.settings.get("execution_manifest") + if isinstance(manifest, dict): + manifest = dict(manifest) + effective_execution = self._frozen_execution(team) + effective_execution.update(persisted_options) + manifest["effective_execution"] = effective_execution + if requested_timeout_s != options.timeout_s: + adjustments = dict(manifest.get("runtime_adjustments") or {}) + adjustments["timeout_s"] = { + "requested": requested_timeout_s, + "effective": options.timeout_s, + "reason": "runtime minimum", + } + manifest["runtime_adjustments"] = adjustments + team.settings["execution_manifest"] = manifest + else: + team.settings.update(persisted_options) + team.usage = self._normalized_usage(team.usage) + team.started_at = team.started_at or utc_now() + if resume: + team.cancel_requested_at = None + if reconciliation_reasons: + lead_context.team_store.append_event( + team.team_id, + "team.execution_manifest_reconciled", + { + "plan_hash": self._active_plan_hash(team), + "mismatches": reconciliation_reasons, + "effective_execution": persisted_options, + }, + ) + + try: + if team.status in {"created", "failed", "cancelled"}: + team.transition_to("running") + if self._is_v2(team): + team.set_lifecycle_state("running") + lead_context.team_store.save_team(team) + lead_context.team_store.append_event( + team.team_id, + "team.resumed" if resume else "team.running", + {"settings": options.to_dict()}, + ) + else: + if self._is_v2(team) and team.lifecycle_state != "running": + team.set_lifecycle_state("running") + lead_context.team_store.save_team(team) + + self._recover_tasks( + lead_context, + team, + options, + retry_failed=retry_failed, + retry_cancelled=retry_cancelled, + ) + executed: list[str] = [] + completed_batches = 0 + run_started = time.monotonic() + + while True: + current = lead_context.team_store.load_team(team.team_id) or team + if current.status == "cancelled" or current.cancel_requested_at: + lead_context.reload_team_state() + return self._cancelled_result(lead_context, current, executed) + + lead_context.reload_team_state() + tasks = lead_context.tasks + if not tasks: + return self._fail_team(lead_context, current, "team has no tasks", executed) + if all(task.get("status") == "completed" for task in tasks.values()): + budget_error = self._budget_error(current, options, run_started) + if ( + self._is_v2(current) + and budget_error + and "budget exhausted" in budget_error + ): + return self._budget_exhausted_result( + lead_context, current, budget_error, executed + ) + if self._is_strict(current): + if self._is_v2(current): + acceptance_failure = self._accept_produced_tasks( + lead_context, + current, + timeout_s=self._validation_timeout(current), + executed=executed, + ) + if acceptance_failure is not None: + return acceptance_failure + current = self._set_lifecycle( + lead_context, current, "awaiting_verification" + ) + coordination_errors = self._coordination_errors( + lead_context, current + ) + if coordination_errors: + lead_context.team_store.append_event( + current.team_id, + "team.coordination_rejected", + {"errors": coordination_errors}, + ) + return self._blocked_result( + lead_context, + current, + "strict coordination gate failed: " + + "; ".join(coordination_errors), + executed, + ) + quality = self._quality_policy(current) + if (quality.get("validation") or {}).get("status") != "passed": + if self._is_v2(current): + verified = self.verify_team( + lead_context, + timeout_s=self._validation_timeout(current), + ) + verified["executed_task_ids"] = executed + return verified + return self._verification_required( + lead_context, current, executed + ) + completed = self._complete_team(lead_context, current) + result = self._result(lead_context, completed, executed) + if budget_error: + lead_context.team_store.append_event( + current.team_id, + "team.budget_exceeded_after_completion", + {"warning": budget_error, "usage": completed.usage}, + ) + result["budget_warning"] = budget_error + return result + + budget_error = self._budget_error(current, options, run_started) + if budget_error: + if self._is_v2(current) and "budget exhausted" in budget_error: + return self._budget_exhausted_result( + lead_context, current, budget_error, executed + ) + return self._fail_team( + lead_context, + current, + budget_error, + executed, + status="paused" if self._is_v2(current) else "failed", + lifecycle_state="paused" if self._is_v2(current) else None, + ) + + failed = [task for task in tasks.values() if task.get("status") == "failed"] + if failed: + names = ", ".join(str(task.get("key") or task.get("id")) for task in failed) + return self._fail_team(lead_context, current, f"failed tasks: {names}", executed) + + agents = { + agent.agent_id: agent + for agent in lead_context.team_store.list_agents(team.team_id) + } + ready = [ + task + for task in tasks.values() + if task.get("status") == "pending" + and self._dependencies_completed(task, tasks) + and task.get("owner") in agents + and agents[str(task.get("owner"))].status + not in {"stopping", "cancelled"} + ] + if not ready: + active = [ + task for task in tasks.values() if task.get("status") == "in_progress" + ] + pending = [ + str(task.get("key") or task.get("id")) + for task in tasks.values() + if task.get("status") in {"pending", "in_progress"} + ] + reason = "no runnable tasks; active leases or dependencies remain" + if pending: + reason += f" ({', '.join(pending)})" + if active: + return self._blocked_result(lead_context, current, reason, executed) + return self._blocked_result(lead_context, current, reason, executed) + + ready.sort( + key=lambda task: ( + str(task.get("created_at") or ""), + str(task.get("id") or ""), + ) + ) + batch, turn_limits = self._build_batch(ready, current, options) + if not batch: + if self._is_v2(current): + return self._budget_exhausted_result( + lead_context, + current, + "team plan-revision turn budget exhausted", + executed, + ) + return self._fail_team( + lead_context, current, "turn budget exhausted", executed + ) + outcomes = self._run_batch( + lead_context, current, batch, options, turn_limits + ) + self._record_usage(lead_context.team_store, current.team_id, outcomes) + + terminal_failures: list[TaskOutcome] = [] + infrastructure_failures: list[TaskOutcome] = [] + for outcome in outcomes: + executed.append(outcome.task_id) + if outcome.infrastructure or outcome.status == "infrastructure": + infrastructure_failures.append(outcome) + elif outcome.status == "failed": + task_data = lead_context.team_store.load_tasks(current.team_id).get( + outcome.task_id + ) + if task_data and not outcome.repair_required and self._schedule_retry( + lead_context.team_store, + current, + TeamTask.from_dict(task_data), + options, + ): + continue + terminal_failures.append(outcome) + elif outcome.status == "cancelled": + latest = lead_context.team_store.load_team(current.team_id) or current + return self._cancelled_result(lead_context, latest, executed) + elif outcome.status == "stopped": + continue + + if infrastructure_failures: + first = infrastructure_failures[0] + paused = self._fail_team( + lead_context, + current, + first.error or "teammate infrastructure failure", + executed, + status="paused", + lifecycle_state="paused", + ) + paused.update( + { + "failure_domain": "infrastructure", + "retryable": True, + "infrastructure_task_ids": [ + outcome.task_id for outcome in infrastructure_failures + ], + } + ) + return paused + + if terminal_failures: + first = terminal_failures[0] + needs_repair = self._is_v2(current) and first.repair_required + return self._fail_team( + lead_context, + current, + first.error or f"task failed: {first.task_id}", + executed, + status="repair_required" if needs_repair else "failed", + lifecycle_state="repair_required" if needs_repair else None, + ) + + completed_batches += 1 + if ( + options.max_batches is not None + and completed_batches >= options.max_batches + ): + latest = lead_context.team_store.load_team(current.team_id) or current + lead_context.reload_team_state() + lead_context.team_store.append_event( + current.team_id, + "team.batch_paused", + { + "completed_batches": completed_batches, + "executed_task_ids": executed, + }, + ) + if self._is_v2(latest): + latest = self._set_lifecycle( + lead_context, latest, "paused" + ) + return self._result(lead_context, latest, executed) + except Exception as exc: + current = lead_context.team_store.load_team(team.team_id) or team + if self._is_v2(current) and self._is_infrastructure_exception(exc): + return self._fail_team( + lead_context, + current, + str(exc), + locals().get("executed", []), + status="paused", + lifecycle_state="paused", + ) + return self._fail_team( + lead_context, current, str(exc), locals().get("executed", []) + ) + + @staticmethod + def _quality_policy(team: Team) -> dict[str, Any]: + value = team.settings.get("quality_gates") + return dict(value) if isinstance(value, dict) else {} + + @classmethod + def _is_strict(cls, team: Team) -> bool: + return bool(cls._quality_policy(team).get("strict")) + + @classmethod + def _protocol_version(cls, team: Team) -> int: + quality = cls._quality_policy(team) + versions = [1] + for raw in ( + getattr(team, "protocol_version", None), + team.settings.get("protocol_version"), + quality.get("protocol_version"), + ): + try: + versions.append(int(raw)) + except (TypeError, ValueError): + continue + return max(versions) + + @classmethod + def _is_v2(cls, team: Team) -> bool: + return cls._protocol_version(team) >= 2 + + @staticmethod + def _active_plan_hash(team: Team) -> str: + plan = team.settings.get("team_plan") + return str(plan.get("hash") or "") if isinstance(plan, dict) else "" + + @staticmethod + def _execution_values_equal(left: Any, right: Any) -> bool: + if isinstance(left, bool) or isinstance(right, bool): + return type(left) is type(right) and left == right + if isinstance(left, (int, float)) and isinstance(right, (int, float)): + return float(left) == float(right) + return left == right + + @classmethod + def _frozen_execution(cls, team: Team) -> dict[str, Any]: + """Return normalized immutable execution values for the active v2 plan.""" + + defaults = TeamRunOptions().to_dict() + defaults.pop("max_batches", None) + defaults.update({"verify_timeout_s": 900, "auto_verify": True}) + plan = team.settings.get("team_plan") + plan = plan if isinstance(plan, dict) else {} + manifest = team.settings.get("execution_manifest") + manifest = manifest if isinstance(manifest, dict) else {} + source: Any = None + if ( + manifest.get("plan_hash") == plan.get("hash") + and isinstance(manifest.get("execution"), dict) + ): + source = manifest["execution"] + elif isinstance(plan.get("execution"), dict): + source = plan["execution"] + if isinstance(source, dict): + for key in (*_FROZEN_RUN_OPTION_KEYS, "verify_timeout_s", "auto_verify"): + if key in source: + defaults[key] = source[key] + return defaults + + @classmethod + def _execution_override_mismatches( + cls, team: Team, requested: dict[str, Any] + ) -> list[dict[str, Any]]: + if not cls._is_v2(team) or not cls._active_plan_hash(team): + return [] + frozen = cls._frozen_execution(team) + mismatches: list[dict[str, Any]] = [] + for field in _FROZEN_RUN_OPTION_KEYS: + value = requested.get(field) + if value is None: + continue + planned = frozen.get(field) + if cls._execution_values_equal(value, planned): + continue + mismatches.append( + { + "field": field, + "planned": planned, + "requested": value, + "reason": ( + "runtime override differs from the frozen TeamPlan execution " + "manifest" + ), + } + ) + return mismatches + + @classmethod + def _execution_setting_mismatches( + cls, team: Team, effective: dict[str, Any] + ) -> list[dict[str, Any]]: + """Describe stale top-level settings that the frozen plan will reconcile.""" + + if not cls._is_v2(team) or not cls._active_plan_hash(team): + return [] + reasons: list[dict[str, Any]] = [] + for field in ( + *_FROZEN_RUN_OPTION_KEYS, + "max_batches", + "verify_timeout_s", + "auto_verify", + ): + if field not in team.settings: + continue + reasons.append( + { + "field": field, + "planned_effective": effective.get(field), + "persisted": team.settings.get(field), + "reason": ( + "legacy top-level execution setting is not authoritative in " + "protocol v2 and was removed" + ), + } + ) + return reasons + + @staticmethod + def _is_infrastructure_exception(exc: Exception) -> bool: + if isinstance(exc, (TimeoutError, ConnectionError, OSError)): + return True + message = str(exc).lower() + return any( + marker in message + for marker in ( + "ags backend is not started", + "sandbox unavailable", + "deployment unavailable", + "connection reset", + "connection refused", + "service unavailable", + "pending request was cancelled", + ) + ) + + @staticmethod + def _set_lifecycle( + context: ToolContext, team: Team, state: str, *, event: bool = True + ) -> Team: + current = context.team_store.load_team(team.team_id) or team + if current.lifecycle_state == state: + return current + previous = current.lifecycle_state + current.set_lifecycle_state(state) + context.team_store.save_team(current) + if event: + context.team_store.append_event( + current.team_id, + "team.lifecycle_changed", + {"from": previous, "to": state}, + ) + context.reload_team_state() + return current + + @staticmethod + def _normalize_owned_path(value: str) -> str: + normalized = value.strip().replace("\\", "/") + while normalized.startswith("./"): + normalized = normalized[2:] + return normalized.rstrip("/") + + @classmethod + def _owned_paths_overlap(cls, left: str, right: str) -> bool: + first = cls._normalize_owned_path(left) + second = cls._normalize_owned_path(right) + return bool( + first == second + or first.startswith(second + "/") + or second.startswith(first + "/") + ) + + def _strict_plan_errors( + self, + lead_context: ToolContext, + team: Team, + *, + require_parallel_start: bool, + ) -> list[str]: + if not self._is_strict(team): + return [] + quality = self._quality_policy(team) + errors: list[str] = [] + if not quality.get("configured"): + errors.append("call TeamConfigure before TeamRun") + if not require_parallel_start and not quality.get("plan_accepted"): + errors.append("strict plan has not been accepted by TeamRun") + for field in ( + "architecture_contract", + "install_command", + "import_command", + "integration_command", + ): + if not str(quality.get(field) or "").strip(): + errors.append(f"quality gate is missing {field}") + + lead_context.reload_team_state() + agents = { + agent.agent_id: agent + for agent in lead_context.team_store.list_agents(team.team_id) + } + tasks = list(lead_context.tasks.values()) + teammate_tasks = [task for task in tasks if task.get("owner") in agents] + if self._is_v2(team): + plan = team.settings.get("team_plan") + plan = plan if isinstance(plan, dict) else {} + plan_hash = str(plan.get("hash") or "") + try: + revision = int(plan.get("revision") or 0) + except (TypeError, ValueError): + revision = 0 + if not plan_hash or revision < 1: + errors.append( + "protocol v2 requires one committed atomic TeamPlan revision" + ) + manifest = team.settings.get("execution_manifest") + manifest = manifest if isinstance(manifest, dict) else {} + if not manifest: + errors.append( + "protocol v2 active plan is missing its frozen execution manifest" + ) + else: + if str(manifest.get("plan_hash") or "") != plan_hash: + errors.append( + "frozen execution manifest plan_hash does not match the active TeamPlan" + ) + try: + manifest_revision = int(manifest.get("plan_revision") or 0) + except (TypeError, ValueError): + manifest_revision = 0 + if manifest_revision != revision: + errors.append( + "frozen execution manifest revision does not match the active TeamPlan" + ) + manifest_execution = manifest.get("execution") + plan_execution = plan.get("execution") + if not isinstance(manifest_execution, dict) or ( + isinstance(plan_execution, dict) + and manifest_execution != plan_execution + ): + errors.append( + "frozen execution manifest values do not match the active TeamPlan" + ) + validation = quality.get("validation") + validation = validation if isinstance(validation, dict) else {} + if ( + validation.get("requires_plan_revision") + and validation.get("failed_plan_hash") == plan_hash + ): + errors.append( + "repair_required state requires a new TeamPlan revision" + ) + for task in teammate_tasks: + metadata = ( + task.get("metadata") + if isinstance(task.get("metadata"), dict) + else {} + ) + if not plan_hash or metadata.get("plan_hash") != plan_hash: + key = str(task.get("key") or task.get("id")) + errors.append( + f"task {key} is not bound to the active atomic TeamPlan hash" + ) + implementation_tasks = [ + task + for task in teammate_tasks + if (task.get("metadata") or {}).get("task_type") + != "validation" + ] + implementation_owners = { + str(task.get("owner")) + for task in implementation_tasks + if task.get("owned_files") and task.get("acceptance_checks") + } + if len(implementation_owners) < 2: + errors.append( + "protocol v2 requires two distinct owners of real implementation tasks" + ) + assigned_owners = {str(task.get("owner")) for task in teammate_tasks} + if len(assigned_owners) < 2: + errors.append("strict teams require at least two assigned teammates") + if len(teammate_tasks) < 2: + errors.append("strict teams require at least two teammate-owned tasks") + + paths: list[tuple[str, str, str]] = [] + providers: dict[str, list[dict[str, Any]]] = {} + contract = quality.get("contract") if isinstance(quality.get("contract"), dict) else {} + interface_modes = { + str(item.get("name")): str(item.get("mode") or "handoff") + for item in (contract.get("interfaces") or []) + if isinstance(item, dict) and item.get("name") + } + for task in teammate_tasks: + key = str(task.get("key") or task.get("id")) + owned_files = list(task.get("owned_files") or []) + acceptance_checks = list(task.get("acceptance_checks") or []) + metadata = task.get("metadata") if isinstance(task.get("metadata"), dict) else {} + is_validation_task = metadata.get("task_type") == "validation" + if not owned_files and not is_validation_task: + errors.append(f"task {key} must declare ownedFiles") + if not acceptance_checks: + errors.append(f"task {key} must declare acceptanceChecks") + for path in owned_files: + normalized = self._normalize_owned_path(str(path)) + if ( + not normalized + or normalized.startswith("/") + or ".." in normalized.split("/") + or any(mark in normalized for mark in "*?[]") + ): + errors.append( + f"task {key} ownedFiles must use concrete workspace paths: {path!r}" + ) + if require_parallel_start or task.get("status") != "completed": + paths.append((key, str(task.get("owner")), normalized)) + for interface in task.get("provides_interfaces") or []: + providers.setdefault(str(interface), []).append(task) + + for index, (left_key, left_owner, left_path) in enumerate(paths): + for right_key, right_owner, right_path in paths[index + 1 :]: + # A single worker may intentionally own a directory plus one of its + # children. The write-conflict gate is only meaningful across owners. + if left_key == right_key or left_owner == right_owner: + continue + if self._owned_paths_overlap(left_path, right_path): + errors.append( + "ownedFiles overlap between " + f"{left_key} ({left_owner}) and {right_key} ({right_owner}): " + f"{left_path!r} vs {right_path!r}" + ) + + for task in teammate_tasks: + key = str(task.get("key") or task.get("id")) + dependencies = set(task.get("blockedBy") or []) + for interface in task.get("depends_on_interfaces") or []: + matches = providers.get(str(interface), []) + if len(matches) != 1: + errors.append( + f"task {key} interface {interface!r} must have exactly one provider" + ) + continue + provider_task = matches[0] + provider_id = str(provider_task.get("id")) + if provider_id == str(task.get("id")): + errors.append(f"task {key} cannot depend on its own interface {interface!r}") + elif ( + interface_modes.get(str(interface), "handoff") != "frozen" + and provider_id not in dependencies + ): + provider_key = str(provider_task.get("key") or provider_id) + errors.append( + f"task {key} must include provider task {provider_key} in blockedBy " + f"for interface {interface!r}" + ) + + if require_parallel_start: + ready_owners = { + str(task.get("owner")) + for task in teammate_tasks + if not task.get("blockedBy") + and ( + task.get("status") == "pending" + or ( + task.get("status") == "completed" + and task.get("lifecycle_state") == "produced" + and isinstance(task.get("metadata"), dict) + and isinstance(task["metadata"].get("carry_forward"), dict) + and task["metadata"]["carry_forward"].get( + "requires_acceptance" + ) + is True + ) + ) + } + if len(ready_owners) < 2: + errors.append( + "strict teams require at least two initially ready tasks with distinct owners" + ) + return list(dict.fromkeys(errors)) + + def _coordination_errors( + self, lead_context: ToolContext, team: Team + ) -> list[str]: + if not self._is_strict(team): + return [] + # Protocol v2 freezes shared contracts in TeamPlan and encodes real handoff + # dependencies in the DAG. A ceremonial peer message is not evidence that + # the contract is correct, so it is retained only for v1 compatibility. + if self._is_v2(team): + return [] + tasks = list(lead_context.team_store.load_tasks(team.team_id).values()) + providers: dict[str, dict[str, Any]] = {} + for task in tasks: + for interface in task.get("provides_interfaces") or []: + providers[str(interface)] = task + message_edges = { + frozenset((message.sender_id, message.recipient_id)) + for message in lead_context.team_store.list_messages(team.team_id) + } + errors: list[str] = [] + for task in tasks: + for interface in task.get("depends_on_interfaces") or []: + provider = providers.get(str(interface)) + if provider is None: + continue + owner = str(task.get("owner") or "") + provider_owner = str(provider.get("owner") or "") + if owner and provider_owner and owner != provider_owner: + if frozenset((owner, provider_owner)) not in message_edges: + errors.append( + f"owners of interface {interface!r} must exchange at least one " + "peer message before completion" + ) + return list(dict.fromkeys(errors)) + + @classmethod + def _validation_timeout(cls, team: Team) -> int: + quality = cls._quality_policy(team) + frozen = cls._frozen_execution(team) if cls._is_v2(team) else {} + raw = ( + quality.get("verify_timeout_s") + or frozen.get("verify_timeout_s") + or team.settings.get("verify_timeout_s") + ) + try: + return max(1, int(raw or 900)) + except (TypeError, ValueError): + return 900 + + def _accept_produced_tasks( + self, + lead_context: ToolContext, + team: Team, + *, + timeout_s: int, + executed: list[str], + ) -> dict[str, Any] | None: + """Run harness-owned task acceptance between production and integration. + + v1 exposes only the coarse ``completed`` status. In v2 a worker reaching + that status means it produced a candidate; the harness must execute every + declared check before the task is accepted. Results are persisted on the + task, making recovery and dashboard inspection deterministic. + """ + if not self._is_v2(team): + return None + failures: list[dict[str, Any]] = [] + task_data = lead_context.team_store.load_tasks(team.team_id) + for raw in task_data.values(): + task = TeamTask.from_dict(raw) + if task.status != "completed" or task.lifecycle_state == "accepted": + continue + task.set_lifecycle_state("produced") + stages: list[dict[str, Any]] = [] + for command in task.acceptance_checks: + result = self._run_validation_command( + lead_context, str(command), timeout_s=timeout_s + ) + stages.append({"command": str(command), **result}) + if result["exit_code"] != 0: + break + passed = bool(task.acceptance_checks) and all( + stage["exit_code"] == 0 for stage in stages + ) + acceptance = { + "status": "passed" if passed else "failed", + "checked_at": utc_now(), + "stages": stages, + } + task.metadata = dict(task.metadata) + task.metadata["acceptance"] = acceptance + if passed: + task.set_lifecycle_state("accepted") + lead_context.team_store.append_event( + team.team_id, + "task.accepted", + {"task_id": task.id, "task_key": task.key, "stages": stages}, + ) + else: + failure = { + "task_id": task.id, + "task_key": task.key, + "error": ( + "task has no acceptance checks" + if not task.acceptance_checks + else "acceptance check failed" + ), + "acceptance": acceptance, + } + failures.append(failure) + task.last_error = failure["error"] + lead_context.team_store.append_event( + team.team_id, "task.acceptance_failed", failure + ) + self._save_task(lead_context.team_store, team.team_id, task) + + lead_context.reload_team_state() + if not failures: + return None + + current = lead_context.team_store.load_team(team.team_id) or team + quality = self._quality_policy(current) + quality["validation"] = { + "status": "pending", + "reason": "task acceptance failed", + "requires_plan_revision": True, + "failed_plan_hash": str( + ((current.settings.get("team_plan") or {}).get("hash") or "") + ), + } + current.settings["quality_gates"] = quality + current.set_lifecycle_state("repair_required") + lead_context.team_store.save_team(current) + lead_context.team_store.append_event( + current.team_id, "team.repair_required", {"task_failures": failures} + ) + lead_context.reload_team_state() + return { + "status": "repair_required", + "team_id": current.team_id, + "lifecycle_state": current.lifecycle_state, + "error": "one or more task acceptance checks failed", + "task_failures": failures, + "executed_task_ids": executed, + "tasks": list(lead_context.tasks.values()), + "quality_gates": quality, + "usage": current.usage, + "next_required_action": ( + "Call TeamReplan first to checkpoint the workspace, then submit one " + "complete materially changed TeamPlan revision and call TeamRun again." + ), + } + + def verify_team( + self, lead_context: ToolContext, *, timeout_s: int = 300 + ) -> dict[str, Any]: + team = lead_context.team_store.load_active_team() + if team is None: + return {"status": "failed", "error": "no active team"} + if not self._is_strict(team): + return { + "status": "failed", + "team_id": team.team_id, + "error": "TeamVerify requires strict quality gates", + } + lead_context.reload_team_state() + quality = self._quality_policy(team) + validation = dict(quality.get("validation") or {}) + v2_tasks_accepted = bool(lead_context.tasks) and all( + task.get("status") == "completed" + and task.get("lifecycle_state") == "accepted" + for task in lead_context.tasks.values() + ) + if ( + team.status == "completed" + and team.lifecycle_state == "completed" + and validation.get("status") == "passed" + and (not self._is_v2(team) or v2_tasks_accepted) + ): + # Verification is deliberately idempotent: repeated tool calls must not + # recreate environments, duplicate events, or reopen a settled team. + result = self._result(lead_context, team, []) + result["validation"] = validation + result["verification_reused"] = True + return result + unfinished = [ + str(task.get("key") or task_id) + for task_id, task in lead_context.tasks.items() + if task.get("status") != "completed" + ] + if unfinished: + return { + "status": "failed", + "team_id": team.team_id, + "error": "unfinished teammate tasks: " + ", ".join(unfinished), + } + plan_errors = self._strict_plan_errors( + lead_context, team, require_parallel_start=False + ) + coordination_errors = self._coordination_errors(lead_context, team) + errors = plan_errors + coordination_errors + if errors: + return { + "status": ( + "repair_required" + if self._is_v2(team) + and team.lifecycle_state == "repair_required" + else "failed" + ), + "team_id": team.team_id, + "lifecycle_state": team.lifecycle_state, + "error": "; ".join(errors), + } + if self._is_v2(team): + acceptance_failure = self._accept_produced_tasks( + lead_context, team, timeout_s=timeout_s, executed=[] + ) + if acceptance_failure is not None: + return acceptance_failure + team = self._set_lifecycle( + lead_context, team, "awaiting_verification" + ) + + quality = self._quality_policy(team) + if self._is_v2(team): + team = self._set_lifecycle(lead_context, team, "verifying") + commands = [ + ("install", str(quality["install_command"])), + ("import", str(quality["import_command"])), + ("integration", str(quality["integration_command"])), + ] + validation_root = ( + f"/tmp/clawd-team-verify-{team.team_id}-{uuid.uuid4().hex[:12]}" + if lead_context.workspace_backend is not None + else tempfile.mkdtemp(prefix=f"clawd-team-verify-{team.team_id}-") + ) + venv_bin = f"{validation_root}/bin" + validation_workspace = ( + lead_context.execution_workspace_root or "/workspace" + if lead_context.workspace_backend is not None + else str(lead_context.workspace_root) + ) + bootstrap_python = "python3" if lead_context.workspace_backend is not None else shlex.quote(sys.executable) + bootstrap = ( + f"{bootstrap_python} -m venv --system-site-packages " + f"{shlex.quote(validation_root)}" + ) + stages: list[dict[str, Any]] = [] + verification_error: Exception | None = None + cleanup_error: Exception | None = None + try: + bootstrap_result = self._run_validation_command( + lead_context, bootstrap, timeout_s=timeout_s + ) + stages.append( + {"stage": "bootstrap", "command": bootstrap, **bootstrap_result} + ) + if bootstrap_result["exit_code"] == 0: + prefix = ( + f"export VIRTUAL_ENV={shlex.quote(validation_root)}; " + f"export PATH={shlex.quote(venv_bin)}:$PATH; " + f"export PYTHONPATH={shlex.quote(validation_workspace)}; " + ) + for stage, command in commands: + result = self._run_validation_command( + lead_context, prefix + command, timeout_s=timeout_s + ) + stages.append({"stage": stage, "command": command, **result}) + if result["exit_code"] != 0: + break + except Exception as exc: + verification_error = exc + finally: + try: + self._cleanup_validation_root(lead_context, validation_root) + except Exception as exc: + cleanup_error = exc + + infrastructure_error = verification_error or cleanup_error + if infrastructure_error is not None: + if not self._is_v2(team): + raise infrastructure_error + current = lead_context.team_store.load_team(team.team_id) or team + validation = { + "status": "paused", + "verified_at": utc_now(), + "fresh_virtualenv": True, + "stages": stages, + "failure_domain": "infrastructure", + "retryable": True, + "error": f"{type(infrastructure_error).__name__}: {infrastructure_error}", + "cleanup_error": ( + f"{type(cleanup_error).__name__}: {cleanup_error}" + if cleanup_error is not None + else None + ), + } + quality = self._quality_policy(current) + quality["validation"] = validation + current.settings["quality_gates"] = quality + lead_context.team_store.save_team(current) + current = self._set_lifecycle(lead_context, current, "paused") + lead_context.team_store.append_event( + current.team_id, + "team.validation_paused", + { + "error": validation["error"], + "cleanup_error": validation["cleanup_error"], + "retryable": True, + }, + ) + return { + "status": "paused", + "team_id": current.team_id, + "lifecycle_state": current.lifecycle_state, + "failure_domain": "infrastructure", + "retryable": True, + "error": validation["error"], + "validation": validation, + "next_required_action": "Retry TeamRun when infrastructure is healthy.", + } + + passed = len(stages) == 4 and all(stage["exit_code"] == 0 for stage in stages) + validation = { + "status": "passed" if passed else "failed", + "verified_at": utc_now(), + "fresh_virtualenv": True, + "stages": stages, + } + team = lead_context.team_store.load_team(team.team_id) or team + if not passed and self._is_v2(team): + plan = team.settings.get("team_plan") + plan = plan if isinstance(plan, dict) else {} + validation.update( + { + "requires_plan_revision": True, + "failed_plan_hash": str(plan.get("hash") or ""), + } + ) + quality = self._quality_policy(team) + quality["validation"] = validation + team.settings["quality_gates"] = quality + lead_context.team_store.save_team(team) + lead_context.team_store.append_event( + team.team_id, + "team.validation_passed" if passed else "team.validation_failed", + {"stages": stages}, + ) + if not passed: + failed_stage = next( + (stage for stage in stages if stage["exit_code"] != 0), stages[-1] + ) + if self._is_v2(team): + team = self._set_lifecycle( + lead_context, team, "repair_required" + ) + lead_context.team_store.append_event( + team.team_id, + "team.repair_required", + {"failed_stage": failed_stage["stage"]}, + ) + return { + "status": "repair_required" if self._is_v2(team) else "failed", + "team_id": team.team_id, + "lifecycle_state": team.lifecycle_state, + "error": f"{failed_stage['stage']} validation failed", + "validation": validation, + "next_required_action": ( + "Call TeamReplan first to checkpoint the workspace, then submit one " + "complete materially changed TeamPlan revision and call TeamRun again." + if self._is_v2(team) + else "Create a repair task, run TeamRun, then retry TeamVerify." + ), + } + completed = self._complete_team(lead_context, team) + result = self._result(lead_context, completed, []) + result["validation"] = validation + return result + + @staticmethod + def _run_validation_command( + context: ToolContext, command: str, *, timeout_s: int + ) -> dict[str, Any]: + if context.workspace_backend is not None: + outcome = context.workspace_backend.exec( + command, + cwd=context.execution_workspace_root or "/workspace", + timeout_s=timeout_s, + ) + return { + "exit_code": int(outcome.exit_code), + "stdout": str(outcome.stdout or "")[-20_000:], + "stderr": str(outcome.stderr or "")[-20_000:], + } + try: + environment = os.environ.copy() + environment["PATH"] = ( + str(Path(sys.executable).parent) + + os.pathsep + + environment.get("PATH", "") + ) + completed = subprocess.run( + ["bash", "-lc", command], + cwd=str(context.workspace_root), + capture_output=True, + text=True, + timeout=timeout_s, + env=environment, + ) + return { + "exit_code": completed.returncode, + "stdout": (completed.stdout or "")[-20_000:], + "stderr": (completed.stderr or "")[-20_000:], + } + except subprocess.TimeoutExpired as exc: + return { + "exit_code": 124, + "stdout": str(exc.stdout or "")[-20_000:], + "stderr": str(exc.stderr or "")[-20_000:], + } + + @staticmethod + def _cleanup_validation_root(context: ToolContext, path: str) -> None: + if context.workspace_backend is not None: + outcome = context.workspace_backend.exec( + f"rm -rf {shlex.quote(path)}", + cwd=context.execution_workspace_root or "/workspace", + timeout_s=60, + ) + if int(outcome.exit_code) != 0: + raise OSError( + "failed to remove validation environment " + f"{path}: {str(outcome.stderr or outcome.stdout or '').strip()}" + ) + else: + shutil.rmtree(path, ignore_errors=True) + + def _verification_required( + self, lead_context: ToolContext, team: Team, executed: list[str] + ) -> dict[str, Any]: + lead_context.reload_team_state() + quality = self._quality_policy(team) + lead_context.team_store.append_event( + team.team_id, + "team.verification_required", + {"validation_status": (quality.get("validation") or {}).get("status")}, + ) + return { + "status": "verification_required", + "team_id": team.team_id, + "executed_task_ids": executed, + "tasks": list(lead_context.tasks.values()), + "quality_gates": quality, + "next_required_action": ( + "Call TeamVerify. The team remains running and protocol completion is false " + "until clean install, import, and integration checks pass." + ), + "usage": team.usage, + } + + @staticmethod + def _dependencies_completed( + task: dict[str, Any], tasks: dict[str, dict[str, Any]] + ) -> bool: + dependencies = list(task.get("blockedBy") or []) + return all( + dependency in tasks and tasks[dependency].get("status") == "completed" + for dependency in dependencies + ) + + @staticmethod + def _build_batch( + ready: list[dict[str, Any]], team: Team, options: TeamRunOptions + ) -> tuple[list[dict[str, Any]], list[int]]: + workers = min(options.max_workers, len(ready)) + usage = TeammateRuntime._normalized_usage(team.usage) + remaining_turns = None + if options.turn_budget is not None: + budget = TeammateRuntime._budget_window(team, options) + ceiling = budget["hard_ceiling"]["turns"] + remaining_turns = max(0, int(ceiling) - usage["turns"]) + workers = min(workers, remaining_turns) + if workers <= 0: + return [], [] + batch: list[dict[str, Any]] = [] + owners: set[str] = set() + for task in ready: + owner_key = str(task.get("owner") or f"task:{task.get('id')}") + if owner_key in owners: + continue + owners.add(owner_key) + batch.append(task) + if len(batch) >= workers: + break + workers = len(batch) + if workers == 0: + return [], [] + if remaining_turns is None: + return batch, [0] * len(batch) + base = max(1, remaining_turns // len(batch)) + limits = [base] * len(batch) + for index in range(remaining_turns - base * len(batch)): + limits[index] += 1 + return batch, limits + + def _run_batch( + self, + lead_context: ToolContext, + team: Team, + batch: list[dict[str, Any]], + options: TeamRunOptions, + turn_limits: list[int], + ) -> list[TaskOutcome]: + tasks = [TeamTask.from_dict(task) for task in batch] + effective_limits = [ + self.max_turns if limit <= 0 else min(self.max_turns, limit) + for limit in turn_limits + ] + if len(tasks) == 1: + return [ + self._run_task( + lead_context, team, tasks[0], options, effective_limits[0] + ) + ] + with ThreadPoolExecutor( + max_workers=len(tasks), thread_name_prefix=f"clawd-{team.team_id}" + ) as pool: + futures = [ + pool.submit( + self._run_task, + lead_context, + team, + task, + options, + limit, + ) + for task, limit in zip(tasks, effective_limits) + ] + return [future.result() for future in futures] + + def _run_task( + self, + lead_context: ToolContext, + team: Team, + task: TeamTask, + options: TeamRunOptions, + task_max_turns: int, + ) -> TaskOutcome: + store = lead_context.team_store + input_tokens = 0 + output_tokens = 0 + turns = 0 + if not task.owner: + self._set_task_failed(store, team, task, "task has no owner") + return TaskOutcome("failed", task.id, error="task has no owner") + agent = store.load_agent(team.team_id, task.owner) + if agent is None: + error = f"unknown task owner: {task.owner}" + self._set_task_failed(store, team, task, error) + return TaskOutcome("failed", task.id, error=error) + if agent.status in {"stopping", "cancelled"} or agent.stop_requested_at: + return TaskOutcome("stopped", task.id) + + lease_id = uuid.uuid4().hex + try: + claimed = store.claim_task( + team.team_id, + task.id, + lease_id=lease_id, + lease_expires_at=self._lease_expiry(options.lease_timeout_s), + max_retries=options.max_retries, + expected_plan_hash=self._active_plan_hash(team), + ) + if claimed is None: + return TaskOutcome("leased", task.id) + task = claimed + agent = self._transition_agent(store, agent, "running") + if agent.status in {"stopping", "cancelled"} or agent.stop_requested_at: + return self._finalize_worker_stop(store, agent, task, {}) + store.append_event( + team.team_id, + "task.started", + { + "task_id": task.id, + "task_key": task.key, + "agent_id": agent.agent_id, + "attempt": task.attempt, + "lease_id": lease_id, + }, + ) + + conversation = self._load_conversation(store, agent) + incoming = self._consume_messages(store, team, agent) + conversation.add_user_message(self._task_prompt(team, agent, task, incoming)) + self._save_session(store, agent, conversation) + + child_context = self._child_context(lead_context, team, agent, task) + + def heartbeat(event: ToolEvent) -> None: + if event.kind in { + "model_started", + "model_response", + "tool_use", + "tool_result", + "tool_error", + }: + self._refresh_lease( + store, + team.team_id, + task.id, + lease_id, + options.lease_timeout_s, + ) + + def should_stop() -> bool: + current_team = store.load_team(team.team_id) or team + current_agent = store.load_agent(team.team_id, agent.agent_id) or agent + return bool( + current_team.status == "cancelled" + or current_team.cancel_requested_at + or current_agent.status in {"stopping", "cancelled"} + or current_agent.stop_requested_at + ) + + result = run_agent_loop( + conversation=conversation, + provider=self.provider, + tool_registry=self._child_registry(agent), + tool_context=child_context, + max_turns=task_max_turns, + max_output_tokens=self.max_output_tokens, + stream=False, + verbose=False, + on_event=heartbeat, + should_stop=should_stop, + ) + self._save_session(store, agent, conversation) + usage = result.usage or {} + input_tokens = int(usage.get("input_tokens", 0) or 0) + output_tokens = int(usage.get("output_tokens", 0) or 0) + turns = result.num_turns + outcome_usage = { + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "turns": turns, + } + + persisted_data = store.load_tasks(team.team_id).get(task.id) + persisted = TeamTask.from_dict(persisted_data or task.to_dict()) + latest_team = store.load_team(team.team_id) or team + if latest_team.status == "cancelled" or latest_team.cancel_requested_at: + if persisted.status == "in_progress": + persisted.transition_to("cancelled") + self._clear_lease(persisted) + persisted.last_error = "team cancelled" + self._save_task(store, team.team_id, persisted) + self._transition_agent(store, agent, "cancelled") + store.append_event( + team.team_id, + "task.cancelled", + {"task_id": task.id, "task_key": task.key, "agent_id": agent.agent_id}, + ) + return TaskOutcome("cancelled", task.id, **outcome_usage) + + latest_agent = store.load_agent(team.team_id, agent.agent_id) or agent + if latest_agent.status in {"stopping", "cancelled"} or latest_agent.stop_requested_at: + return self._finalize_worker_stop( + store, + latest_agent, + persisted, + outcome_usage, + ) + + if child_context.ownership_violations: + error = self._set_ownership_failed( + store, + team, + persisted, + child_context.ownership_violations, + ) + transitioned = self._transition_agent(store, agent, "failed") + if transitioned.status in {"stopping", "cancelled"} or transitioned.stop_requested_at: + return self._finalize_worker_stop( + store, transitioned, persisted, outcome_usage + ) + return TaskOutcome( + "failed", + task.id, + error=error, + repair_required=True, + **outcome_usage, + ) + + if result.response_text == "[Max tool turns reached]": + self._set_task_failed(store, team, persisted, result.response_text) + transitioned = self._transition_agent(store, agent, "failed") + if transitioned.status in {"stopping", "cancelled"} or transitioned.stop_requested_at: + return self._finalize_worker_stop( + store, transitioned, persisted, outcome_usage + ) + return TaskOutcome( + "failed", task.id, error=result.response_text, **outcome_usage + ) + if persisted.status == "failed": + if not persisted.output: + persisted.output = result.response_text + persisted.updated_at = utc_now() + self._save_task(store, team.team_id, persisted) + transitioned = self._transition_agent(store, agent, "failed") + if transitioned.status in {"stopping", "cancelled"} or transitioned.stop_requested_at: + return self._finalize_worker_stop( + store, transitioned, persisted, outcome_usage + ) + return TaskOutcome( + "failed", task.id, error=persisted.output, **outcome_usage + ) + if persisted.status == "cancelled": + self._transition_agent(store, agent, "cancelled") + return TaskOutcome("cancelled", task.id, **outcome_usage) + if persisted.status == "in_progress": + persisted.output = result.response_text + persisted.transition_to("completed") + elif persisted.status == "completed" and not persisted.output: + persisted.output = result.response_text + persisted.updated_at = utc_now() + if self._is_v2(team): + persisted.set_lifecycle_state("produced") + if agent.workspace_mode == "worktree" and agent.auto_integrate: + integration = TeammateWorktreeManager( + lead_context.workspace_root + ).integrate(agent, persisted) + store.append_event( + team.team_id, + "worktree.integrated", + {"agent_id": agent.agent_id, "task_id": task.id, **integration}, + ) + self._clear_lease(persisted) + persisted.completed_at = utc_now() + self._save_task(store, team.team_id, persisted) + transitioned = self._transition_agent(store, agent, "idle") + if transitioned.status in {"stopping", "cancelled"} or transitioned.stop_requested_at: + return self._finalize_worker_stop( + store, transitioned, persisted, outcome_usage + ) + store.append_event( + team.team_id, + "task.produced" if self._is_v2(team) else "task.completed", + { + "task_id": task.id, + "task_key": task.key, + "agent_id": agent.agent_id, + "attempt": persisted.attempt, + "lifecycle_state": persisted.lifecycle_state, + }, + ) + return TaskOutcome("completed", task.id, **outcome_usage) + except Exception as exc: + current_data = store.load_tasks(team.team_id).get(task.id) + current = TeamTask.from_dict(current_data or task.to_dict()) + violations = list( + getattr(locals().get("child_context"), "ownership_violations", []) + or [] + ) + if violations: + error = self._set_ownership_failed(store, team, current, violations) + latest_agent = store.load_agent(team.team_id, agent.agent_id) or agent + if latest_agent.status in {"created", "running", "idle"}: + self._transition_agent(store, latest_agent, "failed") + return TaskOutcome( + "failed", + task.id, + input_tokens=input_tokens, + output_tokens=output_tokens, + turns=turns, + error=error, + repair_required=True, + ) + if self._is_v2(team) and self._is_infrastructure_exception(exc): + error = self._set_task_infrastructure_paused( + store, team, current, str(exc) + ) + latest_agent = store.load_agent(team.team_id, agent.agent_id) or agent + if latest_agent.status == "running": + self._transition_agent(store, latest_agent, "idle") + return TaskOutcome( + "infrastructure", + task.id, + input_tokens=input_tokens, + output_tokens=output_tokens, + turns=turns, + error=error, + infrastructure=True, + ) + if current.status in {"pending", "in_progress"}: + self._set_task_failed(store, team, current, str(exc)) + latest_agent = store.load_agent(team.team_id, agent.agent_id) or agent + if latest_agent.status in {"created", "running", "idle"}: + latest_agent = self._transition_agent(store, latest_agent, "failed") + if latest_agent.status in {"stopping", "cancelled"} and latest_agent.stop_requested_at: + return self._finalize_worker_stop( + store, + latest_agent, + current, + { + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "turns": turns, + }, + ) + return TaskOutcome( + "failed", + task.id, + input_tokens=input_tokens, + output_tokens=output_tokens, + turns=turns, + error=str(exc), + ) + + @staticmethod + def _finalize_worker_stop( + store: TeamStore, + agent: AgentRecord, + task: TeamTask, + usage: dict[str, int], + ) -> TaskOutcome: + reason = agent.stop_reason or "worker stopped by lead" + policy = agent.stop_task_policy or "requeue" + state: dict[str, Any] = {"outcome": "stopped", "event": None} + + def mutate(tasks: dict[str, TeamTask]) -> None: + current = tasks.get(task.id) + if current is None: + current = task + tasks[task.id] = current + if current.status == "completed": + state["outcome"] = "completed" + TeammateRuntime._clear_lease(current) + current.completed_at = current.completed_at or utc_now() + state["event"] = ( + "task.completed", + { + "task_id": current.id, + "task_key": current.key, + "agent_id": agent.agent_id, + "attempt": current.attempt, + }, + ) + elif policy == "requeue": + already_requeued = ( + current.status == "pending" + and current.owner is None + and current.lease_id is None + and current.lease_expires_at is None + ) + if already_requeued: + return + if current.status != "pending": + current.transition_to("pending") + current.owner = None + current.output = "" + current.completed_at = None + current.last_error = reason + TeammateRuntime._clear_lease(current) + state["event"] = ( + "task.requeued", + { + "task_id": current.id, + "agent_id": agent.agent_id, + "reason": reason, + }, + ) + else: + already_cancelled = ( + current.status == "cancelled" + and current.lease_id is None + and current.lease_expires_at is None + and current.last_error == reason + ) + if already_cancelled: + return + if current.status != "cancelled": + current.transition_to("cancelled") + current.completed_at = utc_now() + current.last_error = reason + TeammateRuntime._clear_lease(current) + state["event"] = ( + "task.cancelled", + { + "task_id": current.id, + "agent_id": agent.agent_id, + "reason": reason, + }, + ) + + store.mutate_tasks(agent.team_id, mutate) + if state["event"] is not None: + event_type, payload = state["event"] + store.append_event(agent.team_id, event_type, payload) + + mark_teammate_stopped(store, agent.team_id, agent.agent_id) + return TaskOutcome(str(state["outcome"]), task.id, **usage) + + def _recover_tasks( + self, + lead_context: ToolContext, + team: Team, + options: TeamRunOptions, + *, + retry_failed: bool, + retry_cancelled: bool, + ) -> None: + store = lead_context.team_store + for task_data in store.load_tasks(team.team_id).values(): + task = TeamTask.from_dict(task_data) + reason: str | None = None + if task.status == "in_progress" and self._lease_expired(task.lease_expires_at): + reason = "expired or missing task lease" + elif task.status == "failed" and ( + retry_failed or task.attempt <= max(task.max_retries, options.max_retries) + ): + reason = "retrying failed task" + elif task.status == "cancelled" and retry_cancelled: + reason = "resuming cancelled task" + if reason is None: + continue + previous = task.status + task.transition_to("pending") + self._clear_lease(task) + task.completed_at = None + task.max_retries = max(task.max_retries, options.max_retries) + task.last_error = reason + self._save_task(store, team.team_id, task) + self._reset_agent_for_retry(store, team.team_id, task.owner) + store.append_event( + team.team_id, + "task.recovered", + { + "task_id": task.id, + "from": previous, + "reason": reason, + "attempt": task.attempt, + }, + ) + lead_context.reload_team_state() + + def _schedule_retry( + self, + store: TeamStore, + team: Team, + task: TeamTask, + options: TeamRunOptions, + ) -> bool: + retry_limit = max(task.max_retries, options.max_retries) + if task.status != "failed" or task.attempt > retry_limit: + return False + task.transition_to("pending") + self._clear_lease(task) + task.completed_at = None + self._save_task(store, team.team_id, task) + self._reset_agent_for_retry(store, team.team_id, task.owner) + store.append_event( + team.team_id, + "task.retry_scheduled", + {"task_id": task.id, "attempt": task.attempt, "max_retries": retry_limit}, + ) + return True + + def _child_registry(self, agent: AgentRecord) -> ToolRegistry: + names = self.validate_tools([*agent.tools, *_MANDATORY_TEAMMATE_TOOLS]) + return ToolRegistry(self.registry.get(name) for name in names) + + @staticmethod + def _child_context( + lead_context: ToolContext, team: Team, agent: AgentRecord, task: TeamTask + ) -> ToolContext: + workspace_root = lead_context.workspace_root + if agent.workspace_mode == "worktree" and agent.workspace_path: + workspace_root = Path(agent.workspace_path) + permissions = ToolPermissionContext.from_iterables( + workspace_root=workspace_root, + allow_docs=lead_context.permission_context.allow_docs, + ) + context = ToolContext( + workspace_root=workspace_root, + permission_context=permissions, + cwd=workspace_root, + workspace_backend=lead_context.workspace_backend, + execution_workspace_root=lead_context.execution_workspace_root, + execution_cwd=lead_context.execution_workspace_root, + actor_id=agent.agent_id, + current_task_id=task.id, + mutation_lock=lead_context.mutation_lock, + model_override=agent.model, + system_prompt_extra=( + "## Teammate Identity\n" + f"You are teammate `{agent.name}` with role `{agent.role}` in team `{team.team_name}`.\n" + f"Role instructions: {agent.instructions}\n" + f"Your current task is `{task.key or task.id}`. Work only on this task. " + "Use SendMessage to coordinate directly with any teammate or the lead when it helps; " + "use ReadMessages to receive peer replies during parallel work. Communicate useful " + "decisions, interfaces, blockers, and handoffs rather than sending ceremonial updates. " + "Do not claim another teammate's work. " + "Protocol-v2 file ownership is enforced: Write/Edit paths must be inside this " + "task's owned_files, and Bash workspace changes are audited after each command. " + "Prefer disposable self-tests under `" + f"{task_test_scratch_prefix_for_id(task.id)}/`. A new `tests/test_*.py` " + "path is also task-local only if it did not exist at plan start and no " + "other task declared it. Existing or reserved tests are never writable; " + "persistent deliverable tests must be listed in this task's owned_files. " + "Any out-of-scope write fails the task and requires a repair plan. " + "If the task cannot be completed, set your current task to failed with TaskUpdate and explain why." + ), + ) + if workspace_root != lead_context.workspace_root: + context.team_store = lead_context.team_store + context.team = team.to_dict() + context.tasks = lead_context.team_store.load_tasks(team.team_id) + context.permission_handler = lead_context.permission_handler + return context + + @staticmethod + def _task_prompt( + team: Team, agent: AgentRecord, task: TeamTask, incoming: list[Message] + ) -> str: + quality = TeammateRuntime._quality_policy(team) + lines = [ + f"Team: {team.team_name}", + f"Task ID: {task.id}", + f"Task key: {task.key or task.id}", + f"Attempt: {task.attempt}", + f"Subject: {task.subject}", + f"Description: {task.description}", + ] + if quality.get("strict"): + lines.extend( + [ + f"Architecture contract: {quality.get('architecture_contract') or 'not configured'}", + "Owned files/directories: " + ", ".join(task.owned_files), + "Interfaces provided: " + + (", ".join(task.provides_interfaces) or "none"), + "Interfaces consumed: " + + (", ".join(task.depends_on_interfaces) or "none"), + "Acceptance checks: " + "; ".join(task.acceptance_checks), + ( + "Strict ownership is active: do not modify files owned by another task. " + "Prefer disposable self-tests under `" + + task_test_scratch_prefix_for_id(task.id) + + "/`. A new `tests/test_*.py` is task-local only when it did not " + "exist at plan start and no other task declared it. Existing or " + "reserved tests are forbidden; persistent deliverable tests must " + "be declared in owned_files. " + "If you consume another task's interface, exchange a concrete interface " + "message with that owner before finishing." + ), + ] + ) + if incoming: + lines.append("Incoming teammate messages:") + for message in incoming: + lines.append( + f"- from {message.sender_id}: {message.summary or ''}\n {message.content}" + ) + else: + lines.append("Incoming teammate messages: none") + lines.append( + "Complete the task using only your available tools, run every declared acceptance " + "check, and report concrete evidence. Team-level validation will run independently." + ) + return "\n".join(lines) + + @staticmethod + def _load_conversation(store: TeamStore, agent: AgentRecord) -> Conversation: + data = store.load_session(agent.team_id, agent.session_id) + if data is None: + return Conversation() + conversation = data.get("conversation") + return Conversation.from_dict(conversation) if isinstance(conversation, dict) else Conversation() + + def _save_session( + self, store: TeamStore, agent: AgentRecord, conversation: Conversation + ) -> None: + store.save_session( + agent.team_id, + agent.session_id, + { + "session_id": agent.session_id, + "team_id": agent.team_id, + "agent_id": agent.agent_id, + "model": agent.model or getattr(self.provider, "model", None), + "conversation": conversation.to_dict(), + "updated_at": utc_now(), + }, + ) + + @staticmethod + def _consume_messages( + store: TeamStore, team: Team, agent: AgentRecord + ) -> list[Message]: + return store.consume_messages(team.team_id, agent.agent_id) + + @staticmethod + def _save_task(store: TeamStore, team_id: str, task: TeamTask) -> None: + store.update_task( + team_id, + task, + expected_plan_hash=str((task.metadata or {}).get("plan_hash") or "") + or None, + ) + + @staticmethod + def _set_task_failed( + store: TeamStore, team: Team, task: TeamTask, error: str + ) -> None: + if task.status == "pending": + task.transition_to("in_progress") + if task.status == "in_progress": + task.transition_to("failed") + TeammateRuntime._clear_lease(task) + task.output = error + task.last_error = error + task.completed_at = utc_now() + task.updated_at = utc_now() + TeammateRuntime._save_task(store, team.team_id, task) + store.append_event( + team.team_id, + "task.failed", + { + "task_id": task.id, + "task_key": task.key, + "attempt": task.attempt, + "error": error, + }, + ) + + @staticmethod + def _set_ownership_failed( + store: TeamStore, + team: Team, + task: TeamTask, + violations: list[dict[str, Any]], + ) -> str: + """Persist a sticky ownership failure at the scheduler boundary.""" + + paths = sorted( + { + str(path) + for violation in violations + for path in (violation.get("paths") or []) + } + ) + rendered = ", ".join(paths[:8]) + if len(paths) > 8: + rendered += f", ... (+{len(paths) - 8} more)" + error = f"protocol v2 task ownership violation: {rendered or 'unknown path'}" + # A teammate may catch a tool error and update its own task. Override that + # self-reported state so an ownership breach can never become completed. + task.status = "failed" + task.set_lifecycle_state("failed") + TeammateRuntime._clear_lease(task) + task.output = error + task.last_error = error + task.completed_at = utc_now() + task.metadata = dict(task.metadata) + task.metadata["ownership_audit"] = { + "status": "failed", + "violations": violations, + } + TeammateRuntime._save_task(store, team.team_id, task) + store.append_event( + team.team_id, + "task.ownership_failed", + { + "task_id": task.id, + "task_key": task.key, + "attempt": task.attempt, + "paths": paths, + "error": error, + }, + ) + return error + + @staticmethod + def _set_task_infrastructure_paused( + store: TeamStore, team: Team, task: TeamTask, error: str + ) -> str: + """Release a v2 task lease without turning transport failure into a candidate.""" + + task.status = "pending" + task.set_lifecycle_state("pending") + TeammateRuntime._clear_lease(task) + task.completed_at = None + task.last_error = error + task.metadata = dict(task.metadata) + task.metadata["infrastructure_failure"] = { + "retryable": True, + "error": error, + "recorded_at": utc_now(), + } + TeammateRuntime._save_task(store, team.team_id, task) + store.append_event( + team.team_id, + "task.infrastructure_paused", + { + "task_id": task.id, + "task_key": task.key, + "attempt": task.attempt, + "retryable": True, + "error": error, + }, + ) + return error + + @staticmethod + def _transition_agent( + store: TeamStore, agent: AgentRecord, status: str + ) -> AgentRecord: + changed = False + + def mutate(current: AgentRecord) -> None: + nonlocal changed + if current.stop_requested_at and status != "cancelled": + return + if current.status != status: + current.transition_to(status) + changed = True + + updated = store.mutate_agent(agent.team_id, agent.agent_id, mutate) + current = updated or agent + if changed: + store.append_event( + current.team_id, + f"agent.{status}", + {"agent_id": current.agent_id, "name": current.name}, + ) + return current + + @staticmethod + def _reset_agent_for_retry( + store: TeamStore, team_id: str, agent_id: str | None + ) -> None: + if not agent_id: + return + agent = store.load_agent(team_id, agent_id) + if agent is None: + return + if agent.stop_requested_at or agent.status == "stopping": + return + if agent.status in {"failed", "cancelled"}: + agent = TeammateRuntime._transition_agent(store, agent, "running") + if agent.status == "running": + TeammateRuntime._transition_agent(store, agent, "idle") + + @staticmethod + def _refresh_lease( + store: TeamStore, + team_id: str, + task_id: str, + lease_id: str, + lease_timeout_s: int, + ) -> None: + task_data = store.load_tasks(team_id).get(task_id) + if task_data is None: + return + task = TeamTask.from_dict(task_data) + if task.status != "in_progress" or task.lease_id != lease_id: + return + task.lease_expires_at = TeammateRuntime._lease_expiry(lease_timeout_s) + TeammateRuntime._save_task(store, team_id, task) + + @staticmethod + def _lease_expiry(seconds: int) -> str: + return (datetime.now(timezone.utc) + timedelta(seconds=seconds)).isoformat() + + @staticmethod + def _lease_expired(value: str | None) -> bool: + if not value: + return True + try: + parsed = datetime.fromisoformat(value) + except ValueError: + return True + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed <= datetime.now(timezone.utc) + + @staticmethod + def _clear_lease(task: TeamTask) -> None: + task.lease_id = None + task.lease_expires_at = None + + @staticmethod + def _normalized_usage(usage: dict[str, Any]) -> dict[str, int]: + return { + "input_tokens": int(usage.get("input_tokens", 0) or 0), + "output_tokens": int(usage.get("output_tokens", 0) or 0), + "total_tokens": int(usage.get("total_tokens", 0) or 0), + "turns": int(usage.get("turns", 0) or 0), + } + + @staticmethod + def _record_usage( + store: TeamStore, team_id: str, outcomes: list[TaskOutcome] + ) -> None: + team = store.load_team(team_id) + if team is None: + return + usage = TeammateRuntime._normalized_usage(team.usage) + for outcome in outcomes: + usage["input_tokens"] += outcome.input_tokens + usage["output_tokens"] += outcome.output_tokens + usage["turns"] += outcome.turns + usage["total_tokens"] = usage["input_tokens"] + usage["output_tokens"] + team.usage = usage + store.save_team(team) + + @staticmethod + def _budget_error( + team: Team, options: TeamRunOptions, run_started: float + ) -> str | None: + usage = TeammateRuntime._normalized_usage(team.usage) + budget = TeammateRuntime._budget_window(team, options) + if options.timeout_s is not None and time.monotonic() - run_started >= options.timeout_s: + return f"team timeout exceeded ({options.timeout_s}s)" + token_ceiling = budget["hard_ceiling"]["total_tokens"] + if token_ceiling is not None and usage["total_tokens"] >= int(token_ceiling): + return ( + "team plan-revision token budget exhausted " + f"(incremental={options.token_budget}, " + f"baseline={budget['baseline']['total_tokens']}, " + f"hard_ceiling={token_ceiling})" + ) + turn_ceiling = budget["hard_ceiling"]["turns"] + if turn_ceiling is not None and usage["turns"] >= int(turn_ceiling): + return ( + "team plan-revision turn budget exhausted " + f"(incremental={options.turn_budget}, " + f"baseline={budget['baseline']['turns']}, " + f"hard_ceiling={turn_ceiling})" + ) + return None + + @staticmethod + def _budget_window( + team: Team, options: TeamRunOptions + ) -> dict[str, dict[str, int | None] | str]: + """Resolve the active plan revision's incremental budget window. + + The scheduler still compares against an absolute, team-wide hard ceiling; + only the baseline moves when a newly authorized repair plan is committed. + """ + + baseline = {"total_tokens": 0, "turns": 0} + stored_ceiling: dict[str, int | None] | None = None + global_cap = {"total_tokens": None, "turns": None} + manifest = team.settings.get("execution_manifest") + if TeammateRuntime._is_v2(team): + plan = team.settings.get("team_plan") + plan = plan if isinstance(plan, dict) else {} + if not isinstance(manifest, dict): + raise ValueError("execution budget manifest is missing") + errors = execution_budget_manifest_errors( + plan, manifest, usage=team.usage + ) + if errors: + raise ValueError( + "invalid execution budget manifest: " + "; ".join(errors) + ) + if isinstance(manifest, dict): + plan = team.settings.get("team_plan") + plan_hash = str(plan.get("hash") or "") if isinstance(plan, dict) else "" + window = manifest.get("budget_window") + if manifest.get("plan_hash") == plan_hash and isinstance(window, dict): + raw_baseline = window.get("baseline") + if isinstance(raw_baseline, dict): + baseline = { + "total_tokens": int(raw_baseline.get("total_tokens", 0) or 0), + "turns": int(raw_baseline.get("turns", 0) or 0), + } + raw_ceiling = window.get("hard_ceiling") + if isinstance(raw_ceiling, dict): + stored_ceiling = { + "total_tokens": ( + int(raw_ceiling["total_tokens"]) + if raw_ceiling.get("total_tokens") is not None + else None + ), + "turns": ( + int(raw_ceiling["turns"]) + if raw_ceiling.get("turns") is not None + else None + ), + } + raw_global = manifest.get("global_cap") + if isinstance(raw_global, dict): + global_cap = { + "total_tokens": ( + int(raw_global["total_tokens"]) + if raw_global.get("total_tokens") is not None + else None + ), + "turns": ( + int(raw_global["turns"]) + if raw_global.get("turns") is not None + else None + ), + } + computed_ceiling = { + "total_tokens": ( + baseline["total_tokens"] + options.token_budget + if options.token_budget is not None + else None + ), + "turns": ( + baseline["turns"] + options.turn_budget + if options.turn_budget is not None + else None + ), + } + return { + "scope": "plan_revision", + "baseline": baseline, + "global_cap": global_cap, + "hard_ceiling": stored_ceiling or computed_ceiling, + } + + @staticmethod + def _budget_exhausted_result( + lead_context: ToolContext, + team: Team, + error: str, + executed: list[str], + ) -> dict[str, Any]: + current = lead_context.team_store.load_team(team.team_id) or team + already_terminal = current.lifecycle_state == "budget_exhausted" + if not already_terminal: + if current.status == "created": + current.transition_to("running") + if current.status == "running": + current.transition_to("failed") + current.set_lifecycle_state("budget_exhausted") + current.completed_at = utc_now() + lead_context.team_store.save_team(current) + lead_context.team_store.append_event( + current.team_id, + "team.budget_exhausted", + {"error": error, "usage": current.usage}, + ) + lead_context.reload_team_state() + return { + "status": "budget_exhausted", + "team_id": current.team_id, + "lifecycle_state": "budget_exhausted", + "error": error, + "terminal": True, + "replan_allowed": False, + "resume_allowed": False, + "workspace_preserved": True, + "executed_task_ids": executed, + "usage": current.usage, + } + + def _complete_team(self, lead_context: ToolContext, team: Team) -> Team: + store = lead_context.team_store + for agent in store.list_agents(team.team_id): + if agent.status == "created": + agent = self._transition_agent(store, agent, "running") + self._transition_agent(store, agent, "completed") + elif agent.status in {"running", "idle"}: + self._transition_agent(store, agent, "completed") + elif agent.status == "failed": + agent = self._transition_agent(store, agent, "running") + self._transition_agent(store, agent, "completed") + current = store.load_team(team.team_id) or team + if current.status != "completed": + current.transition_to("completed") + current.completed_at = utc_now() + store.save_team(current) + store.append_event(current.team_id, "team.completed", {"usage": current.usage}) + elif self._is_v2(current) and current.lifecycle_state != "completed": + current.set_lifecycle_state("completed") + current.completed_at = current.completed_at or utc_now() + store.save_team(current) + lead_context.reload_team_state() + return current + + @staticmethod + def _fail_team( + lead_context: ToolContext, + team: Team, + error: str, + executed: list[str], + *, + status: str = "failed", + lifecycle_state: str | None = None, + ) -> dict[str, Any]: + current = lead_context.team_store.load_team(team.team_id) or team + if lifecycle_state is not None: + current.set_lifecycle_state(lifecycle_state) + lead_context.team_store.save_team(current) + lead_context.team_store.append_event( + current.team_id, + f"team.{lifecycle_state}", + {"error": error, "usage": current.usage}, + ) + elif current.status == "running": + current.transition_to("failed") + lead_context.team_store.save_team(current) + lead_context.team_store.append_event( + current.team_id, "team.failed", {"error": error, "usage": current.usage} + ) + lead_context.reload_team_state() + return { + "status": status, + "team_id": current.team_id, + "lifecycle_state": current.lifecycle_state, + "error": error, + "executed_task_ids": executed, + "usage": current.usage, + } + + @staticmethod + def _cancelled_result( + lead_context: ToolContext, team: Team, executed: list[str] + ) -> dict[str, Any]: + return { + "status": "cancelled", + "team_id": team.team_id, + "lifecycle_state": team.lifecycle_state, + "error": "team cancellation requested", + "executed_task_ids": executed, + "usage": team.usage, + } + + @staticmethod + def _blocked_result( + lead_context: ToolContext, team: Team, error: str, executed: list[str] + ) -> dict[str, Any]: + lead_context.reload_team_state() + return { + "status": "blocked", + "team_id": team.team_id, + "lifecycle_state": team.lifecycle_state, + "error": error, + "executed_task_ids": executed, + "usage": team.usage, + } + + @staticmethod + def _result( + lead_context: ToolContext, team: Team, executed: list[str] + ) -> dict[str, Any]: + agents = lead_context.team_store.list_agents(team.team_id) + names = {agent.agent_id: agent.name for agent in agents} + names[team.lead_agent_id] = "lead" + messages = [ + { + "message_id": message.message_id, + "from": names.get(message.sender_id, message.sender_id), + "to": names.get(message.recipient_id, message.recipient_id), + "summary": message.summary, + "status": message.status, + } + for message in lead_context.team_store.list_messages(team.team_id) + ] + return { + "status": team.status, + "team_id": team.team_id, + "protocol_version": TeammateRuntime._protocol_version(team), + "lifecycle_state": team.lifecycle_state, + "executed_task_ids": executed, + "tasks": list(lead_context.tasks.values()), + "messages": messages, + "quality_gates": TeammateRuntime._quality_policy(team), + "usage": team.usage, + } diff --git a/src/teammate/store.py b/src/teammate/store.py new file mode 100644 index 0000000..dfca511 --- /dev/null +++ b/src/teammate/store.py @@ -0,0 +1,1330 @@ +from __future__ import annotations + +import hashlib +import json +import os +import threading +import uuid +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Callable, Iterator + +try: + import fcntl +except ImportError: # pragma: no cover - Windows fallback + fcntl = None # type: ignore[assignment] + +from .models import AgentRecord, Message, Team, TeamTask, utc_now + + +_THREAD_LOCKS: dict[str, threading.RLock] = {} +_THREAD_LOCKS_GUARD = threading.Lock() +_TEAM_TRANSACTION_LOCAL = threading.local() +_TEAM_PLAN_EXECUTION_KEYS = { + "max_workers", + "max_batches", + "timeout_s", + "token_budget", + "turn_budget", + "max_retries", + "lease_timeout_s", + "verify_timeout_s", + "auto_verify", +} +_BUDGET_FIELDS = { + "total_tokens": "token_budget", + "turns": "turn_budget", +} + + +def _budget_integrity_hash( + *, + plan_hash: str, + plan_revision: int, + execution: dict[str, Any], + global_cap: dict[str, Any], + budget_window: dict[str, Any], +) -> str: + payload = { + "plan_hash": plan_hash, + "plan_revision": plan_revision, + "execution": execution, + "global_cap": global_cap, + "budget_window": budget_window, + } + encoded = json.dumps( + payload, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def execution_budget_manifest_errors( + plan: dict[str, Any], + manifest: dict[str, Any], + *, + usage: dict[str, Any] | None = None, +) -> list[str]: + """Validate every runtime-authoritative execution-budget field. + + Protocol-v2 schema 2 treats the derived window as frozen state. The plan hash + alone covers only the requested incremental limits, so the manifest also binds + the usage baseline, rollout-wide cap, and absolute ceilings to the plan revision. + """ + + errors: list[str] = [] + try: + schema_version = int(manifest.get("schema_version") or 0) + except (TypeError, ValueError): + schema_version = 0 + if schema_version != 2: + errors.append("execution manifest budget schema_version must be 2") + + plan_hash = str(plan.get("hash") or "") + try: + plan_revision = int(plan.get("revision") or 0) + except (TypeError, ValueError): + plan_revision = 0 + if str(manifest.get("plan_hash") or "") != plan_hash: + errors.append("budget manifest plan_hash does not match TeamPlan") + try: + manifest_revision = int(manifest.get("plan_revision") or 0) + except (TypeError, ValueError): + manifest_revision = 0 + if manifest_revision != plan_revision: + errors.append("budget manifest plan_revision does not match TeamPlan") + execution = plan.get("execution") + execution = execution if isinstance(execution, dict) else {} + manifest_execution = manifest.get("execution") + manifest_execution = ( + manifest_execution if isinstance(manifest_execution, dict) else {} + ) + if manifest_execution != execution: + errors.append("budget manifest execution does not match TeamPlan") + + global_cap = manifest.get("global_cap") + window = manifest.get("budget_window") + if not isinstance(global_cap, dict): + errors.append("budget manifest global_cap must be an object") + global_cap = {} + if not isinstance(window, dict): + errors.append("budget manifest budget_window must be an object") + window = {} + if window.get("scope") != "plan_revision": + errors.append("budget_window.scope must be plan_revision") + + baseline = window.get("baseline") + incremental = window.get("incremental_limit") + hard_ceiling = window.get("hard_ceiling") + if not isinstance(baseline, dict): + errors.append("budget_window.baseline must be an object") + baseline = {} + if not isinstance(incremental, dict): + errors.append("budget_window.incremental_limit must be an object") + incremental = {} + if not isinstance(hard_ceiling, dict): + errors.append("budget_window.hard_ceiling must be an object") + hard_ceiling = {} + + normalized_usage: dict[str, int] = {} + if usage is not None: + raw_total = int(usage.get("total_tokens", 0) or 0) + component_total = int(usage.get("input_tokens", 0) or 0) + int( + usage.get("output_tokens", 0) or 0 + ) + normalized_usage = { + "total_tokens": max(raw_total, component_total), + "turns": int(usage.get("turns", 0) or 0), + } + + normalized_global: dict[str, int | None] = {} + normalized_baseline: dict[str, int] = {} + normalized_incremental: dict[str, int | None] = {} + normalized_ceiling: dict[str, int | None] = {} + + def optional_non_negative(value: Any, field: str) -> int | None: + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + errors.append(f"{field} must be a non-negative integer or null") + return None + return value + + for metric, budget_key in _BUDGET_FIELDS.items(): + raw_baseline = baseline.get(metric) + if ( + isinstance(raw_baseline, bool) + or not isinstance(raw_baseline, int) + or raw_baseline < 0 + ): + errors.append( + f"budget_window.baseline.{metric} must be a non-negative integer" + ) + baseline_value = 0 + else: + baseline_value = raw_baseline + normalized_baseline[metric] = baseline_value + if normalized_usage and baseline_value > normalized_usage[metric]: + errors.append( + f"budget_window.baseline.{metric} exceeds recorded team usage" + ) + + planned_limit = optional_non_negative( + execution.get(budget_key), f"execution.{budget_key}" + ) + recorded_limit = optional_non_negative( + incremental.get(metric), + f"budget_window.incremental_limit.{metric}", + ) + normalized_incremental[metric] = recorded_limit + if recorded_limit != planned_limit: + errors.append( + f"budget_window.incremental_limit.{metric} does not match TeamPlan" + ) + + global_value = optional_non_negative( + global_cap.get(metric), f"global_cap.{metric}" + ) + normalized_global[metric] = global_value + ceiling_value = optional_non_negative( + hard_ceiling.get(metric), f"budget_window.hard_ceiling.{metric}" + ) + normalized_ceiling[metric] = ceiling_value + + allocated = ( + baseline_value + planned_limit if planned_limit is not None else None + ) + expected_ceiling = ( + global_value + if allocated is None + else allocated + if global_value is None + else min(allocated, global_value) + ) + if ceiling_value != expected_ceiling: + errors.append( + f"budget_window.hard_ceiling.{metric} is inconsistent with its " + "baseline, incremental limit, and global cap" + ) + if plan_revision == 1 and global_value != allocated: + errors.append( + f"global_cap.{metric} does not match the first plan allocation" + ) + + normalized_window = { + "scope": window.get("scope"), + "baseline": normalized_baseline, + "incremental_limit": normalized_incremental, + "hard_ceiling": normalized_ceiling, + } + expected_integrity = _budget_integrity_hash( + plan_hash=plan_hash, + plan_revision=plan_revision, + execution=execution, + global_cap=normalized_global, + budget_window=normalized_window, + ) + if str(manifest.get("budget_integrity_hash") or "") != expected_integrity: + errors.append("budget_integrity_hash mismatch") + return list(dict.fromkeys(errors)) + + +def _accepted_task_evidence(task: TeamTask) -> dict[str, Any] | None: + metadata = task.metadata if isinstance(task.metadata, dict) else {} + evidence = metadata.get("acceptance") + if not isinstance(evidence, dict) or evidence.get("status") != "passed": + return None + stages = evidence.get("stages") + if not isinstance(stages, list) or len(stages) != len(task.acceptance_checks): + return None + for command, stage in zip(task.acceptance_checks, stages): + if ( + not isinstance(stage, dict) + or str(stage.get("command") or "") != str(command) + or stage.get("exit_code") != 0 + ): + return None + return evidence + + +def _logical_task_dependencies( + tasks: dict[str, dict[str, Any]], +) -> dict[str, set[str]]: + id_to_key = { + task_id: str(raw.get("key") or task_id).lower() + for task_id, raw in tasks.items() + if isinstance(raw, dict) + } + providers: dict[str, set[str]] = {} + for task_id, raw in tasks.items(): + if not isinstance(raw, dict): + continue + key = id_to_key[task_id] + for interface in raw.get("provides_interfaces") or []: + providers.setdefault(str(interface), set()).add(key) + dependencies: dict[str, set[str]] = {} + for task_id, raw in tasks.items(): + if not isinstance(raw, dict): + continue + key = id_to_key[task_id] + task_dependencies = { + id_to_key[dependency_id] + for dependency_id in raw.get("blockedBy") or [] + if dependency_id in id_to_key + } + for interface in raw.get("depends_on_interfaces") or []: + task_dependencies.update(providers.get(str(interface), set())) + task_dependencies.discard(key) + dependencies[key] = task_dependencies + return dependencies + + +def _carry_forward_accepted_tasks( + *, + current_tasks: dict[str, dict[str, Any]], + candidate_tasks: dict[str, dict[str, Any]], + current_plan: dict[str, Any], + next_plan: dict[str, Any], + checkpoint: dict[str, Any], +) -> list[dict[str, Any]]: + """Carry exact accepted artifacts into a revision without re-running workers. + + Candidate records retain their new IDs, owners, DAG edges, and active plan hash. + Only the old artifact/output and acceptance evidence are inherited. Lifecycle is + deliberately downgraded to ``produced`` so the new revision's harness must run + task acceptance again before final verification. + """ + + current_hash = str(current_plan.get("hash") or "") + next_hash = str(next_plan.get("hash") or "") + current_contract_hash = str(current_plan.get("contract_hash") or "") + next_contract_hash = str(next_plan.get("contract_hash") or "") + if not current_hash or not next_hash or current_hash == next_hash: + return [] + if not current_contract_hash or current_contract_hash != next_contract_hash: + return [] + artifact_keys = { + str(key).lower() for key in checkpoint.get("artifact_tasks") or [] + } + current_by_key = { + str(raw.get("key") or task_id).lower(): (task_id, raw) + for task_id, raw in current_tasks.items() + if isinstance(raw, dict) + } + candidate_by_key = { + str(raw.get("key") or task_id).lower(): (task_id, raw) + for task_id, raw in candidate_tasks.items() + if isinstance(raw, dict) + } + evidence_by_key: dict[str, dict[str, Any]] = {} + reusable: set[str] = set() + for key, (_, candidate_raw) in candidate_by_key.items(): + previous = current_by_key.get(key) + if previous is None or key not in artifact_keys: + continue + _, current_raw = previous + current_task = TeamTask.from_dict(current_raw) + current_metadata = current_task.metadata or {} + candidate_metadata = ( + candidate_raw.get("metadata") + if isinstance(candidate_raw.get("metadata"), dict) + else {} + ) + current_fingerprint = str( + current_metadata.get("task_contract_fingerprint") or "" + ) + candidate_fingerprint = str( + candidate_metadata.get("task_contract_fingerprint") or "" + ) + evidence = _accepted_task_evidence(current_task) + if ( + current_task.status == "completed" + and current_task.lifecycle_state == "accepted" + and evidence is not None + and current_fingerprint + and current_fingerprint == candidate_fingerprint + and str(current_metadata.get("plan_hash") or "") == current_hash + and str(candidate_metadata.get("plan_hash") or "") == next_hash + and str(current_metadata.get("contract_hash") or "") + == current_contract_hash + and str(candidate_metadata.get("contract_hash") or "") + == next_contract_hash + ): + reusable.add(key) + evidence_by_key[key] = evidence + + dependencies = _logical_task_dependencies(candidate_tasks) + while True: + invalidated = { + key + for key in reusable + if any( + dependency not in reusable + for dependency in dependencies.get(key, set()) + ) + } + if not invalidated: + break + reusable.difference_update(invalidated) + + carried: list[dict[str, Any]] = [] + for key in sorted(reusable): + old_task_id, current_raw = current_by_key[key] + candidate_task_id, candidate_raw = candidate_by_key[key] + old_task = TeamTask.from_dict(current_raw) + task = TeamTask.from_dict(candidate_raw) + task.transition_to("completed") + task.set_lifecycle_state("produced") + task.output = old_task.output + task.started_at = old_task.started_at + task.completed_at = old_task.completed_at + task.last_error = None + task.metadata = dict(task.metadata) + task.metadata.pop("acceptance", None) + task.metadata["carry_forward"] = { + "from_task_id": old_task_id, + "from_plan_hash": current_hash, + "from_plan_revision": int(current_plan.get("revision") or 0), + "accepted_evidence": json.loads( + json.dumps(evidence_by_key[key], ensure_ascii=False) + ), + "source_attempt": old_task.attempt, + "requires_acceptance": True, + "carried_at": utc_now(), + } + task.updated_at = utc_now() + candidate_tasks[candidate_task_id] = task.to_dict() + carried.append( + { + "key": str(task.key or candidate_task_id), + "from_task_id": old_task_id, + "task_id": candidate_task_id, + "from_plan_hash": current_hash, + "contract_fingerprint": str( + task.metadata.get("task_contract_fingerprint") or "" + ), + "lifecycle_state": "produced", + "requires_acceptance": True, + } + ) + return carried + + +def _thread_lock(path: Path) -> threading.RLock: + key = str(path.resolve()) + with _THREAD_LOCKS_GUARD: + return _THREAD_LOCKS.setdefault(key, threading.RLock()) + + +@contextmanager +def _locked_path(path: Path) -> Iterator[None]: + lock_path = path.with_name(f".{path.name}.lock") + lock_path.parent.mkdir(parents=True, exist_ok=True) + with _thread_lock(lock_path): + with lock_path.open("a+", encoding="utf-8") as handle: + if fcntl is not None: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX) + try: + yield + finally: + if fcntl is not None: + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + + +@contextmanager +def _locked_team_transaction(path: Path) -> Iterator[None]: + """Acquire one re-entrant write transaction lock for a whole team. + + Every team/tasks/agents/sessions writer takes this lock before its narrower + file lock. Re-entry in the same thread deliberately skips a second ``flock`` + so callbacks cannot deadlock on a lock already owned by their transaction. + """ + + key = str(path.resolve()) + held = getattr(_TEAM_TRANSACTION_LOCAL, "held", None) + if held is None: + held = set() + _TEAM_TRANSACTION_LOCAL.held = held + if key in held: + yield + return + with _locked_path(path): + held.add(key) + try: + yield + finally: + held.remove(key) + + +class TeamStore: + """Filesystem-backed storage for the active team and its shared state.""" + + def __init__(self, workspace_root: Path): + self.workspace_root = Path(workspace_root).resolve() + self.clawd_dir = self.workspace_root / ".clawd" + self.teams_dir = self.clawd_dir / "teams" + self.active_team_path = self.clawd_dir / "team.json" + + def team_dir(self, team_id: str) -> Path: + return self.teams_dir / team_id + + def _transaction_path(self, team_id: str) -> Path: + return self.team_dir(team_id) / "team-transaction" + + def create_team( + self, + team_name: str, + description: str | None = None, + agent_type: str | None = None, + ) -> Team: + if self.load_active_team() is not None: + raise ValueError("an active team already exists") + + team = Team( + team_id=uuid.uuid4().hex[:12], + team_name=team_name, + description=description, + agent_type=agent_type, + lead_agent_id=uuid.uuid4().hex[:12], + ) + directory = self.team_dir(team.team_id) + for name in ("agents", "sessions", "messages"): + (directory / name).mkdir(parents=True, exist_ok=True) + + self._write_json(directory / "team.json", team.to_dict()) + self._write_json(directory / "tasks.json", {}) + (directory / "events.jsonl").touch(exist_ok=True) + self._write_json(self.active_team_path, team.to_dict()) + self.append_event(team.team_id, "team.created", {"team": team.to_dict()}) + return team + + def load_active_team(self) -> Team | None: + if not self.active_team_path.exists(): + return None + data = self._read_json(self.active_team_path) + if "team_id" not in data: + data = self._migrate_legacy_team(data) + return Team.from_dict(data) + + def load_team(self, team_id: str) -> Team | None: + path = self.team_dir(team_id) / "team.json" + if not path.exists(): + return None + return Team.from_dict(self._read_json(path)) + + def save_team(self, team: Team) -> Path: + path = self.team_dir(team.team_id) / "team.json" + with _locked_team_transaction(self._transaction_path(team.team_id)): + self._write_json(path, team.to_dict()) + active = self.load_active_team() + if active is not None and active.team_id == team.team_id: + self._write_json(self.active_team_path, team.to_dict()) + return path + + def load_tasks(self, team_id: str) -> dict[str, dict[str, Any]]: + path = self.team_dir(team_id) / "tasks.json" + if not path.exists(): + return {} + data = self._read_json(path) + if not isinstance(data, dict): + raise ValueError(f"invalid task store at {path}") + return {task_id: TeamTask.from_dict(task).to_dict() for task_id, task in data.items()} + + def save_tasks(self, team_id: str, tasks: dict[str, dict[str, Any]]) -> None: + serialized = {task_id: TeamTask.from_dict(task).to_dict() for task_id, task in tasks.items()} + with _locked_team_transaction(self._transaction_path(team_id)): + self._write_json(self.team_dir(team_id) / "tasks.json", serialized) + + def mutate_tasks( + self, + team_id: str, + mutator: Callable[[dict[str, TeamTask]], Any], + ) -> Any: + """Apply one atomic mutation across the team's task collection.""" + path = self.team_dir(team_id) / "tasks.json" + with _locked_team_transaction(self._transaction_path(team_id)): + with _locked_path(path): + data = self._read_json(path) if path.exists() else {} + tasks = { + task_id: TeamTask.from_dict(task) + for task_id, task in data.items() + } + result = mutator(tasks) + self._write_json_unlocked( + path, + {task_id: task.to_dict() for task_id, task in tasks.items()}, + ) + return result + + def update_task( + self, + team_id: str, + task: TeamTask, + *, + expected_plan_hash: str | None = None, + ) -> dict[str, dict[str, Any]]: + path = self.team_dir(team_id) / "tasks.json" + with _locked_team_transaction(self._transaction_path(team_id)): + with _locked_path(path): + data = self._read_json(path) if path.exists() else {} + current = data.get(task.id) + # A worker may finish after a TeamReplan/TeamPlan replacement. Its + # stale in-memory task must never recreate the deleted revision. + if not isinstance(current, dict): + return { + task_id: TeamTask.from_dict(raw).to_dict() + for task_id, raw in data.items() + } + team_path = self.team_dir(team_id) / "team.json" + team = ( + Team.from_dict(self._read_json(team_path)) + if team_path.exists() + else None + ) + if team is not None and team.protocol_version >= 2: + plan = team.settings.get("team_plan") + plan = plan if isinstance(plan, dict) else {} + active_hash = str(plan.get("hash") or "") + task_hash = str( + expected_plan_hash + or (task.metadata or {}).get("plan_hash") + or "" + ) + current_hash = str( + ((current.get("metadata") or {}).get("plan_hash") or "") + if isinstance(current.get("metadata"), dict) + else "" + ) + if ( + not active_hash + or task_hash != active_hash + or current_hash != active_hash + ): + return { + task_id: TeamTask.from_dict(raw).to_dict() + for task_id, raw in data.items() + } + data[task.id] = task.to_dict() + self._write_json_unlocked(path, data) + return self.load_tasks(team_id) + + def claim_task( + self, + team_id: str, + task_id: str, + *, + lease_id: str, + lease_expires_at: str, + max_retries: int, + expected_plan_hash: str | None = None, + ) -> TeamTask | None: + path = self.team_dir(team_id) / "tasks.json" + with _locked_team_transaction(self._transaction_path(team_id)): + with _locked_path(path): + data = self._read_json(path) if path.exists() else {} + raw = data.get(task_id) + if not isinstance(raw, dict): + return None + task = TeamTask.from_dict(raw) + team_path = self.team_dir(team_id) / "team.json" + team = ( + Team.from_dict(self._read_json(team_path)) + if team_path.exists() + else None + ) + if team is not None and team.protocol_version >= 2: + plan = team.settings.get("team_plan") + plan = plan if isinstance(plan, dict) else {} + active_hash = str(plan.get("hash") or "") + task_hash = str( + expected_plan_hash + or (task.metadata or {}).get("plan_hash") + or "" + ) + if ( + team.lifecycle_state != "running" + or not active_hash + or task_hash != active_hash + or str((task.metadata or {}).get("plan_hash") or "") + != active_hash + ): + return None + if task.status != "pending": + return None + task.transition_to("in_progress") + task.attempt += 1 + task.max_retries = max(task.max_retries, max_retries) + task.lease_id = lease_id + task.lease_expires_at = lease_expires_at + task.started_at = utc_now() + task.completed_at = None + task.last_error = None + data[task_id] = task.to_dict() + self._write_json_unlocked(path, data) + return task + + def delete_task(self, team_id: str, task_id: str) -> dict[str, dict[str, Any]]: + path = self.team_dir(team_id) / "tasks.json" + with _locked_team_transaction(self._transaction_path(team_id)): + with _locked_path(path): + data = self._read_json(path) if path.exists() else {} + data.pop(task_id, None) + self._write_json_unlocked(path, data) + return self.load_tasks(team_id) + + def save_agent(self, agent: AgentRecord) -> Path: + path = self.team_dir(agent.team_id) / "agents" / f"{agent.agent_id}.json" + with _locked_team_transaction(self._transaction_path(agent.team_id)): + self._write_json(path, agent.to_dict()) + return path + + def mutate_agent( + self, + team_id: str, + agent_id: str, + mutator: Callable[[AgentRecord], Any], + ) -> AgentRecord | None: + """Atomically load, mutate, and persist one agent record.""" + path = self.team_dir(team_id) / "agents" / f"{agent_id}.json" + with _locked_team_transaction(self._transaction_path(team_id)): + with _locked_path(path): + if not path.exists(): + return None + agent = AgentRecord.from_dict(self._read_json(path)) + mutator(agent) + self._write_json_unlocked(path, agent.to_dict()) + return agent + + def load_agent(self, team_id: str, agent_id: str) -> AgentRecord | None: + path = self.team_dir(team_id) / "agents" / f"{agent_id}.json" + if not path.exists(): + return None + return AgentRecord.from_dict(self._read_json(path)) + + def list_agents(self, team_id: str) -> list[AgentRecord]: + directory = self.team_dir(team_id) / "agents" + if not directory.exists(): + return [] + return [AgentRecord.from_dict(self._read_json(path)) for path in sorted(directory.glob("*.json"))] + + def find_agent(self, team_id: str, identity: str) -> AgentRecord | None: + normalized = identity.strip().lower() + for agent in self.list_agents(team_id): + if agent.agent_id == identity or agent.name.lower() == normalized: + return agent + return None + + def save_message(self, message: Message) -> Path: + path = self.team_dir(message.team_id) / "messages" / f"{message.message_id}.json" + # Message writes participate in the team transaction so a protocol-v2 + # teammate Bash audit can freeze legitimate harness control-state changes + # while attributing its before/after filesystem diff. + with _locked_team_transaction(self._transaction_path(message.team_id)): + self._write_json(path, message.to_dict()) + return path + + def load_message(self, team_id: str, message_id: str) -> Message | None: + path = self.team_dir(team_id) / "messages" / f"{message_id}.json" + if not path.exists(): + return None + return Message.from_dict(self._read_json(path)) + + def list_messages(self, team_id: str) -> list[Message]: + directory = self.team_dir(team_id) / "messages" + if not directory.exists(): + return [] + messages = [Message.from_dict(self._read_json(path)) for path in directory.glob("*.json")] + messages.sort(key=lambda message: (message.created_at, message.message_id)) + return messages + + def consume_messages(self, team_id: str, recipient_id: str) -> list[Message]: + incoming = [ + message + for message in self.list_messages(team_id) + if message.recipient_id == recipient_id and message.status == "delivered" + ] + for message in incoming: + message.transition_to("consumed") + self.save_message(message) + self.append_event( + team_id, + "message.consumed", + {"message_id": message.message_id, "agent_id": recipient_id}, + ) + return incoming + + def save_session(self, team_id: str, session_id: str, data: dict[str, Any]) -> Path: + path = self.team_dir(team_id) / "sessions" / f"{session_id}.json" + with _locked_team_transaction(self._transaction_path(team_id)): + self._write_json(path, data) + return path + + def request_team_replan( + self, + team_id: str, + *, + reason: str, + replace_completed_work: bool, + ) -> tuple[Team, dict[str, Any]]: + """Checkpoint a recoverable plan transition in one team transaction. + + The busy check, lifecycle change, checkpoint, and event share the same + transaction as task claims. Therefore either a claim wins and replan is + rejected as busy, or replan wins and the claim observes repair_required. + """ + + team_path = self.team_dir(team_id) / "team.json" + tasks_path = self.team_dir(team_id) / "tasks.json" + with _locked_team_transaction(self._transaction_path(team_id)): + if not team_path.exists(): + raise ValueError("active team state is unavailable") + team = Team.from_dict(self._read_json(team_path)) + quality = team.settings.get("quality_gates") + quality = quality if isinstance(quality, dict) else {} + if team.protocol_version < 2 or not quality.get("strict"): + raise ValueError( + "TeamReplan is only available to strict protocol v2 teams" + ) + lifecycle = str(team.lifecycle_state or team.status) + if lifecycle in {"completed", "aborted", "budget_exhausted"} or ( + team.status == "completed" + ): + raise ValueError( + f"a {lifecycle} team is terminal and cannot be replanned; " + "preserve its workspace for scoring and start a new top-level rollout" + ) + + raw_tasks = self._read_json(tasks_path) if tasks_path.exists() else {} + active_tasks = [ + str(raw.get("key") or task_id) + for task_id, raw in raw_tasks.items() + if isinstance(raw, dict) and raw.get("status") == "in_progress" + ] + running_agents = [ + agent.name + for agent in self.list_agents(team_id) + if agent.status in {"running", "stopping"} + ] + if active_tasks or running_agents: + details: list[str] = [] + if active_tasks: + details.append("active tasks: " + ", ".join(active_tasks)) + if running_agents: + details.append("running teammates: " + ", ".join(running_agents)) + raise ValueError( + "cannot replan while workers are active; call TeamCancel, wait for " + "cooperative shutdown, then call TeamReplan. Do not use TeamAbort " + "for restart (" + + "; ".join(details) + + ")" + ) + + artifact_tasks = [ + str(raw.get("key") or task_id) + for task_id, raw in raw_tasks.items() + if isinstance(raw, dict) + and ( + raw.get("status") == "completed" + or raw.get("lifecycle_state") in {"produced", "accepted"} + or bool(str(raw.get("output") or "").strip()) + ) + ] + if artifact_tasks and not replace_completed_work: + raise ValueError( + "the current plan has completed or produced task artifacts: " + + ", ".join(artifact_tasks) + + ". They remain preserved. If replacement is intentional, call " + "TeamReplan again with replace_completed_work=true" + ) + + current_plan = team.settings.get("team_plan") + current_plan = current_plan if isinstance(current_plan, dict) else {} + checkpoint = { + "checkpoint_id": uuid.uuid4().hex[:12], + "created_at": utc_now(), + "plan_revision": int(current_plan.get("revision") or 0), + "plan_hash": current_plan.get("hash"), + "prior_status": team.status, + "prior_lifecycle_state": lifecycle, + "artifact_tasks": artifact_tasks, + "workspace_preserved": True, + } + quality = dict(quality) + quality["plan_accepted"] = False + quality["validation"] = { + "status": "pending", + "reason": "recoverable replan requested", + } + team.settings["quality_gates"] = quality + team.settings["last_replan_checkpoint"] = checkpoint + if team.status in {"failed", "cancelled"}: + team.transition_to("running") + team.set_lifecycle_state("repair_required") + team.cancel_requested_at = None + team.completed_at = None + self.save_team(team) + self.append_event( + team.team_id, + "team.replan_requested", + { + "reason": reason, + "replace_completed_work": replace_completed_work, + "checkpoint": checkpoint, + }, + ) + return team, checkpoint + + def replace_team_plan( + self, + team_id: str, + *, + tasks: dict[str, dict[str, Any]], + agents: list[AgentRecord], + sessions: dict[str, dict[str, Any]], + settings_updates: dict[str, Any], + plan_record: dict[str, Any], + expected_revision: int | None, + idempotency_key: str | None, + ) -> tuple[Team, bool]: + """Replace a complete materialized plan with optimistic concurrency. + + TeamPlan validates its candidate entirely before calling this method. This + final storage boundary rechecks revision/idempotency under one plan lock and + rolls every materialized file back if any write fails. Readers still use + the established team/tasks/agents/session layout, so older runtimes remain + compatible with v2 plans. + + Returns ``(team, changed)``. ``changed`` is false for an idempotent retry. + """ + + directory = self.team_dir(team_id) + team_path = directory / "team.json" + tasks_path = directory / "tasks.json" + agent_dir = directory / "agents" + session_dir = directory / "sessions" + + with _locked_team_transaction(self._transaction_path(team_id)): + if not team_path.exists(): + raise ValueError("active team state is unavailable") + team = Team.from_dict(self._read_json(team_path)) + if team.lifecycle_state in {"aborted", "budget_exhausted"}: + raise ValueError( + f"a {team.lifecycle_state} protocol v2 team is terminal and " + "cannot accept a new plan" + ) + if team.status == "completed" or team.lifecycle_state == "completed": + raise ValueError( + "a completed protocol v2 team is terminal and cannot accept a new plan" + ) + current_plan = team.settings.get("team_plan") + current_plan = current_plan if isinstance(current_plan, dict) else {} + current_revision = int(current_plan.get("revision") or 0) + current_key = current_plan.get("idempotency_key") + candidate_hash = str(plan_record.get("hash") or "") + + if ( + candidate_hash + and str(current_plan.get("hash") or "") == candidate_hash + and team.lifecycle_state == "repair_required" + ): + raise ValueError( + "repair_required cannot consume TeamReplan with an unchanged plan; " + "submit a materially revised TeamPlan that addresses the reported " + "failure" + ) + if candidate_hash and str(current_plan.get("hash") or "") == candidate_hash: + return team, False + if idempotency_key and current_key == idempotency_key: + if str(current_plan.get("hash") or "") != candidate_hash: + raise ValueError( + "idempotency_key was already used for a different plan" + ) + return team, False + if expected_revision is not None and expected_revision != current_revision: + raise ValueError( + f"expected plan revision {expected_revision}, current revision is " + f"{current_revision}" + ) + + quality = team.settings.get("quality_gates") + quality = quality if isinstance(quality, dict) else {} + current_manifest = team.settings.get("execution_manifest") + current_manifest = ( + current_manifest if isinstance(current_manifest, dict) else {} + ) + if current_revision > 0: + budget_errors = execution_budget_manifest_errors( + current_plan, current_manifest, usage=team.usage + ) + if budget_errors: + raise ValueError( + "active execution budget manifest is invalid: " + + "; ".join(budget_errors) + ) + + current_task_records = ( + self._read_json(tasks_path) if tasks_path.exists() else {} + ) + active_tasks = [ + str(raw.get("key") or task_id) + for task_id, raw in current_task_records.items() + if isinstance(raw, dict) and raw.get("status") == "in_progress" + ] + running_agents = [] + for path in agent_dir.glob("*.json"): + agent = AgentRecord.from_dict(self._read_json(path)) + if agent.status in {"running", "stopping"}: + running_agents.append(agent.name) + if active_tasks or running_agents: + details = [] + if active_tasks: + details.append("active tasks: " + ", ".join(active_tasks)) + if running_agents: + details.append("running teammates: " + ", ".join(running_agents)) + raise ValueError( + "a running plan cannot be replaced (" + "; ".join(details) + ")" + ) + accepted_plan = bool( + current_plan + and ( + quality.get("plan_accepted") + or current_manifest.get("status") == "accepted" + ) + ) + checkpoint = team.settings.get("last_replan_checkpoint") + checkpoint = checkpoint if isinstance(checkpoint, dict) else {} + checkpoint_valid = bool( + team.lifecycle_state == "repair_required" + and checkpoint.get("workspace_preserved") is True + and int(checkpoint.get("plan_revision") or 0) == current_revision + and str(checkpoint.get("plan_hash") or "") + == str(current_plan.get("hash") or "") + and checkpoint.get("consumed_by_revision") is None + ) + if accepted_plan and not checkpoint_valid: + raise ValueError( + "an accepted protocol v2 plan is frozen; call TeamReplan before " + "submitting a replacement TeamPlan" + ) + + next_plan = dict(plan_record) + next_plan["revision"] = current_revision + 1 + next_plan["idempotency_key"] = idempotency_key + next_plan["updated_at"] = utc_now() + carried_forward = ( + _carry_forward_accepted_tasks( + current_tasks=current_task_records, + candidate_tasks=tasks, + current_plan=current_plan, + next_plan=next_plan, + checkpoint=checkpoint, + ) + if checkpoint_valid + else [] + ) + execution = next_plan.get("execution") + execution = dict(execution) if isinstance(execution, dict) else {} + recorded_total = max( + int(team.usage.get("total_tokens", 0) or 0), + int(team.usage.get("input_tokens", 0) or 0) + + int(team.usage.get("output_tokens", 0) or 0), + ) + usage = { + "total_tokens": recorded_total, + "turns": int(team.usage.get("turns", 0) or 0), + } + + def allocated_ceiling(metric: str, budget_key: str) -> int | None: + raw = execution.get(budget_key) + if raw is None: + return None + return usage[metric] + int(raw) + + previous_global = current_manifest.get("global_cap") + previous_global = ( + previous_global if isinstance(previous_global, dict) else {} + ) + + def inherited_global_cap(metric: str, budget_key: str) -> int | None: + if current_revision == 0: + return allocated_ceiling(metric, budget_key) + if metric in previous_global: + raw = previous_global.get(metric) + return int(raw) if raw is not None else None + # Migration for manifests created before global_cap existed. + previous_window = current_manifest.get("budget_window") + if isinstance(previous_window, dict): + hard = previous_window.get("hard_ceiling") + if isinstance(hard, dict) and metric in hard: + raw = hard.get(metric) + return int(raw) if raw is not None else None + return None + + global_cap = { + "total_tokens": inherited_global_cap( + "total_tokens", "token_budget" + ), + "turns": inherited_global_cap("turns", "turn_budget"), + } + + def effective_ceiling(metric: str, budget_key: str) -> int | None: + allocated = allocated_ceiling(metric, budget_key) + global_limit = global_cap[metric] + if allocated is None: + return global_limit + if global_limit is None: + return allocated + return min(allocated, global_limit) + + frozen_at = utc_now() + execution_manifest = { + "schema_version": 2, + "status": "frozen", + "plan_revision": next_plan["revision"], + "plan_hash": candidate_hash, + "execution": execution, + "frozen_at": frozen_at, + # The first plan freezes a rollout-wide absolute cap. Repair + # revisions receive an incremental window from their current usage + # baseline, but that window is always clamped to the inherited cap. + "global_cap": global_cap, + "budget_window": { + "scope": "plan_revision", + "baseline": usage, + "incremental_limit": { + "total_tokens": execution.get("token_budget"), + "turns": execution.get("turn_budget"), + }, + "hard_ceiling": { + "total_tokens": effective_ceiling( + "total_tokens", "token_budget" + ), + "turns": effective_ceiling("turns", "turn_budget"), + }, + }, + } + execution_manifest["budget_integrity_hash"] = _budget_integrity_hash( + plan_hash=candidate_hash, + plan_revision=next_plan["revision"], + execution=execution, + global_cap=global_cap, + budget_window=execution_manifest["budget_window"], + ) + for key in _TEAM_PLAN_EXECUTION_KEYS: + team.settings.pop(key, None) + team.settings.update(settings_updates) + team.settings["team_plan"] = next_plan + team.settings["execution_manifest"] = execution_manifest + team.settings["last_plan_carry_forward"] = { + "from_revision": current_revision, + "plan_revision": next_plan["revision"], + "tasks": carried_forward, + "requires_acceptance": bool(carried_forward), + "recorded_at": utc_now(), + } + if checkpoint_valid: + consumed_checkpoint = dict(checkpoint) + consumed_checkpoint["consumed_by_revision"] = next_plan["revision"] + consumed_checkpoint["consumed_at"] = utc_now() + consumed_checkpoint["carried_forward_task_keys"] = [ + item["key"] for item in carried_forward + ] + team.settings["last_replan_checkpoint"] = consumed_checkpoint + team.settings["protocol_version"] = 2 + # ``protocol_version`` is a first-class Team field in schema v2. The + # setattr keeps this method source-compatible while older serialized + # teams are migrated by Team.from_dict. + team.protocol_version = 2 # type: ignore[attr-defined] + # Replacing a completed/failed plan is the explicit v2 reopen action; + # TeamRun itself never reopens a completed team implicitly. + if team.status in {"failed", "cancelled"}: + team.transition_to("running") + team.set_lifecycle_state("ready") + team.completed_at = None + team.cancel_requested_at = None + team.updated_at = utc_now() + + new_agent_paths = { + agent_dir / f"{agent.agent_id}.json" for agent in agents + } + new_session_paths = { + session_dir / f"{session_id}.json" for session_id in sessions + } + affected_paths = { + team_path, + tasks_path, + self.active_team_path, + *agent_dir.glob("*.json"), + *session_dir.glob("*.json"), + *new_agent_paths, + *new_session_paths, + } + snapshots = { + path: path.read_bytes() if path.exists() else None + for path in affected_paths + } + + try: + agent_dir.mkdir(parents=True, exist_ok=True) + session_dir.mkdir(parents=True, exist_ok=True) + self._write_json_unlocked(team_path, team.to_dict()) + self._write_json_unlocked( + tasks_path, + { + task_id: TeamTask.from_dict(task).to_dict() + for task_id, task in tasks.items() + }, + ) + for path in agent_dir.glob("*.json"): + if path not in new_agent_paths: + path.unlink(missing_ok=True) + for path in session_dir.glob("*.json"): + if path not in new_session_paths: + path.unlink(missing_ok=True) + for agent in agents: + self._write_json_unlocked( + agent_dir / f"{agent.agent_id}.json", agent.to_dict() + ) + for session_id, data in sessions.items(): + self._write_json_unlocked( + session_dir / f"{session_id}.json", data + ) + active = ( + Team.from_dict(self._read_json(self.active_team_path)) + if self.active_team_path.exists() + else None + ) + if active is not None and active.team_id == team_id: + self._write_json_unlocked(self.active_team_path, team.to_dict()) + except Exception: + for path, content in snapshots.items(): + if content is None: + path.unlink(missing_ok=True) + else: + self._write_bytes_unlocked(path, content) + raise + return team, True + + def load_session(self, team_id: str, session_id: str) -> dict[str, Any] | None: + path = self.team_dir(team_id) / "sessions" / f"{session_id}.json" + if not path.exists(): + return None + return self._read_json(path) + + def list_events(self, team_id: str) -> list[dict[str, Any]]: + path = self.team_dir(team_id) / "events.jsonl" + if not path.exists(): + return [] + events: list[dict[str, Any]] = [] + with path.open("r", encoding="utf-8") as handle: + for line in handle: + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(event, dict): + events.append(event) + return events + + def append_event( + self, + team_id: str, + event_type: str, + data: dict[str, Any] | None = None, + *, + created_at: str | None = None, + event_id: str | None = None, + ) -> None: + path = self.team_dir(team_id) / "events.jsonl" + path.parent.mkdir(parents=True, exist_ok=True) + event = { + "event_id": event_id or uuid.uuid4().hex, + "team_id": team_id, + "type": event_type, + "created_at": created_at or utc_now(), + "data": data or {}, + } + # See save_message: event appends must not race a teammate Bash control + # snapshot, otherwise a valid harness event could be reported as tampering. + with _locked_team_transaction(self._transaction_path(team_id)): + with _locked_path(path): + with path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(event, ensure_ascii=False) + "\n") + handle.flush() + os.fsync(handle.fileno()) + + def disband_active_team(self) -> Team | None: + team = self.load_active_team() + if team is None: + return None + cancelled = False + with _locked_team_transaction(self._transaction_path(team.team_id)): + team = self.load_active_team() + if team is None: + return None + if team.status in {"created", "running", "failed"}: + team.transition_to("cancelled") + self._write_json_unlocked( + self.team_dir(team.team_id) / "team.json", team.to_dict() + ) + self._write_json_unlocked(self.active_team_path, team.to_dict()) + cancelled = True + self.active_team_path.unlink(missing_ok=True) + if cancelled: + self.append_event(team.team_id, "team.cancelled") + return team + + def _migrate_legacy_team(self, data: dict[str, Any]) -> dict[str, Any]: + team_name = str(data.get("team_name") or "legacy-team") + seed = f"{self.workspace_root}:{team_name}" + team_id = uuid.uuid5(uuid.NAMESPACE_URL, seed).hex[:12] + lead_agent_id = str(data.get("lead_agent_id") or uuid.uuid5(uuid.NAMESPACE_OID, seed).hex[:12]) + team = Team( + team_id=team_id, + team_name=team_name, + lead_agent_id=lead_agent_id, + description=data.get("description"), + agent_type=data.get("agent_type"), + ) + directory = self.team_dir(team_id) + for name in ("agents", "sessions", "messages"): + (directory / name).mkdir(parents=True, exist_ok=True) + self._write_json(directory / "team.json", team.to_dict()) + if not (directory / "tasks.json").exists(): + self._write_json(directory / "tasks.json", {}) + (directory / "events.jsonl").touch(exist_ok=True) + self._write_json(self.active_team_path, team.to_dict()) + return team.to_dict() + + @staticmethod + def _read_json(path: Path) -> dict[str, Any]: + with path.open("r", encoding="utf-8") as handle: + data = json.load(handle) + if not isinstance(data, dict): + raise ValueError(f"expected a JSON object in {path}") + return data + + @staticmethod + def _write_json(path: Path, data: dict[str, Any]) -> None: + with _locked_path(path): + TeamStore._write_json_unlocked(path, data) + + @staticmethod + def _write_json_unlocked(path: Path, data: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + payload = (json.dumps(data, ensure_ascii=False, indent=2) + "\n").encode( + "utf-8" + ) + TeamStore._write_bytes_unlocked(path, payload) + + @staticmethod + def _write_bytes_unlocked(path: Path, data: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temp_path = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") + try: + with temp_path.open("wb") as handle: + handle.write(data) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temp_path, path) + finally: + temp_path.unlink(missing_ok=True) diff --git a/src/teammate/trace.py b/src/teammate/trace.py new file mode 100644 index 0000000..25c05bc --- /dev/null +++ b/src/teammate/trace.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +import json +import re +from typing import Any + + +_EVENT_TYPES = { + "run_started": "run.started", + "run_completed": "run.completed", + "run_failed": "run.failed", + "run_cancelled": "run.cancelled", + "model_started": "model.started", + "model_response": "model.response", + "model_error": "model.failed", + "tool_use": "tool.started", + "tool_result": "tool.completed", + "tool_error": "tool.failed", +} +_SENSITIVE_KEYS = { + "api_key", + "apikey", + "authorization", + "auth_token", + "access_token", + "refresh_token", + "password", + "passwd", + "secret", + "credential", + "cookie", + "set_cookie", +} +_ASSIGNMENT_RE = re.compile( + r"(?i)\b([a-z0-9_-]*(?:api[_-]?key|auth(?:orization)?[_-]?token|access[_-]?token|" + r"refresh[_-]?token|password|secret))\b(\s*[:=]\s*)([^\s'\"\\]+)" +) +_BEARER_RE = re.compile(r"(?i)\b(bearer\s+)[A-Za-z0-9._~+/=-]+") + + +def redact_trace_value(value: Any) -> Any: + """Return a JSON-safe copy with common credential fields redacted.""" + if isinstance(value, dict): + redacted: dict[str, Any] = {} + for key, item in value.items(): + normalized = str(key).lower().replace("-", "_") + if normalized in _SENSITIVE_KEYS or ( + normalized.endswith("_token") and not normalized.endswith("_tokens") + ): + redacted[str(key)] = "[REDACTED]" + else: + redacted[str(key)] = redact_trace_value(item) + return redacted + if isinstance(value, (list, tuple, set)): + return [redact_trace_value(item) for item in value] + if isinstance(value, str): + value = _ASSIGNMENT_RE.sub(lambda match: f"{match.group(1)}{match.group(2)}[REDACTED]", value) + return _BEARER_RE.sub(lambda match: f"{match.group(1)}[REDACTED]", value) + if value is None or isinstance(value, (bool, int, float)): + return value + return str(value) + + +class TeamTraceRecorder: + """Attach agent-loop events to the team created or active during a run.""" + + def __init__(self, tool_context: Any): + self.context = tool_context + self.team_id: str | None = None + self.pending: list[dict[str, Any]] = [] + self._bind_existing_team() + + def _bind_existing_team(self) -> None: + try: + team = self.context.team_store.load_active_team() + except Exception: + return + if team is None: + return + is_child_run = bool(self.context.actor_id or self.context.current_task_id) + if is_child_run or team.status in {"created", "running", "failed"}: + self.team_id = team.team_id + + def _try_bind_new_team(self) -> None: + if self.team_id is not None: + return + try: + team = self.context.team_store.load_active_team() + except Exception: + return + if team is not None and team.status in {"created", "running", "failed"}: + self.team_id = team.team_id + + def record(self, event: Any) -> None: + try: + packed = self._pack(event) + self._try_bind_new_team() + if self.team_id is None: + self.pending.append(packed) + return + queued = [*self.pending, packed] + self.pending.clear() + for item in queued: + self.context.team_store.append_event( + self.team_id, + item["type"], + item["data"], + created_at=item["created_at"], + ) + except Exception: + # Tracing must never make the underlying agent run fail. + return + + def _pack(self, event: Any) -> dict[str, Any]: + event_type = _EVENT_TYPES.get(event.kind, str(event.kind).replace("_", ".")) + if event.kind == "tool_result" and event.is_error: + event_type = "tool.failed" + actor_id, actor_name = self._actor_identity() + data: dict[str, Any] = { + "actor_id": actor_id, + "actor_name": actor_name, + "task_id": self.context.current_task_id, + } + for source, target in ( + ("turn", "turn"), + ("model", "model"), + ("finish_reason", "finish_reason"), + ("content", "content"), + ("usage", "usage"), + ("tool_name", "tool_name"), + ("tool_input", "tool_input"), + ("tool_output", "tool_output"), + ("tool_use_id", "tool_use_id"), + ("duration_ms", "duration_ms"), + ("error", "error"), + ): + value = getattr(event, source, None) + if value is not None: + data[target] = value + if getattr(event, "is_error", False): + data["is_error"] = True + safe = redact_trace_value(data) + return { + "type": event_type, + "created_at": event.created_at, + "data": json.loads(json.dumps(safe, ensure_ascii=False, default=str)), + } + + def _actor_identity(self) -> tuple[str | None, str | None]: + try: + team = self.context.team_store.load_team(self.team_id) if self.team_id else None + if team is None: + team = self.context.team_store.load_active_team() + if team is None: + return self.context.actor_id, None + actor_id = self.context.actor_id or team.lead_agent_id + if actor_id == team.lead_agent_id: + return actor_id, "lead" + agent = self.context.team_store.load_agent(team.team_id, actor_id) + return actor_id, agent.name if agent is not None else actor_id + except Exception: + return self.context.actor_id, None diff --git a/src/teammate/trace_viewer.html b/src/teammate/trace_viewer.html new file mode 100644 index 0000000..4ea9e9a --- /dev/null +++ b/src/teammate/trace_viewer.html @@ -0,0 +1,1010 @@ + + + + + + Clawd Teammate Trace + + + +
+
+
Clawd Teammate Trace
+
Loading workspace...
+
+ +
+ + CONNECTING +
+
+ +
+
+ +
+
No team selected
+
Waiting for teammate state
+
+
+
Events
0
+
Tool Calls
0
+
Messages
0
+
Tokens
0
+
Duration
0s
+
+ +
+ + +
+
+
+ + +
+ + + + +
+
+ Historical run: tool events were reconstructed from persisted teammate sessions. New runs stream native timing and usage events. +
+
+
+
+
+
+
+
+
+
+ + +
+ + + + diff --git a/src/teammate/viewer.py b/src/teammate/viewer.py new file mode 100644 index 0000000..04fbf10 --- /dev/null +++ b/src/teammate/viewer.py @@ -0,0 +1,462 @@ +from __future__ import annotations + +import json +import sys +import time +import urllib.parse +import webbrowser +from datetime import datetime +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any + +from .store import TeamStore +from .trace import redact_trace_value + + +_TRACE_PREFIXES = ("run.", "model.", "tool.") + + +def _read_json(path: Path) -> dict[str, Any]: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + return data if isinstance(data, dict) else {} + + +def list_team_summaries(workspace_root: str | Path) -> list[dict[str, Any]]: + store = TeamStore(Path(workspace_root)) + summaries: list[dict[str, Any]] = [] + if not store.teams_dir.exists(): + return summaries + for directory in store.teams_dir.iterdir(): + if not directory.is_dir(): + continue + team = _read_json(directory / "team.json") + if not team.get("team_id"): + continue + summaries.append({ + "team_id": team.get("team_id"), + "team_name": team.get("team_name") or team.get("team_id"), + "status": team.get("status") or "unknown", + "created_at": team.get("created_at"), + "updated_at": team.get("updated_at"), + }) + summaries.sort(key=lambda team: str(team.get("updated_at") or team.get("created_at") or ""), reverse=True) + return summaries + + +def _select_team_id(store: TeamStore, requested_team_id: str | None) -> str | None: + teams = list_team_summaries(store.workspace_root) + known_ids = {str(team["team_id"]) for team in teams} + if requested_team_id: + if requested_team_id not in known_ids: + raise ValueError(f"unknown team: {requested_team_id}") + return requested_team_id + active = store.load_active_team() + if active is not None and active.team_id in known_ids: + return active.team_id + return str(teams[0]["team_id"]) if teams else None + + +def _parse_timestamp(value: Any) -> float: + if not isinstance(value, str) or not value: + return 0.0 + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() + except ValueError: + return 0.0 + + +def _task_for_agent(tasks: dict[str, Any], agent_id: str) -> str | None: + for task_id, task in tasks.items(): + if isinstance(task, dict) and task.get("owner") == agent_id: + return task_id + return None + + +def _decode_tool_output(content: Any) -> Any: + if not isinstance(content, str): + return content + try: + return json.loads(content) + except json.JSONDecodeError: + return content + + +def _reconstruct_session_events( + store: TeamStore, + team_id: str, + tasks: dict[str, Any], +) -> list[dict[str, Any]]: + events: list[dict[str, Any]] = [] + directory = store.team_dir(team_id) / "sessions" + if not directory.exists(): + return events + agents = {agent.agent_id: agent for agent in store.list_agents(team_id)} + for path in sorted(directory.glob("*.json")): + session = _read_json(path) + agent_id = str(session.get("agent_id") or "") + if not agent_id: + continue + agent = agents.get(agent_id) + actor_name = agent.name if agent is not None else agent_id + task_id = _task_for_agent(tasks, agent_id) + conversation = session.get("conversation") + messages = conversation.get("messages") if isinstance(conversation, dict) else [] + if not isinstance(messages, list): + continue + turn = 0 + for message_index, message in enumerate(messages): + if not isinstance(message, dict): + continue + role = message.get("role") + content = message.get("content") + created_at = str(message.get("timestamp") or session.get("updated_at") or "") + if role == "assistant": + turn += 1 + text_parts: list[str] = [] + blocks = content if isinstance(content, list) else [] + if isinstance(content, str) and content: + text_parts.append(content) + for block in blocks: + if isinstance(block, dict) and block.get("type") == "text" and block.get("text"): + text_parts.append(str(block["text"])) + if text_parts or blocks: + events.append(_session_event( + team_id, + path.stem, + message_index, + 0, + "model.response", + created_at, + { + "actor_id": agent_id, + "actor_name": actor_name, + "task_id": task_id, + "turn": turn, + "model": session.get("model"), + "content": "\n".join(text_parts), + "source": "session-reconstruction", + }, + )) + for block_index, block in enumerate(blocks, start=1): + if not isinstance(block, dict) or block.get("type") != "tool_use": + continue + events.append(_session_event( + team_id, + path.stem, + message_index, + block_index, + "tool.started", + created_at, + { + "actor_id": agent_id, + "actor_name": actor_name, + "task_id": task_id, + "turn": turn, + "tool_name": block.get("name"), + "tool_input": block.get("input") or {}, + "tool_use_id": block.get("id"), + "source": "session-reconstruction", + }, + )) + elif role == "user" and isinstance(content, list): + for block_index, block in enumerate(content): + if not isinstance(block, dict) or block.get("type") != "tool_result": + continue + is_error = bool(block.get("is_error")) + events.append(_session_event( + team_id, + path.stem, + message_index, + block_index, + "tool.failed" if is_error else "tool.completed", + created_at, + { + "actor_id": agent_id, + "actor_name": actor_name, + "task_id": task_id, + "tool_output": _decode_tool_output(block.get("content")), + "tool_use_id": block.get("tool_use_id"), + "is_error": is_error, + "source": "session-reconstruction", + }, + )) + return events + + +def _session_event( + team_id: str, + session_id: str, + message_index: int, + block_index: int, + event_type: str, + created_at: str, + data: dict[str, Any], +) -> dict[str, Any]: + return { + "event_id": f"session-{session_id}-{message_index:04d}-{block_index:03d}", + "team_id": team_id, + "type": event_type, + "created_at": created_at, + "data": redact_trace_value(data), + "reconstructed": True, + } + + +def _event_actor_id(event: dict[str, Any], lead_id: str) -> str: + data = event.get("data") if isinstance(event.get("data"), dict) else {} + if data.get("actor_id"): + return str(data["actor_id"]) + if data.get("agent_id"): + return str(data["agent_id"]) + agent = data.get("agent") + if isinstance(agent, dict) and agent.get("agent_id"): + return str(agent["agent_id"]) + message = data.get("message") + if isinstance(message, dict) and message.get("sender_id"): + return str(message["sender_id"]) + task = data.get("task") + if isinstance(task, dict) and task.get("owner"): + return str(task["owner"]) + return lead_id + + +def build_trace_snapshot( + workspace_root: str | Path, + team_id: str | None = None, +) -> dict[str, Any]: + store = TeamStore(Path(workspace_root)) + selected_id = _select_team_id(store, team_id) + teams = list_team_summaries(store.workspace_root) + if selected_id is None: + return { + "workspace": str(store.workspace_root), + "teams": teams, + "team": None, + "agents": [], + "tasks": [], + "messages": [], + "events": [], + "stats": {"event_count": 0, "tool_count": 0, "message_count": 0, "error_count": 0}, + "historical_reconstruction": False, + } + + team = store.load_team(selected_id) + if team is None: + raise ValueError(f"team state is missing: {selected_id}") + tasks_by_id = store.load_tasks(selected_id) + agents = [agent.to_dict() for agent in store.list_agents(selected_id)] + agents.insert(0, { + "agent_id": team.lead_agent_id, + "team_id": team.team_id, + "name": "lead", + "role": "lead", + "session_id": None, + "model": None, + "instructions": "", + "tools": [], + "status": team.status, + "created_at": team.created_at, + "updated_at": team.updated_at, + "schema_version": 1, + }) + names = {str(agent["agent_id"]): str(agent.get("name") or agent["agent_id"]) for agent in agents} + messages = [] + for message in store.list_messages(selected_id): + item = message.to_dict() + item["sender_name"] = names.get(message.sender_id, message.sender_id) + item["recipient_name"] = names.get(message.recipient_id, message.recipient_id) + messages.append(redact_trace_value(item)) + + persisted_events = store.list_events(selected_id) + has_native_trace = any(str(event.get("type") or "").startswith(_TRACE_PREFIXES) for event in persisted_events) + events = list(persisted_events) + if not has_native_trace: + events.extend(_reconstruct_session_events(store, selected_id, tasks_by_id)) + for event in events: + event["actor_id"] = _event_actor_id(event, team.lead_agent_id) + event["actor_name"] = names.get(event["actor_id"], event["actor_id"]) + events.sort(key=lambda event: ( + _parse_timestamp(event.get("created_at")), + 0 if event.get("type") == "model.response" else 1, + str(event.get("event_id") or ""), + )) + for sequence, event in enumerate(events): + event["sequence"] = sequence + + model_events = [event for event in events if event.get("type") == "model.response"] + input_tokens = sum(int((event.get("data") or {}).get("usage", {}).get("input_tokens", 0)) for event in model_events) + output_tokens = sum(int((event.get("data") or {}).get("usage", {}).get("output_tokens", 0)) for event in model_events) + first_time = _parse_timestamp(events[0].get("created_at")) if events else 0 + last_time = _parse_timestamp(events[-1].get("created_at")) if events else 0 + safe_events = redact_trace_value(events) + return { + "workspace": str(store.workspace_root), + "teams": teams, + "team": redact_trace_value(team.to_dict()), + "agents": redact_trace_value(agents), + "tasks": redact_trace_value(list(tasks_by_id.values())), + "messages": messages, + "events": safe_events, + "stats": { + "event_count": len(events), + "tool_count": sum(1 for event in events if event.get("type") == "tool.started"), + "message_count": len(messages), + "error_count": sum(1 for event in events if event.get("type") in {"tool.failed", "model.failed", "task.failed", "team.failed", "run.failed"}), + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "duration_ms": max(0, round((last_time - first_time) * 1000)) if first_time and last_time else 0, + }, + "historical_reconstruction": not has_native_trace, + } + + +def _team_fingerprint(workspace_root: Path, team_id: str | None) -> tuple[int, int]: + if not team_id: + root = workspace_root / ".clawd" + else: + root = workspace_root / ".clawd" / "teams" / team_id + total_size = 0 + latest_ns = 0 + if root.exists(): + for path in root.rglob("*"): + if not path.is_file(): + continue + try: + stat = path.stat() + except OSError: + continue + total_size += stat.st_size + latest_ns = max(latest_ns, stat.st_mtime_ns) + return latest_ns, total_size + + +class TraceViewerServer(ThreadingHTTPServer): + daemon_threads = True + allow_reuse_address = True + + def __init__( + self, + address: tuple[str, int], + workspace_root: Path, + team_id: str | None = None, + ): + self.workspace_root = workspace_root.resolve() + self.default_team_id = team_id + super().__init__(address, TraceViewerHandler) + + def handle_error(self, request: Any, client_address: Any) -> None: + error = sys.exc_info()[1] + if isinstance(error, (BrokenPipeError, ConnectionResetError)): + return + super().handle_error(request, client_address) + + +class TraceViewerHandler(BaseHTTPRequestHandler): + server: TraceViewerServer + + def do_GET(self) -> None: + parsed = urllib.parse.urlparse(self.path) + query = urllib.parse.parse_qs(parsed.query) + team_id = query.get("team", [self.server.default_team_id])[0] + if parsed.path == "/": + self._send_html() + return + if parsed.path == "/api/state": + try: + snapshot = build_trace_snapshot(self.server.workspace_root, team_id) + except ValueError as exc: + self._send_json({"error": str(exc)}, HTTPStatus.NOT_FOUND) + return + self._send_json(snapshot) + return + if parsed.path == "/api/stream": + self._stream_changes(team_id) + return + self._send_json({"error": "not found"}, HTTPStatus.NOT_FOUND) + + def _send_html(self) -> None: + path = Path(__file__).with_name("trace_viewer.html") + try: + body = path.read_bytes() + except OSError: + self._send_json({"error": "trace viewer asset is missing"}, HTTPStatus.INTERNAL_SERVER_ERROR) + return + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def _send_json(self, payload: Any, status: HTTPStatus = HTTPStatus.OK) -> None: + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def _stream_changes(self, team_id: str | None) -> None: + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.send_header("Connection", "keep-alive") + self.end_headers() + last = None + try: + for tick in range(900): + current = _team_fingerprint(self.server.workspace_root, team_id) + if current != last: + payload = json.dumps({"type": "refresh", "fingerprint": current}) + self.wfile.write(f"data: {payload}\n\n".encode("utf-8")) + self.wfile.flush() + last = current + elif tick % 15 == 0: + self.wfile.write(b": heartbeat\n\n") + self.wfile.flush() + time.sleep(1) + except (BrokenPipeError, ConnectionResetError): + return + + def log_message(self, format: str, *args: Any) -> None: + return + + +def create_trace_server( + workspace_root: str | Path, + *, + host: str = "127.0.0.1", + port: int = 8765, + team_id: str | None = None, +) -> TraceViewerServer: + return TraceViewerServer((host, port), Path(workspace_root), team_id) + + +def serve_trace_viewer( + workspace_root: str | Path, + *, + host: str = "127.0.0.1", + port: int = 8765, + team_id: str | None = None, + open_browser: bool = False, +) -> int: + server = create_trace_server(workspace_root, host=host, port=port, team_id=team_id) + actual_port = server.server_address[1] + url = f"http://{host}:{actual_port}" + print(f"Teammate Trace Viewer: {url}") + print(f"Workspace: {server.workspace_root}") + if open_browser: + webbrowser.open(url) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + return 0 diff --git a/src/teammate/worktree.py b/src/teammate/worktree.py new file mode 100644 index 0000000..8b9e3bd --- /dev/null +++ b/src/teammate/worktree.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import re +import subprocess +import threading +from pathlib import Path + +from .models import AgentRecord, TeamTask + + +_INTEGRATION_LOCKS: dict[str, threading.Lock] = {} +_INTEGRATION_LOCKS_GUARD = threading.Lock() + + +def _integration_lock(repo_root: Path) -> threading.Lock: + key = str(repo_root.resolve()) + with _INTEGRATION_LOCKS_GUARD: + return _INTEGRATION_LOCKS.setdefault(key, threading.Lock()) + + +class TeammateWorktreeManager: + """Create isolated git worktrees and integrate their commits safely.""" + + def __init__(self, workspace_root: str | Path): + self.workspace_root = Path(workspace_root).resolve() + self.repo_root = self._repo_root() + + def _repo_root(self) -> Path: + completed = self._git( + ["rev-parse", "--show-toplevel"], cwd=self.workspace_root, check=False + ) + if completed.returncode != 0: + raise ValueError("worktree isolation requires a git repository") + root = Path(completed.stdout.strip()).resolve() + if root != self.workspace_root: + raise ValueError("workspace root must be the git repository root for worktree isolation") + return root + + def create(self, team_id: str, agent_id: str, name: str) -> Path: + safe_name = re.sub(r"[^a-zA-Z0-9._-]+", "-", name).strip("-") or "agent" + directory = ( + self.repo_root.parent + / ".clawd-worktrees" + / f"{self.repo_root.name}-{team_id}-{safe_name}-{agent_id}" + ) + if (directory / ".git").exists(): + return directory.resolve() + directory.parent.mkdir(parents=True, exist_ok=True) + completed = self._git( + ["worktree", "add", "--detach", str(directory), "HEAD"], + cwd=self.repo_root, + check=False, + ) + if completed.returncode != 0: + raise RuntimeError(completed.stderr.strip() or "failed to create git worktree") + return directory.resolve() + + def integrate(self, agent: AgentRecord, task: TeamTask | None = None) -> dict[str, str | bool | None]: + if agent.workspace_mode != "worktree" or not agent.workspace_path: + raise ValueError("teammate does not use worktree isolation") + worktree = Path(agent.workspace_path).resolve() + if not worktree.is_dir(): + raise ValueError(f"teammate worktree does not exist: {worktree}") + + with _integration_lock(self.repo_root): + self._git(["add", "-A"], cwd=worktree) + changed = self._git(["diff", "--cached", "--quiet"], cwd=worktree, check=False) + if changed.returncode == 0: + return {"integrated": False, "commit": None, "reason": "no changes"} + if changed.returncode != 1: + raise RuntimeError(changed.stderr.strip() or "failed to inspect worktree changes") + + label = task.key or task.id if task is not None else "manual" + message = f"clawd teammate {agent.name}: {label}" + committed = self._git( + [ + "-c", + "user.name=Clawd Teammate", + "-c", + "user.email=clawd-teammate@local", + "commit", + "-m", + message, + ], + cwd=worktree, + check=False, + ) + if committed.returncode != 0: + raise RuntimeError(committed.stderr.strip() or "failed to commit worktree changes") + commit = self._git(["rev-parse", "HEAD"], cwd=worktree).stdout.strip() + integrated = self._git( + [ + "-c", + "user.name=Clawd Teammate", + "-c", + "user.email=clawd-teammate@local", + "cherry-pick", + commit, + ], + cwd=self.repo_root, + check=False, + ) + if integrated.returncode != 0: + self._git(["cherry-pick", "--abort"], cwd=self.repo_root, check=False) + raise RuntimeError( + integrated.stderr.strip() or "failed to integrate teammate worktree" + ) + return {"integrated": True, "commit": commit, "reason": None} + + def remove(self, agent: AgentRecord, *, force: bool = False) -> None: + if agent.workspace_mode != "worktree" or not agent.workspace_path: + return + args = ["worktree", "remove"] + if force: + args.append("--force") + args.append(agent.workspace_path) + completed = self._git(args, cwd=self.repo_root, check=False) + if completed.returncode != 0 and Path(agent.workspace_path).exists(): + raise RuntimeError(completed.stderr.strip() or "failed to remove teammate worktree") + + @staticmethod + def _git( + args: list[str], *, cwd: Path, check: bool = True + ) -> subprocess.CompletedProcess[str]: + completed = subprocess.run( + ["git", *args], + cwd=str(cwd), + capture_output=True, + text=True, + ) + if check and completed.returncode != 0: + raise RuntimeError(completed.stderr.strip() or f"git {' '.join(args)} failed") + return completed diff --git a/src/tool_system/agent_loop.py b/src/tool_system/agent_loop.py index b1ecf9d..2390b75 100644 --- a/src/tool_system/agent_loop.py +++ b/src/tool_system/agent_loop.py @@ -3,25 +3,87 @@ from __future__ import annotations import json +import time from dataclasses import dataclass +from datetime import datetime, timezone from typing import Any, Callable from .registry import ToolRegistry from .context import ToolContext -from ..agent.conversation import Conversation, TextContentBlock, ToolUseContentBlock +from ..agent.conversation import ( + Conversation, + TextContentBlock, + ToolResultContentBlock, + ToolUseContentBlock, +) from ..context_system import build_context_prompt from ..outputStyles import resolve_output_style from ..providers.base import BaseProvider, ChatResponse from ..providers.anthropic_provider import AnthropicProvider from ..providers.minimax_provider import MinimaxProvider +from ..teammate.trace import TeamTraceRecorder +from ..peer.trace import PeerTraceRecorder + + +_LOCAL_TOOL_GUIDANCE = """## Local Engineering Tools +- Local workspace files are accessed with `Read`, `Glob`, and `Grep`. +- Use `Write` to create files and `Edit` for exact replacements in files you have read. +- Use `Bash` to run local commands and tests. For Python, prefer `$CLAWD_PYTHON`; it points to the interpreter running Clawd. +- Use web tools only for HTTP or HTTPS resources. Never send local paths or `file://` URLs to `webReader`, `WebFetch`, or other web tools. +- These local tools are registered even if a provider also offers built-in web tools. If unsure, call `ToolSearch` with focused keywords or query `*`; do not conclude that a tool is unavailable after one empty search.""" + +_LEADER_TEAM_GUIDANCE = """## Adaptive Team Orchestration +- You are the lead and remain responsible for the final result. First decide whether delegation is likely to improve quality, latency, or context coverage enough to justify its cost. It is valid to complete the task without creating a team. +- For a strict quality-gated team, submit the complete contract, workers, tasks, ownership, acceptance, validation, and execution settings in one atomic TeamPlan, then call TeamRun. TeamRun owns task acceptance and final verification. Do not assemble protocol v2 incrementally with TeamConfigure, TeammateCreate, TaskCreate, or TeamVerify. +- When a team is useful, choose the number of teammates, their roles, tool allowlists, workspace modes, tasks, dependencies, and concurrency from the task itself. Omit a teammate model override unless the runtime explicitly lists it as supported; otherwise inherit the lead model. Do not default to a fixed planner/coder/reviewer pipeline. +- Teammates may communicate directly with one another through `SendMessage` and receive new peer messages through `ReadMessages`; useful peer coordination does not need to be routed through the lead. +- Use stable task keys and explicit ownership. Prefer dependencies only when work truly must be sequential, and allow independent tasks to run in parallel. +- Include persistent project test files in the owning task's owned_files. For behavioral acceptance without a deliverable test file, prefer an inline command; teammates receive a private scratch-test path for additional disposable checks. +- Observe persisted task, message, and agent state. You may run a bounded number of scheduling batches, then add or reassign tasks, adjust dependencies, stop or resume workers, recover failures, and continue. +- Treat teammate output as evidence, not authority. Integrate the work, run final verification yourself, and stop unnecessary work when the expected value of more collaboration is low. +- The resulting communication topology is an execution outcome, not a prescribed shape.""" + +_EMPTY_RESPONSE_RETRY_LIMIT = 3 +_EMPTY_RESPONSE_CORRECTION = ( + "Your previous response contained neither visible content nor a tool call. " + "Continue the task instead of stopping silently. If work remains, call the " + "appropriate tools now; if the task is genuinely complete, provide a non-empty " + "final response describing the completed work." +) def _is_anthropic_provider(provider: BaseProvider) -> bool: return isinstance(provider, (AnthropicProvider, MinimaxProvider)) -def _build_openai_tool_result_content(result_output: Any) -> str: - """Format tool result as string for OpenAI/GLM.""" +def _truncate_provider_text(value: Any, limit: int = 8_000) -> Any: + if not isinstance(value, str) or len(value) <= limit: + return value + half = limit // 2 + return f"{value[:half]}\n\n... [truncated for model context] ...\n\n{value[-half:]}" + + +def _build_tool_result_content(tool_name: str, result_output: Any) -> str: + """Serialize structured tool output without echoing large redundant payloads.""" + lowered = tool_name.lower() + if ( + isinstance(result_output, dict) + and lowered in {"write", "edit"} + and ("filePath" in result_output or "file_path" in result_output) + ): + keys = ( + ("type", "filePath") + if lowered == "write" + else ("filePath", "replaceAll", "userModified") + ) + compact = {key: result_output[key] for key in keys if key in result_output} + compact["success"] = True + return json.dumps(compact, ensure_ascii=False) + if isinstance(result_output, dict) and lowered == "bash": + compact = dict(result_output) + compact["stdout"] = _truncate_provider_text(compact.get("stdout")) + compact["stderr"] = _truncate_provider_text(compact.get("stderr")) + return json.dumps(compact, ensure_ascii=False) if isinstance(result_output, str): return result_output return json.dumps(result_output, ensure_ascii=False) @@ -92,12 +154,23 @@ def summarize_tool_result(name: str, output: Any) -> str: @dataclass(frozen=True) class ToolEvent: kind: str - tool_name: str + tool_name: str | None = None tool_input: dict[str, Any] | None = None tool_output: Any | None = None tool_use_id: str | None = None is_error: bool = False error: str | None = None + content: str | None = None + model: str | None = None + usage: dict[str, Any] | None = None + finish_reason: str | None = None + turn: int | None = None + duration_ms: int | None = None + created_at: str = "" + + def __post_init__(self) -> None: + if not self.created_at: + object.__setattr__(self, "created_at", datetime.now(timezone.utc).isoformat()) @dataclass(frozen=True) @@ -106,10 +179,14 @@ class AgentLoopResult: response_text: str usage: dict[str, Any] | None = None # {"input_tokens": int, "output_tokens": int} num_turns: int = 0 + cancelled: bool = False + failed: bool = False + failure_reason: str | None = None ToolEventHandler = Callable[[ToolEvent], None] TextChunkHandler = Callable[[str], None] +StopPredicate = Callable[[], bool] def _safe_call_handler(handler: ToolEventHandler | None, event: ToolEvent) -> None: @@ -121,6 +198,15 @@ def _safe_call_handler(handler: ToolEventHandler | None, event: ToolEvent) -> No return +def _safe_should_stop(predicate: StopPredicate | None) -> bool: + if predicate is None: + return False + try: + return bool(predicate()) + except Exception: + return False + + def _emit_text_chunks(handler: TextChunkHandler | None, text: str, *, chunk_size: int = 12) -> None: """Emit text in small chunks for user-visible streaming without changing loop semantics.""" if handler is None or not text: @@ -174,9 +260,145 @@ def _build_effective_system_prompt(style_prompt: str, tool_context: ToolContext) ) except Exception: context_prompt = "" - if not context_prompt.strip(): - return style_prompt - return f"{style_prompt}\n\n{context_prompt}" + sections = [style_prompt, _LOCAL_TOOL_GUIDANCE] + if tool_context.actor_id is None: + sections.append(_LEADER_TEAM_GUIDANCE) + if tool_context.workspace_backend is not None: + execution_root = tool_context.execution_workspace_root or "/workspace" + execution_cwd = tool_context.execution_cwd or execution_root + sections.append( + "## Sandboxed Execution Workspace\n" + f"Bash and file tools execute inside a remote sandbox. Its workspace root is " + f"`{execution_root}` and the current directory is `{execution_cwd}`. " + "Use these paths for repository work. The host workspace shown in environment " + "context contains control metadata and maps transparently to this remote workspace." + ) + if tool_context.system_prompt_extra and tool_context.system_prompt_extra.strip(): + sections.append(tool_context.system_prompt_extra.strip()) + if context_prompt.strip(): + sections.append(context_prompt) + return "\n\n".join(sections) + + +def _team_lifecycle_warning(tool_context: ToolContext) -> str | None: + """Return a corrective prompt when a lead tries to finish with unsettled team state.""" + if tool_context.actor_id is not None: + return None + try: + tool_context.reload_team_state() + except Exception: + return None + team = tool_context.team + if team is None: + return None + + team_id = str(team.get("team_id") or "") + status = str(team.get("status") or "created") + agents = tool_context.team_store.list_agents(team_id) if team_id else [] + tasks = list(tool_context.tasks.values()) + task_counts = { + name: sum(task.get("status") == name for task in tasks) + for name in ("pending", "in_progress", "completed", "failed", "cancelled") + } + quality = dict((team.get("settings") or {}).get("quality_gates") or {}) + strict = bool(quality.get("strict")) + versions = [1] + for raw_version in ( + team.get("protocol_version"), + (team.get("settings") or {}).get("protocol_version"), + quality.get("protocol_version"), + ): + try: + versions.append(int(raw_version)) + except (TypeError, ValueError): + continue + protocol_version = max(versions) + lifecycle_state = str(team.get("lifecycle_state") or status) + validation_status = (quality.get("validation") or {}).get("status") + + if protocol_version >= 2 and lifecycle_state == "completed": + v2_tasks_settled = bool(tasks) and all( + task.get("status") == "completed" + and task.get("lifecycle_state") == "accepted" + for task in tasks + ) + if v2_tasks_settled and validation_status == "passed": + return None + if ( + protocol_version < 2 + and + team.get("status") == "completed" + and tasks + and task_counts["completed"] == len(tasks) + and (not strict or validation_status == "passed") + ): + return None + + if protocol_version >= 2 and lifecycle_state == "completed": + action = ( + "The completed state has drifted from its accepted tasks or validation. " + "Do not reopen or replace this terminal team; preserve its workspace and " + "report the lifecycle inconsistency as a harness failure." + ) + elif protocol_version >= 2 and lifecycle_state == "draft": + action = "Submit one atomic TeamPlan before attempting execution." + elif protocol_version >= 2 and lifecycle_state == "ready": + action = "Call TeamRun to execute the accepted atomic plan." + elif protocol_version >= 2 and lifecycle_state == "repair_required": + action = ( + "Stop any active workers, call TeamReplan to checkpoint the current " + "workspace, submit one complete replacement TeamPlan, then call TeamRun." + ) + elif protocol_version >= 2 and lifecycle_state == "budget_exhausted": + action = ( + "This rollout is terminal because its frozen execution budget was exhausted. " + "Preserve the workspace for scoring; TeamResume and TeamReplan cannot add budget." + ) + elif protocol_version >= 2 and lifecycle_state == "paused": + action = "Call TeamRun to continue the persisted protocol v2 lifecycle." + elif protocol_version >= 2 and lifecycle_state in { + "awaiting_verification", + "verifying", + }: + action = "Call TeamRun; protocol v2 will run or resume verification automatically." + elif not agents: + action = "Call TeammateCreate, create an owned task with TaskCreate, then call TeamRun." + elif not tasks: + action = "Call TaskCreate with a teammate owner, then call TeamRun. TeammateCreate did not start the worker." + elif status in {"failed", "cancelled"} or task_counts["failed"] or task_counts["cancelled"]: + action = ( + "Inspect the failed state, call TeamReplan to preserve the current workspace, " + "submit one complete repair TeamPlan revision, and call TeamRun." + if protocol_version >= 2 + else "Inspect the failed task, then call TeamResume (retry_failed=true) or explicitly abandon it with TeamDelete." + ) + elif task_counts["pending"] or task_counts["in_progress"]: + action = "Call TeamRun to execute or continue the pending teammate tasks." + elif strict and task_counts["completed"] == len(tasks) and validation_status != "passed": + action = ( + "Call TeamVerify now. Strict clean-install, import, and integration validation " + "must pass before the team can complete." + ) + else: + action = "Call TeamRun once more so the runtime records the completed team state." + + counts = ", ".join(f"{name}={count}" for name, count in task_counts.items() if count) + summary = ( + f"status={status}, lifecycle={lifecycle_state}, " + f"agents={len(agents)}, tasks={len(tasks)}" + ) + if counts: + summary += f" ({counts})" + ending = ( + "TeamDelete cannot bypass a strict protocol v2 lifecycle; use TeamAbort only " + "when you intend to report an explicit failed/aborted outcome." + if strict and protocol_version >= 2 + else "If the team is no longer needed, call TeamDelete before finishing." + ) + return ( + f"Team lifecycle guard: active team `{team.get('team_name') or team_id}` is not settled " + f"({summary}). Do not provide the final answer yet. {action} {ending}" + ) def summarize_tool_use(name: str, tool_input: dict[str, Any]) -> str: @@ -243,10 +465,12 @@ def run_agent_loop( tool_registry: ToolRegistry, tool_context: ToolContext, max_turns: int = 20, + max_output_tokens: int = 4096, stream: bool = False, verbose: bool = False, on_event: ToolEventHandler | None = None, on_text_chunk: TextChunkHandler | None = None, + should_stop: StopPredicate | None = None, ) -> AgentLoopResult: """Run agent loop: LLM -> tools -> LLM until no more tools or max turns. @@ -256,14 +480,23 @@ def run_agent_loop( tool_registry: Tool registry to use tool_context: Tool context max_turns: Maximum tool turns before stopping + max_output_tokens: Maximum output tokens requested from the model per turn stream: Whether to stream responses verbose: Whether to print tool calls/results on_event: Optional callback for tool events on_text_chunk: Optional callback for incremental user-visible text chunks + should_stop: Optional cooperative cancellation check at model/tool boundaries Returns: AgentLoopResult with final text response, usage info, and turn count """ + # A tool turn adds one assistant message and one grouped tool-result message. + # Do not let the generic message-count cap split Anthropic tool pairs mid-run. + conversation.max_history = max( + conversation.max_history, + len(conversation.messages) + (2 * max_turns) + 1, + ) + # Convert tools to schemas (Anthropic format) tool_schemas = [] for spec in tool_registry.list_specs(): @@ -290,38 +523,120 @@ def run_agent_loop( pass # Track usage across all turns - total_usage: dict[str, int] = {"input_tokens": 0, "output_tokens": 0} + total_usage: dict[str, int] = { + "input_tokens": 0, + "output_tokens": 0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + } turn_count = 0 + consecutive_empty_responses = 0 + trace_recorder = ( + PeerTraceRecorder(tool_context) + if tool_context.peer_run_id is not None + else TeamTraceRecorder(tool_context) + ) + + def emit(event: ToolEvent) -> None: + _safe_call_handler(on_event, event) + trace_recorder.record(event) + + def finish( + response_text: str, + *, + failed: bool = False, + cancelled: bool = False, + failure_reason: str | None = None, + ) -> AgentLoopResult: + usage = total_usage if any(total_usage.values()) else None + emit(ToolEvent( + kind="run_cancelled" if cancelled else ("run_failed" if failed else "run_completed"), + content=response_text, + model=tool_context.model_override or getattr(provider, "model", None), + usage=usage, + turn=turn_count, + is_error=failed, + error=response_text if failed else None, + )) + return AgentLoopResult( + response_text=response_text, + usage=usage, + num_turns=turn_count, + cancelled=cancelled, + failed=failed, + failure_reason=failure_reason, + ) + + emit(ToolEvent( + kind="run_started", + model=tool_context.model_override or getattr(provider, "model", None), + turn=0, + )) for turn in range(max_turns): + if _safe_should_stop(should_stop): + return finish("[Run stopped]", cancelled=True) if _is_anthropic_provider(provider): api_messages = conversation.get_messages() else: # Use OpenAI formatted messages for non-Anthropic api_messages = openai_messages - call_kwargs: dict[str, Any] = {"tools": tool_schemas} + call_kwargs: dict[str, Any] = { + "tools": tool_schemas, + "max_tokens": max_output_tokens, + } + if tool_context.model_override: + call_kwargs["model"] = tool_context.model_override if _is_anthropic_provider(provider): call_kwargs["system"] = effective_system_prompt else: if turn == 0: api_messages = [{"role": "system", "content": effective_system_prompt}, *api_messages] - response, streamed_live_text = _call_provider_for_turn( - provider=provider, - api_messages=api_messages, - call_kwargs=call_kwargs, - stream=stream, - on_text_chunk=on_text_chunk, - ) + model_name = tool_context.model_override or getattr(provider, "model", None) + emit(ToolEvent(kind="model_started", model=model_name, turn=turn + 1)) + model_started_at = time.monotonic() + try: + response, streamed_live_text = _call_provider_for_turn( + provider=provider, + api_messages=api_messages, + call_kwargs=call_kwargs, + stream=stream, + on_text_chunk=on_text_chunk, + ) + except Exception as exc: + duration_ms = round((time.monotonic() - model_started_at) * 1000) + error = f"{type(exc).__name__}: {exc}" + emit(ToolEvent( + kind="model_error", + model=model_name, + turn=turn + 1, + duration_ms=duration_ms, + is_error=True, + error=error, + )) + finish(error, failed=True) + raise turn_count += 1 # Collect usage info if response.usage: - total_usage["input_tokens"] += response.usage.get("input_tokens", 0) - total_usage["output_tokens"] += response.usage.get("output_tokens", 0) + for usage_key in total_usage: + total_usage[usage_key] += int(response.usage.get(usage_key, 0) or 0) # Build assistant content for Anthropic or just text for OpenAI final_assistant_content = response.content or "" + emit(ToolEvent( + kind="model_response", + content=final_assistant_content, + model=response.model or model_name, + usage=response.usage, + finish_reason=response.finish_reason, + turn=turn_count, + duration_ms=round((time.monotonic() - model_started_at) * 1000), + )) + if _safe_should_stop(should_stop): + return finish("[Run stopped]", cancelled=True) if _is_anthropic_provider(provider): assistant_blocks: list = [] @@ -363,30 +678,101 @@ def run_agent_loop( tool_uses = response.tool_uses or [] if not tool_uses: + lifecycle_warning = _team_lifecycle_warning(tool_context) + if lifecycle_warning is not None: + current_team = tool_context.team or {} + if current_team.get("lifecycle_state") in { + "aborted", + "budget_exhausted", + }: + budget_exhausted = ( + current_team.get("lifecycle_state") == "budget_exhausted" + ) + terminal_text = ( + final_assistant_content.strip() + or ( + "Team execution budget exhausted before successful completion." + if budget_exhausted + else "Team aborted before successful completion." + ) + ) + if stream and terminal_text and not streamed_live_text: + _emit_text_chunks(on_text_chunk, terminal_text) + return finish( + terminal_text, + failed=True, + failure_reason=( + "team_budget_exhausted" + if budget_exhausted + else "team_aborted" + ), + ) + emit( + ToolEvent( + kind="team_lifecycle_warning", + content=lifecycle_warning, + model=response.model or model_name, + turn=turn_count, + ) + ) + conversation.add_user_message(lifecycle_warning) + if not _is_anthropic_provider(provider): + openai_messages.append({"role": "user", "content": lifecycle_warning}) + continue + if not final_assistant_content.strip() and last_user_visible_message is None: + consecutive_empty_responses += 1 + if consecutive_empty_responses <= _EMPTY_RESPONSE_RETRY_LIMIT: + emit( + ToolEvent( + kind="empty_response_retry", + content=_EMPTY_RESPONSE_CORRECTION, + model=response.model or model_name, + finish_reason=response.finish_reason, + turn=turn_count, + is_error=True, + error=( + "model returned no content or tool calls " + f"({consecutive_empty_responses}/" + f"{_EMPTY_RESPONSE_RETRY_LIMIT})" + ), + ) + ) + conversation.add_user_message(_EMPTY_RESPONSE_CORRECTION) + if not _is_anthropic_provider(provider): + openai_messages.append( + {"role": "user", "content": _EMPTY_RESPONSE_CORRECTION} + ) + continue + error = ( + "model returned no content or tool calls after " + f"{_EMPTY_RESPONSE_RETRY_LIMIT} corrective retries" + ) + finish(error, failed=True) + raise RuntimeError(error) # No more tools, done if stream and final_assistant_content and not streamed_live_text: _emit_text_chunks(on_text_chunk, final_assistant_content) if (final_assistant_content or "").strip() == "" and last_user_visible_message is not None: - return AgentLoopResult( - response_text=last_user_visible_message, - usage=total_usage if total_usage["input_tokens"] > 0 or total_usage["output_tokens"] > 0 else None, - num_turns=turn_count, - ) - return AgentLoopResult( - response_text=final_assistant_content, - usage=total_usage if total_usage["input_tokens"] > 0 or total_usage["output_tokens"] > 0 else None, - num_turns=turn_count, - ) + return finish(last_user_visible_message) + return finish(final_assistant_content) + + consecutive_empty_responses = 0 + + # Anthropic expects all results for one assistant tool-use response in a + # single following user message. + anthropic_result_blocks: list[ToolResultContentBlock] = [] # Call each tool for tool_use in tool_uses: + if _safe_should_stop(should_stop): + return finish("[Run stopped]", cancelled=True) tool_id = tool_use["id"] tool_name = tool_use["name"] tool_input = tool_use["input"] + tool_started_at = time.monotonic() try: - _safe_call_handler( - on_event, + emit( ToolEvent( kind="tool_use", tool_name=tool_name, @@ -417,31 +803,35 @@ def run_agent_loop( summary = summarize_tool_result(tool_name, result_output) print(f"{summary}") - _safe_call_handler( - on_event, + emit( ToolEvent( kind="tool_result", tool_name=tool_name, tool_output=result_output, tool_use_id=tool_id, is_error=result.is_error, + duration_ms=round((time.monotonic() - tool_started_at) * 1000), ), ) if _is_anthropic_provider(provider): - conversation.add_tool_result_message(tool_id, result_output) + anthropic_result_blocks.append(ToolResultContentBlock( + type="tool_result", + tool_use_id=tool_id, + content=_build_tool_result_content(tool_name, result_output), + is_error=result.is_error, + )) else: # Add tool result in OpenAI format openai_messages.append({ "role": "tool", "tool_call_id": tool_id, - "content": _build_openai_tool_result_content(result_output) + "content": _build_tool_result_content(tool_name, result_output) }) except Exception as e: error_str = f"Error: {e}" if verbose: print(f"[Tool Error] {error_str}") - _safe_call_handler( - on_event, + emit( ToolEvent( kind="tool_error", tool_name=tool_name, @@ -449,10 +839,16 @@ def run_agent_loop( tool_use_id=tool_id, is_error=True, error=error_str, + duration_ms=round((time.monotonic() - tool_started_at) * 1000), ), ) if _is_anthropic_provider(provider): - conversation.add_tool_result_message(tool_id, error_str, is_error=True) + anthropic_result_blocks.append(ToolResultContentBlock( + type="tool_result", + tool_use_id=tool_id, + content=error_str, + is_error=True, + )) else: openai_messages.append({ "role": "tool", @@ -460,9 +856,39 @@ def run_agent_loop( "content": error_str }) - # Reached max turns - return AgentLoopResult( - response_text="[Max tool turns reached]", - usage=total_usage if total_usage["input_tokens"] > 0 or total_usage["output_tokens"] > 0 else None, - num_turns=turn_count, + if _safe_should_stop(should_stop): + if anthropic_result_blocks: + conversation.add_message("user", anthropic_result_blocks) + return finish("[Run stopped]", cancelled=True) + + if anthropic_result_blocks: + conversation.add_message("user", anthropic_result_blocks) + + # Reached max turns. An unsettled protocol v2 team is a distinct lifecycle + # failure, not a successful model stop. Preserve the historical response text + # so existing CLI/evaluation callers continue to recognize max-turn failures. + lifecycle_warning = _team_lifecycle_warning(tool_context) + if lifecycle_warning is not None: + emit( + ToolEvent( + kind="team_lifecycle_failed", + content=lifecycle_warning, + model=tool_context.model_override or getattr(provider, "model", None), + turn=turn_count, + is_error=True, + error="maximum turns reached with an unsettled team lifecycle", + ) + ) + return finish( + "[Max tool turns reached]", + failed=True, + failure_reason=( + "team_budget_exhausted" + if (tool_context.team or {}).get("lifecycle_state") + == "budget_exhausted" + else "team_lifecycle_failure" + ), + ) + return finish( + "[Max tool turns reached]", failed=True, failure_reason="max_turns" ) diff --git a/src/tool_system/context.py b/src/tool_system/context.py index affd3aa..5b82335 100644 --- a/src/tool_system/context.py +++ b/src/tool_system/context.py @@ -1,5 +1,6 @@ from __future__ import annotations +import threading from dataclasses import dataclass, field from pathlib import Path from typing import Any, Callable, Optional @@ -7,6 +8,8 @@ from .errors import ToolPermissionError from .permissions import ToolPermissionContext from .task_manager import TaskManager +from ..teammate.models import TeamTask +from ..teammate.store import TeamStore @dataclass @@ -26,8 +29,29 @@ class ToolContext: ask_user: Callable[[list[dict[str, Any]]], dict[str, str]] | None = None crons: dict[str, dict[str, Any]] = field(default_factory=dict) team: dict[str, Any] | None = None + actor_id: str | None = None + current_task_id: str | None = None + system_prompt_extra: str | None = None + model_override: str | None = None + teammate_runtime: Any | None = None output_style_name: str | None = None output_style_dir: Path | None = None + workspace_backend: Any | None = None + execution_workspace_root: str | None = None + execution_cwd: str | None = None + remote_file_fingerprints: dict[str, tuple[int, int]] = field(default_factory=dict) + peer_store: Any | None = None + peer_run_id: str | None = None + peer_id: str | None = None + peer_control: Any | None = None + # All child contexts in a team share this lock. Protocol-v2 mutation tools use + # it to make Bash before/after snapshots attributable even while model turns + # and read-only tools continue concurrently. + mutation_lock: Any = field(default_factory=threading.RLock, repr=False) + # Sticky for the lifetime of a worker task. The runtime fails the task even if + # the model catches the tool error or attempts to reset its task status. + ownership_violations: list[dict[str, Any]] = field(default_factory=list) + team_store: TeamStore = field(init=False, repr=False) # Permission handler callback: called when a tool needs user consent. # Signature: (tool_name: str, message: str, suggestion: str | None) @@ -37,10 +61,17 @@ class ToolContext: def __post_init__(self) -> None: self.workspace_root = Path(self.workspace_root).resolve() + self.team_store = TeamStore(self.workspace_root) if self.cwd is None: self.cwd = self.workspace_root else: self.cwd = Path(self.cwd).resolve() + if self.workspace_backend is not None: + self.execution_workspace_root = ( + self.execution_workspace_root + or str(getattr(self.workspace_backend, "workspace_root", "/workspace")) + ) + self.execution_cwd = self.execution_cwd or self.execution_workspace_root if self.permission_context.workspace_root is None: self.permission_context = ToolPermissionContext.from_iterables( self.permission_context.deny_names, @@ -49,6 +80,30 @@ def __post_init__(self) -> None: additional_working_directories=self.permission_context.additional_working_directories, allow_docs=self.permission_context.allow_docs, ) + active_team = self.team_store.load_active_team() + if active_team is not None: + self.team = active_team.to_dict() + self.tasks = self.team_store.load_tasks(active_team.team_id) + + def persist_tasks(self) -> None: + if self.team is None: + return + team_id = self.team.get("team_id") + if isinstance(team_id, str) and team_id: + if self.actor_id is not None and self.current_task_id in self.tasks: + task = TeamTask.from_dict(self.tasks[self.current_task_id]) + self.tasks = self.team_store.update_task(team_id, task) + else: + self.team_store.save_tasks(team_id, self.tasks) + + def reload_team_state(self) -> None: + active_team = self.team_store.load_active_team() + if active_team is None: + self.team = None + self.tasks = {} + return + self.team = active_team.to_dict() + self.tasks = self.team_store.load_tasks(active_team.team_id) def mark_file_read(self, path: Path) -> None: stat = path.stat() @@ -62,6 +117,28 @@ def was_file_read_and_unchanged(self, path: Path) -> bool: stat = resolved.stat() return fingerprint == (int(stat.st_mtime), int(stat.st_size)) + def mark_remote_file_read(self, path: str) -> None: + if self.workspace_backend is None: + return + self.remote_file_fingerprints[path] = self.workspace_backend.stat(path).fingerprint + + def was_remote_file_read_and_unchanged(self, path: str) -> bool: + if self.workspace_backend is None: + return False + fingerprint = self.remote_file_fingerprints.get(path) + if fingerprint is None: + return False + return fingerprint == self.workspace_backend.stat(path).fingerprint + + def resolve_execution_path(self, path: str) -> str: + if self.workspace_backend is None: + return str(self.ensure_allowed_path(path)) + return self.workspace_backend.resolve_path( + path, + cwd=self.execution_cwd or self.execution_workspace_root or "/workspace", + local_root=self.workspace_root, + ) + def ensure_allowed_path(self, path: str | Path) -> Path: p = Path(path).expanduser() if isinstance(path, str) else path.expanduser() if not p.is_absolute(): diff --git a/src/tool_system/defaults.py b/src/tool_system/defaults.py index ffd7b6a..44946ba 100644 --- a/src/tool_system/defaults.py +++ b/src/tool_system/defaults.py @@ -26,6 +26,7 @@ MCPTool, NotebookEditTool, PowerShellTool, + ReadMessagesTool, REPLTool, ReadMcpResourceTool, RemoteTriggerTool, @@ -34,12 +35,25 @@ SkillTool, SleepTool, StructuredOutputTool, + TeamAbortTool, TeamCreateTool, + TeamConfigureTool, + TeamCancelTool, TeamDeleteTool, + TeamIntegrateTool, + TeamPlanTool, + TeamReplanTool, + TeamResumeTool, + TeammateCreateTool, + TeammateResumeTool, + TeammateStopTool, + TeamRunTool, + TeamVerifyTool, TaskCreateTool, TaskGetTool, TaskListTool, TaskOutputTool, + TaskRetryTool, TaskStopTool, TaskUpdateTool, TestingPermissionTool, @@ -49,55 +63,109 @@ ) from .tools.agent import AgentTool from .tools.tool_search import ToolSearchTool +from .remote_tools import ( + RemoteBashTool, + RemoteFileEditTool, + RemoteFileReadTool, + RemoteFileWriteTool, + RemoteGlobTool, + RemoteGrepTool, +) -def build_default_registry(*, include_user_tools: bool = True) -> ToolRegistry: - registry = ToolRegistry( - tools=[ - SendUserMessageTool(), +def build_default_registry( + *, + include_user_tools: bool = True, + workspace_backend: object | None = None, + include_team_tools: bool = True, +) -> ToolRegistry: + workspace_tools = ( + [ + RemoteBashTool(), + RemoteFileReadTool(), + RemoteFileWriteTool(), + RemoteFileEditTool(), + RemoteGlobTool(), + RemoteGrepTool(), + ] + if workspace_backend is not None + else [ BashTool(), FileReadTool(), FileWriteTool(), FileEditTool(), GlobTool(), GrepTool(), + ] + ) + collaboration_tools = ( + [ + TaskStopTool(), + TaskCreateTool(), + TaskGetTool(), + TaskListTool(), + TaskUpdateTool(), + TaskOutputTool(), + TaskRetryTool(), + TeamCreateTool(), + TeamConfigureTool(), + TeamPlanTool(), + TeammateCreateTool(), + TeamRunTool(), + TeamVerifyTool(), + TeamReplanTool(), + TeamResumeTool(), + TeamCancelTool(), + TeamAbortTool(), + TeammateStopTool(), + TeammateResumeTool(), + TeamIntegrateTool(), + TeamDeleteTool(), + SendMessageTool(), + ReadMessagesTool(), + RemoteTriggerTool(), + ] + if include_team_tools + else [] + ) + registry = ToolRegistry( + tools=[ + SendUserMessageTool(), + *workspace_tools, WebFetchTool(), WebSearchTool(), SleepTool(), - TaskStopTool(), ConfigTool(), MCPTool(), ListMcpResourcesTool(), ReadMcpResourceTool(), - LSPTool(), + *([] if workspace_backend is not None else [LSPTool()]), SkillTool(), BriefTool(), AskUserQuestionTool(), TodoWriteTool(), - TaskCreateTool(), - TaskGetTool(), - TaskListTool(), - TaskUpdateTool(), - TaskOutputTool(), - TeamCreateTool(), - TeamDeleteTool(), + *collaboration_tools, EnterPlanModeTool(), ExitPlanModeTool(), - EnterWorktreeTool(), - ExitWorktreeTool(), + *( + [] + if workspace_backend is not None + else [EnterWorktreeTool(), ExitWorktreeTool()] + ), CronCreateTool(), CronListTool(), CronDeleteTool(), - SendMessageTool(), StructuredOutputTool(), - RemoteTriggerTool(), - PowerShellTool(), - NotebookEditTool(), - REPLTool(), + *( + [] + if workspace_backend is not None + else [PowerShellTool(), NotebookEditTool(), REPLTool()] + ), TestingPermissionTool(), ] ) - registry.register(AgentTool(registry)) + if include_team_tools: + registry.register(AgentTool(registry)) registry.register(ToolSearchTool(registry)) if include_user_tools: diff --git a/src/tool_system/ownership.py b/src/tool_system/ownership.py new file mode 100644 index 0000000..bc4f132 --- /dev/null +++ b/src/tool_system/ownership.py @@ -0,0 +1,959 @@ +from __future__ import annotations + +import os +import posixpath +import re +import shlex +import shutil +import tempfile +import uuid +from contextlib import contextmanager +from pathlib import Path, PurePosixPath +from typing import TYPE_CHECKING, Any, Iterator + +from .errors import ToolPermissionError + +if TYPE_CHECKING: + from .context import ToolContext + + +_IGNORED_DIRECTORY_NAMES = { + ".cache", + ".eggs", + ".git", + ".gradle", + ".hypothesis", + ".mypy_cache", + ".npm", + ".nyc_output", + ".nox", + ".pytest_cache", + ".ruff_cache", + ".tox", + ".venv", + "__pycache__", + "htmlcov", + "node_modules", + "venv", +} +_IGNORED_FILE_NAMES = { + ".coverage", + ".DS_Store", + "coverage.xml", + "lcov.info", +} +_IGNORED_FILE_PREFIXES = {".coverage."} +_IGNORED_FILE_SUFFIXES = { + ".bak", + ".db", + ".db-journal", + ".db-shm", + ".db-wal", + ".log", + ".orig", + ".pyc", + ".pyo", + ".rej", + ".sqlite", + ".sqlite-journal", + ".sqlite-shm", + ".sqlite-wal", + ".sqlite3", + ".swo", + ".swp", + ".temp", + ".tmp", + "~", +} +_INTEGRATION_FILE_NAMES = { + "androidmanifest.xml", + "cargo.lock", + "cargo.toml", + "composer.json", + "composer.lock", + "gemfile", + "gemfile.lock", + "go.mod", + "go.sum", + "manifest.in", + "manifest.json", + "manifest.yaml", + "manifest.yml", + "package-lock.json", + "package.json", + "pnpm-lock.yaml", + "pom.xml", + "pyproject.toml", + "setup.cfg", + "setup.py", + "yarn.lock", +} +_INTEGRATOR_ROLES = { + "integration", + "integration_owner", + "integrator", + "lead_integrator", +} +_TASK_TEST_ROOT = ".clawd/task-tests" +_CONTROL_ROOT = ".clawd" +_GENERATED_TEST_PATHS_ATTRIBUTE = "_ownership_generated_test_paths" + + +def protocol_version(context: ToolContext) -> int: + team = context.team or {} + settings = team.get("settings") if isinstance(team.get("settings"), dict) else {} + quality = ( + settings.get("quality_gates") + if isinstance(settings.get("quality_gates"), dict) + else {} + ) + versions = [1] + for value in ( + team.get("protocol_version"), + settings.get("protocol_version"), + quality.get("protocol_version"), + ): + try: + versions.append(int(value)) + except (TypeError, ValueError): + continue + return max(versions) + + +def ownership_enforced(context: ToolContext) -> bool: + """Whether this is a protocol-v2 teammate task with scoped writes.""" + + return bool( + context.actor_id + and context.current_task_id + and context.team + and protocol_version(context) >= 2 + ) + + +def strict_protocol_v2(context: ToolContext) -> bool: + """Whether the active team uses strict protocol-v2 safety rules.""" + + team = context.team or {} + settings = team.get("settings") if isinstance(team.get("settings"), dict) else {} + quality = ( + settings.get("quality_gates") + if isinstance(settings.get("quality_gates"), dict) + else {} + ) + return bool(quality.get("strict") and protocol_version(context) >= 2) + + +def bash_audit_required(context: ToolContext) -> bool: + """Audit teammate mutations and strict-v2 lead control-state mutations.""" + + return ownership_enforced(context) or strict_protocol_v2(context) + + +def _is_control_path(relative_path: str) -> bool: + candidate = _normalize_owned_path(relative_path) + return candidate == _CONTROL_ROOT or candidate.startswith(_CONTROL_ROOT + "/") + + +def _normalize_owned_path( + value: str, *, workspace_roots: tuple[str, ...] = () +) -> str: + normalized = value.strip().replace("\\", "/") + roots = [*workspace_roots, "/workspace"] + if normalized.startswith("/"): + for root in roots: + canonical_root = str(root or "").strip().replace("\\", "/").rstrip("/") + if not canonical_root: + continue + if normalized == canonical_root: + normalized = "" + break + if normalized.startswith(canonical_root + "/"): + normalized = normalized[len(canonical_root) + 1 :] + break + while normalized.startswith("./"): + normalized = normalized[2:] + return normalized.strip("/") + + +def owned_paths(context: ToolContext) -> tuple[str, ...]: + if not ownership_enforced(context): + return () + task = context.tasks.get(str(context.current_task_id)) + if not isinstance(task, dict): + return () + normalized = [ + _normalize_owned_path( + str(value), + workspace_roots=( + str(context.workspace_root), + str(context.execution_workspace_root or ""), + ), + ) + for value in (task.get("owned_files") or []) + ] + return tuple(path for path in normalized if path) + + +def task_test_scratch_prefix_for_id(task_id: str | None) -> str: + """Return the task-private location for disposable teammate tests. + + Protocol-v2 tasks must explicitly own every deliverable file. Focused tests that + exist only to validate one teammate's implementation are different: forcing the + lead to predict their names creates needless repair loops, while allowing arbitrary + ``test_*.py`` writes would let a teammate modify project or evaluator tests. Each + task therefore gets one isolated, non-deliverable scratch subtree. + """ + + raw_task_id = str(task_id or "unknown") + safe_task_id = re.sub(r"[^A-Za-z0-9._-]+", "_", raw_task_id).strip("._-") + return f"{_TASK_TEST_ROOT}/{safe_task_id or 'unknown'}" + + +def task_test_scratch_prefix(context: ToolContext) -> str: + return task_test_scratch_prefix_for_id(context.current_task_id) + + +class ControlStateBackup: + """Out-of-workspace backup used to roll back unauthorized Bash mutations. + + Teammate commands back up the auditable workspace, excluding VCS metadata and + generated/cache directories. Strict-v2 lead commands need only the ``.clawd`` + control tree because the lead otherwise owns integration. After the command, + only unauthorized changed paths are restored, so legal task work is retained. + """ + + def __init__(self, context: ToolContext) -> None: + self.context = context + self.remote = context.workspace_backend is not None + self.full_workspace = ownership_enforced(context) + self.location = ( + f"/tmp/clawd-mutation-backup-{uuid.uuid4().hex}" + if self.remote + else tempfile.mkdtemp(prefix="clawd-mutation-backup-") + ) + self.backup_root = ( + posixpath.join(str(self.location), "workspace") + if self.remote + else str(Path(str(self.location)) / "workspace") + ) + self._closed = False + try: + self._capture() + except BaseException: + self.close() + raise + + def _capture(self) -> None: + if self.remote: + backend = self.context.workspace_backend + assert backend is not None + root = posixpath.normpath( + self.context.execution_workspace_root or "/workspace" + ) + script = r""" +import base64, json, os, shutil, sys +p = json.loads(base64.b64decode(sys.argv[1])); root = p["root"]; backup = p["backup"] +ignored = set(p["ignored_dirs"]); full = bool(p["full_workspace"]) +if os.path.lexists(backup): + if os.path.islink(backup) or os.path.isfile(backup): os.unlink(backup) + else: shutil.rmtree(backup) +def ignore(directory, names): + skipped = set() + for name in names: + path = os.path.join(directory, name) + if name in ignored or name.endswith(".egg-info"): skipped.add(name) + elif not os.path.islink(path) and not os.path.isdir(path) and not os.path.isfile(path): skipped.add(name) + return skipped +if full: + if os.path.isdir(root): shutil.copytree(root, backup, symlinks=True, ignore=ignore) +else: + os.makedirs(backup, exist_ok=True) + source = os.path.join(root, ".clawd"); target = os.path.join(backup, ".clawd") + if os.path.islink(source): os.symlink(os.readlink(source), target) + elif os.path.isdir(source): shutil.copytree(source, target, symlinks=True) + elif os.path.isfile(source): shutil.copy2(source, target, follow_symlinks=False) +print(json.dumps({"captured": True})) +""".strip() + result = backend.run_json_helper( + script, + { + "operation": "capture_workspace_backup", + "root": root, + "backup": self.backup_root, + "full_workspace": self.full_workspace, + "ignored_dirs": sorted(_IGNORED_DIRECTORY_NAMES), + }, + timeout_s=120, + ) + if not isinstance(result, dict) or not result.get("captured"): + raise RuntimeError( + "failed to back up remote workspace mutation state" + ) + return + + root = self.context.workspace_root + backup_root = Path(self.backup_root) + + def ignore(_directory: str, names: list[str]) -> set[str]: + return { + name + for name in names + if name in _IGNORED_DIRECTORY_NAMES + or name.endswith(".egg-info") + or ( + not (Path(_directory) / name).is_symlink() + and not (Path(_directory) / name).is_dir() + and not (Path(_directory) / name).is_file() + ) + } + + if self.full_workspace: + shutil.copytree(root, backup_root, symlinks=True, ignore=ignore) + return + backup_root.mkdir(parents=True, exist_ok=True) + source = root / _CONTROL_ROOT + target = backup_root / _CONTROL_ROOT + if source.is_symlink(): + target.symlink_to(os.readlink(source)) + elif source.is_dir(): + shutil.copytree(source, target, symlinks=True) + elif source.is_file(): + shutil.copy2(source, target, follow_symlinks=False) + + def restore(self, relative_paths: list[str]) -> None: + """Restore unauthorized paths while retaining legal task mutations.""" + + paths = sorted( + { + _normalize_owned_path(path) + for path in relative_paths + if _normalize_owned_path(path) + } + ) + if not paths: + return + if self.remote: + self._restore_remote(paths) + else: + self._restore_local(paths) + + @staticmethod + def _remove_local(path: Path) -> None: + if path.is_symlink() or path.is_file(): + path.unlink(missing_ok=True) + elif path.is_dir(): + shutil.rmtree(path) + + @classmethod + def _ensure_local_parents(cls, root: Path, relative: PurePosixPath) -> None: + cursor = root + for part in relative.parent.parts: + cursor /= part + if cursor.is_symlink() or (cursor.exists() and not cursor.is_dir()): + cls._remove_local(cursor) + cursor.mkdir(exist_ok=True) + + def _restore_local(self, paths: list[str]) -> None: + live_root = self.context.workspace_root + backup_root = Path(self.backup_root) + if live_root.is_symlink() or (live_root.exists() and not live_root.is_dir()): + self._remove_local(live_root) + live_root.mkdir(parents=True, exist_ok=True) + # Remove shallow paths first. If a command replaced a control directory with + # a symlink, deleting children first would follow that attacker-controlled + # ancestor and could touch files outside the workspace. + for relative in sorted(paths, key=lambda value: value.count("/")): + self._remove_local(live_root / PurePosixPath(relative)) + for relative in sorted(paths, key=lambda value: value.count("/")): + pure = PurePosixPath(relative) + source = backup_root.joinpath(*pure.parts) + target = live_root.joinpath(*pure.parts) + if not (source.exists() or source.is_symlink()): + continue + self._ensure_local_parents(live_root, pure) + if source.is_symlink(): + target.symlink_to(os.readlink(source)) + elif source.is_dir(): + target.mkdir(parents=True, exist_ok=True) + shutil.copystat(source, target, follow_symlinks=False) + else: + shutil.copy2(source, target, follow_symlinks=False) + + def _restore_remote(self, paths: list[str]) -> None: + backend = self.context.workspace_backend + assert backend is not None + root = posixpath.normpath( + self.context.execution_workspace_root or "/workspace" + ) + script = r""" +import base64, json, os, shutil, sys +p = json.loads(base64.b64decode(sys.argv[1])); root = p["root"]; backup = p["backup"] +paths = sorted(set(p["paths"]), key=lambda value: value.count("/")) +def remove(path): + if os.path.islink(path) or os.path.isfile(path): os.unlink(path) + elif os.path.isdir(path): shutil.rmtree(path) +if os.path.islink(root) or (os.path.lexists(root) and not os.path.isdir(root)): remove(root) +os.makedirs(root, exist_ok=True) +def ensure_parents(relative): + cursor = root + for part in relative.split("/")[:-1]: + cursor = os.path.join(cursor, part) + if os.path.islink(cursor) or (os.path.exists(cursor) and not os.path.isdir(cursor)): + remove(cursor) + os.makedirs(cursor, exist_ok=True) +for relative in paths: + remove(os.path.join(root, *relative.split("/"))) +for relative in paths: + source = os.path.join(backup, *relative.split("/")); target = os.path.join(root, *relative.split("/")) + if not os.path.lexists(source): continue + ensure_parents(relative) + if os.path.islink(source): os.symlink(os.readlink(source), target) + elif os.path.isdir(source): os.makedirs(target, exist_ok=True); shutil.copystat(source, target, follow_symlinks=False) + else: shutil.copy2(source, target, follow_symlinks=False) +print(json.dumps({"restored": len(paths)})) +""".strip() + backend.run_json_helper( + script, + { + "operation": "restore_workspace_backup", + "root": root, + "backup": self.backup_root, + "paths": paths, + }, + timeout_s=120, + ) + + def close(self) -> None: + if self._closed: + return + self._closed = True + if self.remote: + backend = self.context.workspace_backend + if backend is not None: + root = posixpath.normpath( + self.context.execution_workspace_root or "/workspace" + ) + result = backend.exec( + f"rm -rf -- {shlex.quote(str(self.location))}", + cwd=root, + timeout_s=120, + ) + if int(result.exit_code) != 0: + raise RuntimeError( + "failed to remove remote control-state backup: " + + str(result.stderr or result.stdout or "unknown error") + ) + else: + shutil.rmtree(str(self.location), ignore_errors=True) + + +@contextmanager +def control_state_guard(context: ToolContext) -> Iterator[ControlStateBackup | None]: + """Freeze local harness writers and provide a Bash rollback point. + + Local orchestration state and local Bash share one filesystem, so the TeamStore + transaction lock prevents legitimate harness writes from being attributed to the + command. AGS orchestration state is local while Bash runs remotely, so the remote + copy has no legitimate concurrent writer and needs only backup/audit/rollback. + """ + + if not bash_audit_required(context): + yield None + return + + def guarded() -> Iterator[ControlStateBackup]: + backup = ControlStateBackup(context) + try: + yield backup + finally: + backup.close() + + if context.workspace_backend is not None: + yield from guarded() + return + + team_id = str((context.team or {}).get("team_id") or "") + if not team_id: + yield from guarded() + return + # Import lazily to avoid coupling normal, non-team tool initialization to the + # teammate persistence module. + from ..teammate.store import _locked_team_transaction + + with _locked_team_transaction(context.team_store._transaction_path(team_id)): + yield from guarded() + + +def allowed_write_paths(context: ToolContext) -> tuple[str, ...]: + paths = list(owned_paths(context)) + if ownership_enforced(context): + paths.append(task_test_scratch_prefix(context)) + return tuple(paths) + + +def _current_task(context: ToolContext) -> dict[str, Any]: + task = context.tasks.get(str(context.current_task_id)) + return task if isinstance(task, dict) else {} + + +def _normalized_role(value: Any) -> str: + return re.sub(r"[\s-]+", "_", str(value or "").strip().casefold()) + + +def _actor_is_integrator(context: ToolContext) -> bool: + """Return whether the current writer has explicit integration authority. + + The lead is always the integration authority. A worker only receives the same + narrow privilege when its task metadata or persisted agent role says so; task + names such as ``integration`` and read-only validation tasks are deliberately not + treated as authority declarations. + """ + + team = context.team or {} + actor_id = str(context.actor_id or "") + if actor_id and actor_id == str(team.get("lead_agent_id") or ""): + return True + + metadata = _current_task(context).get("metadata") + metadata = metadata if isinstance(metadata, dict) else {} + if metadata.get("integration_owner") is True: + return True + if _normalized_role(metadata.get("ownership_role")) in _INTEGRATOR_ROLES: + return True + + team_id = str(team.get("team_id") or "") + if not (team_id and actor_id): + return False + try: + agent = context.team_store.load_agent(team_id, actor_id) + except (OSError, ValueError): + return False + return bool(agent and _normalized_role(agent.role) in _INTEGRATOR_ROLES) + + +def _is_integration_path(relative_path: str) -> bool: + path = PurePosixPath(_normalize_owned_path(relative_path)) + name = path.name.casefold() + if name == "__init__.py": + return True + if name in _INTEGRATION_FILE_NAMES: + return True + return name.startswith("requirements") and name.endswith(".txt") + + +def _is_generated_test_candidate(relative_path: str) -> bool: + path = PurePosixPath(_normalize_owned_path(relative_path)) + return bool( + len(path.parts) >= 2 + and path.parts[0] == "tests" + and path.name.startswith("test_") + and path.suffix == ".py" + ) + + +def _generated_test_paths(context: ToolContext) -> set[str]: + paths = getattr(context, _GENERATED_TEST_PATHS_ATTRIBUTE, None) + if not isinstance(paths, set): + paths = set() + setattr(context, _GENERATED_TEST_PATHS_ATTRIBUTE, paths) + return paths + + +def _remember_generated_test(context: ToolContext, relative_path: str) -> None: + _generated_test_paths(context).add(_normalize_owned_path(relative_path)) + + +def _path_exists( + context: ToolContext, + path: str | Path, + *, + execution_path: bool, +) -> bool: + if execution_path: + backend = context.workspace_backend + if backend is None: + return False + return bool(backend.stat(str(path)).exists) + candidate = Path(path) + return candidate.exists() or candidate.is_symlink() + + +def _declared_task_paths(context: ToolContext) -> list[tuple[str, str]]: + roots = ( + str(context.workspace_root), + str(context.execution_workspace_root or ""), + ) + declared: list[tuple[str, str]] = [] + for task_id, task in context.tasks.items(): + if not isinstance(task, dict): + continue + for value in task.get("owned_files") or []: + normalized = _normalize_owned_path( + str(value), workspace_roots=roots + ) + if normalized: + declared.append((str(task_id), normalized)) + return declared + + +def _path_in_declared_scope(candidate: str, declared: str) -> bool: + return candidate == declared or candidate.startswith(declared + "/") + + +def _path_reserved_by_other_task( + context: ToolContext, relative_path: str +) -> bool: + candidate = _normalize_owned_path(relative_path) + current_task_id = str(context.current_task_id or "") + return any( + task_id != current_task_id + and _path_in_declared_scope(candidate, declared) + for task_id, declared in _declared_task_paths(context) + ) + + +def _is_runtime_artifact(relative: str) -> bool: + path = PurePosixPath(_normalize_owned_path(relative)) + if any( + component in _IGNORED_DIRECTORY_NAMES or component.endswith(".egg-info") + for component in path.parts[:-1] + ): + return True + name = path.name + return bool( + name in _IGNORED_FILE_NAMES + or any(name.startswith(prefix) for prefix in _IGNORED_FILE_PREFIXES) + or any(name.endswith(suffix) for suffix in _IGNORED_FILE_SUFFIXES) + ) + + +def _relative_local_path(context: ToolContext, path: str | Path) -> str: + candidate = Path(path).expanduser() + if not candidate.is_absolute(): + candidate = (context.cwd or context.workspace_root) / candidate + resolved = candidate.resolve() + try: + relative = resolved.relative_to(context.workspace_root.resolve()) + except ValueError as exc: + raise ToolPermissionError( + f"task writes must remain inside the task workspace: {path}" + ) from exc + return relative.as_posix() + + +def _relative_execution_path(context: ToolContext, path: str) -> str: + root = posixpath.normpath(context.execution_workspace_root or "/workspace") + candidate = posixpath.normpath(path) + if candidate == root: + return "" + prefix = root.rstrip("/") + "/" + if not candidate.startswith(prefix): + raise ToolPermissionError( + f"task writes must remain inside the task workspace: {path}" + ) + return candidate[len(prefix) :] + + +def relative_task_path( + context: ToolContext, + path: str | Path, + *, + execution_path: bool = False, +) -> str: + if execution_path: + return _relative_execution_path(context, str(path)) + return _relative_local_path(context, path) + + +def path_is_owned(context: ToolContext, relative_path: str) -> bool: + if not ownership_enforced(context): + return True + candidate = _normalize_owned_path(relative_path) + if any( + candidate == owned or candidate.startswith(owned + "/") + for owned in allowed_write_paths(context) + ): + return True + if _is_runtime_artifact(candidate) and not _path_reserved_by_other_task( + context, candidate + ): + return True + if candidate in _generated_test_paths(context): + return True + return _is_integration_path(candidate) and _actor_is_integrator(context) + + +def require_owned_path( + context: ToolContext, + path: str | Path, + *, + tool_name: str, + execution_path: bool = False, +) -> str: + """Reject a direct write outside the current v2 task's ownership scope.""" + + if not ownership_enforced(context): + relative = relative_task_path( + context, path, execution_path=execution_path + ) + if strict_protocol_v2(context) and _is_control_path(relative): + raise ToolPermissionError( + "strict protocol v2 protects .clawd control state; use Team tools " + "instead of Write/Edit" + ) + return relative + relative = relative_task_path(context, path, execution_path=execution_path) + if path_is_owned(context, relative): + return relative + # A new test module under tests/ is task-local disposable scratch. Remembering + # provenance keeps subsequent edits legal while existing repository tests remain + # protected. Persistent project tests should still be declared in owned_files. + if ( + _is_generated_test_candidate(relative) + and not _path_reserved_by_other_task(context, relative) + and not _path_exists(context, path, execution_path=execution_path) + ): + _remember_generated_test(context, relative) + return relative + violation = record_ownership_violation(context, tool_name, [relative]) + raise ToolPermissionError(violation["error"]) + + +def record_ownership_violation( + context: ToolContext, tool_name: str, paths: list[str] +) -> dict[str, Any]: + normalized = sorted( + { + _normalize_owned_path(path) or "." + for path in paths + if isinstance(path, str) + } + ) + allowed = list(allowed_write_paths(context)) + rendered = ", ".join(normalized[:8]) + if len(normalized) > 8: + rendered += f", ... (+{len(normalized) - 8} more)" + error = ( + f"protocol v2 task ownership violation in {tool_name}: changed {rendered}; " + f"allowed owned_files are {allowed or ['']}; package initializers and " + "manifests additionally require an explicit lead/integrator role" + ) + violation = { + "tool": tool_name, + "task_id": context.current_task_id, + "actor_id": context.actor_id, + "paths": normalized, + "allowed_paths": allowed, + "error": error, + } + context.ownership_violations.append(violation) + if context.team is not None: + team_id = str(context.team.get("team_id") or "") + if team_id: + context.team_store.append_event( + team_id, "task.ownership_violation_detected", violation + ) + return violation + + +def _ignored_relative_path(relative: str) -> bool: + return _is_runtime_artifact(relative) + + +def snapshot_local_workspace(context: ToolContext) -> dict[str, str]: + """Return filesystem fingerprints for auditable workspace files. + + Deterministic interpreter/test caches are excluded because they are not + deliverable source. ``.clawd`` is intentionally included: local Bash holds the + TeamStore transaction lock while this snapshot is live, so control-state changes + can only come from the command itself. The task-private scratch subtree remains + writable through :func:`allowed_write_paths`. + """ + + root = context.workspace_root.resolve() + scan_root = root if ownership_enforced(context) else root / _CONTROL_ROOT + if not (scan_root.exists() or scan_root.is_symlink()): + return {} + declared_paths = tuple(path for _, path in _declared_task_paths(context)) + + def declared(relative: str, *, include_ancestors: bool = False) -> bool: + candidate = _normalize_owned_path(relative) + return any( + _path_in_declared_scope(candidate, owned) + or ( + include_ancestors + and _path_in_declared_scope(owned, candidate) + ) + for owned in declared_paths + ) + + snapshot: dict[str, str] = {} + for directory, names, files in os.walk(scan_root, followlinks=False): + directory_path = Path(directory) + names[:] = sorted( + name + for name in names + if ( + name not in _IGNORED_DIRECTORY_NAMES + and not name.endswith(".egg-info") + ) + or declared( + (directory_path / name).relative_to(root).as_posix(), + include_ancestors=True, + ) + ) + # os.walk reports directory symlinks in ``names`` even with + # followlinks=False. Fingerprint them explicitly so a command cannot replace + # a protected control directory with a symlink without appearing in the diff. + for name in list(names): + path = directory_path / name + if not path.is_symlink(): + continue + relative = path.relative_to(root).as_posix() + try: + stat = path.lstat() + metadata = ( + f"{stat.st_mode & 0o777:o}:{stat.st_size}:" + f"{stat.st_mtime_ns}:{stat.st_ctime_ns}:{stat.st_ino}" + ) + snapshot[relative] = f"link:{metadata}:{os.readlink(path)}" + except FileNotFoundError: + continue + for name in sorted(files): + path = directory_path / name + relative = path.relative_to(root).as_posix() + if _ignored_relative_path(relative) and not declared(relative): + continue + try: + stat = path.lstat() + metadata = ( + f"{stat.st_mode & 0o777:o}:{stat.st_size}:" + f"{stat.st_mtime_ns}:{stat.st_ctime_ns}:{stat.st_ino}" + ) + if path.is_symlink(): + fingerprint = f"link:{metadata}:{os.readlink(path)}" + elif path.is_file(): + fingerprint = f"file:{metadata}" + else: + continue + except FileNotFoundError: + # External processes can race a scan; teammate mutations themselves + # are serialized by ToolContext.mutation_lock. + continue + snapshot[relative] = fingerprint + return snapshot + + +_REMOTE_SNAPSHOT_SCRIPT = r""" +import base64, json, os, sys +p = json.loads(base64.b64decode(sys.argv[1])); root = p["root"] +scan_root = root if not p["control_only"] else os.path.join(root, ".clawd") +ignored_dirs = set(p["ignored_dirs"]); ignored_files = set(p["ignored_files"]) +ignored_prefixes = tuple(p["ignored_prefixes"]); ignored_suffixes = tuple(p["ignored_suffixes"]) +declared_paths = tuple(p["declared_paths"]); out = {} +def declared(rel): + return any(rel == owned or rel.startswith(owned + "/") or owned.startswith(rel + "/") for owned in declared_paths) +for directory, names, files in os.walk(scan_root, followlinks=False): + names[:] = sorted(n for n in names if (n not in ignored_dirs and not n.endswith(".egg-info")) or declared(os.path.relpath(os.path.join(directory, n), root).replace(os.sep, "/"))) + for name in list(names): + path = os.path.join(directory, name) + if not os.path.islink(path): continue + rel = os.path.relpath(path, root).replace(os.sep, "/") + try: + stat = os.lstat(path) + metadata = "%o:%s:%s:%s:%s" % (stat.st_mode & 0o777, stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns, stat.st_ino) + out[rel] = "link:" + metadata + ":" + os.readlink(path) + except FileNotFoundError: continue + for name in sorted(files): + path = os.path.join(directory, name); rel = os.path.relpath(path, root).replace(os.sep, "/") + parts = rel.split("/") + ignored_parent = any(part in ignored_dirs or part.endswith(".egg-info") for part in parts[:-1]) + ignored_file = name in ignored_files or name.startswith(ignored_prefixes) or name.endswith(ignored_suffixes) + if (ignored_parent or ignored_file) and not declared(rel): continue + try: + stat = os.lstat(path) + metadata = "%o:%s:%s:%s:%s" % (stat.st_mode & 0o777, stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns, stat.st_ino) + if os.path.islink(path): value = "link:" + metadata + ":" + os.readlink(path) + elif os.path.isfile(path): + value = "file:" + metadata + else: continue + except FileNotFoundError: continue + out[rel] = value +print(json.dumps(out, sort_keys=True)) +""".strip() + + +def snapshot_remote_workspace(context: ToolContext) -> dict[str, str]: + backend = context.workspace_backend + if backend is None: + raise RuntimeError("remote ownership audit requires a workspace backend") + value = backend.run_json_helper( + _REMOTE_SNAPSHOT_SCRIPT, + { + "root": context.execution_workspace_root or "/workspace", + "ignored_dirs": sorted(_IGNORED_DIRECTORY_NAMES), + "ignored_files": sorted(_IGNORED_FILE_NAMES), + "ignored_prefixes": sorted(_IGNORED_FILE_PREFIXES), + "ignored_suffixes": sorted(_IGNORED_FILE_SUFFIXES), + "declared_paths": sorted( + {path for _, path in _declared_task_paths(context)} + ), + "control_only": not ownership_enforced(context), + }, + ) + if not isinstance(value, dict): + raise RuntimeError("remote ownership audit returned an invalid snapshot") + return {str(path): str(fingerprint) for path, fingerprint in value.items()} + + +def changed_paths(before: dict[str, str], after: dict[str, str]) -> list[str]: + return sorted( + path + for path in set(before) | set(after) + if before.get(path) != after.get(path) + ) + + +def audit_changed_paths( + context: ToolContext, + *, + tool_name: str, + before: dict[str, str], + after: dict[str, str], + control_backup: ControlStateBackup | None = None, +) -> list[str]: + if not bash_audit_required(context): + return [] + changed = changed_paths(before, after) + unauthorized: list[str] = [] + for path in changed: + if not ownership_enforced(context): + if _is_control_path(path): + unauthorized.append(path) + continue + if path_is_owned(context, path): + continue + # Only additions qualify for the convenient tests/test_*.py scratch rule. + # A repository test present in the pre-command snapshot remains protected, + # including against edits and deletion. + if ( + _is_generated_test_candidate(path) + and path not in before + and path in after + ): + _remember_generated_test(context, path) + continue + unauthorized.append(path) + if unauthorized: + if control_backup is None: + raise RuntimeError( + "refusing to report an unauthorized Bash mutation without a rollback backup" + ) + # Restore before recording the violation: for .clawd this ensures the audit + # event is appended to the genuine event stream. For delivery files it keeps + # the best-known workspace intact for an explicit repair plan. + control_backup.restore(unauthorized) + violation = record_ownership_violation(context, tool_name, unauthorized) + raise ToolPermissionError(violation["error"]) + return changed diff --git a/src/tool_system/remote_tools.py b/src/tool_system/remote_tools.py new file mode 100644 index 0000000..8624d05 --- /dev/null +++ b/src/tool_system/remote_tools.py @@ -0,0 +1,529 @@ +from __future__ import annotations + +import base64 +import difflib +import json +import mimetypes +from pathlib import PurePosixPath +from typing import Any + +from .context import ToolContext +from .diff_utils import unified_diff_hunks +from .errors import ToolExecutionError, ToolInputError, ToolPermissionError +from .ownership import ( + audit_changed_paths, + bash_audit_required, + control_state_guard, + require_owned_path, + snapshot_remote_workspace, +) +from .protocol import ToolResult +from .registry import ToolSpec +from .tools.bash import ( + _DANGEROUS_PATTERNS, + _destructive_delete_targets, + _safe_delete_target, + _strict_protocol_v2_team, + _truncate, + _try_extract_cd, +) +from .tools.edit import FileEditTool +from .tools.glob import GlobTool +from .tools.grep import GrepTool +from .tools.read import FileReadTool +from .tools.write import FileWriteTool + + +def _backend(context: ToolContext) -> Any: + if context.workspace_backend is None: + raise ToolExecutionError("remote workspace backend is unavailable") + return context.workspace_backend + + +class RemoteBashTool: + def spec(self) -> ToolSpec: + return ToolSpec( + name="Bash", + description="Execute a shell command inside the remote sandbox workspace.", + input_schema={ + "type": "object", + "additionalProperties": False, + "properties": { + "command": {"type": "string"}, + "cwd": {"type": "string"}, + "timeout_s": {"type": "integer"}, + }, + "required": ["command"], + }, + is_destructive=True, + max_result_size_chars=50_000, + ) + + def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: + command = tool_input["command"] + if not isinstance(command, str) or not command.strip(): + raise ToolInputError("command must be a non-empty string") + if "\x00" in command: + raise ToolInputError("command contains NUL byte") + for pattern in _DANGEROUS_PATTERNS: + if pattern.search(command): + raise ToolPermissionError("refusing to run potentially dangerous command") + delete_targets = _destructive_delete_targets(command) + if any(target.strip().rstrip("/") in {"", "/"} for target in delete_targets): + raise ToolPermissionError("refusing to run potentially dangerous command") + if _strict_protocol_v2_team(context): + delete_target = next( + ( + target + for target in delete_targets + if not _safe_delete_target(target) + ), + None, + ) + if delete_target is not None: + raise ToolPermissionError( + "strict protocol v2 preserves the best workspace and refuses " + f"recursive deletion of deliverable path {delete_target!r}; edit " + "the owned files in place or use TeamReplan for a recoverable plan " + "replacement. TeamAbort is terminal and is not a restart operation" + ) + + backend = _backend(context) + explicit_cwd = tool_input.get("cwd") + if explicit_cwd is not None: + if not isinstance(explicit_cwd, str) or not explicit_cwd.startswith("/"): + raise ToolInputError("cwd must be an absolute path when provided") + try: + cwd = context.resolve_execution_path(explicit_cwd) + except ValueError as exc: + raise ToolPermissionError(str(exc)) from exc + else: + cwd = context.execution_cwd or context.execution_workspace_root or "/workspace" + + cd_target = _try_extract_cd(command) + if cd_target is not None and len(command.strip().splitlines()) == 1: + try: + next_dir = backend.resolve_path( + str(cd_target), cwd=cwd, local_root=context.workspace_root + ) + except ValueError as exc: + raise ToolPermissionError(str(exc)) from exc + stat = backend.stat(next_dir) + if not stat.exists or not stat.is_dir: + return ToolResult( + name="Bash", + output={"error": f"directory does not exist: {next_dir}"}, + is_error=True, + ) + context.execution_cwd = next_dir + return ToolResult( + name="Bash", + output={"cwd": next_dir, "stdout": "", "stderr": ""}, + ) + + timeout_s = tool_input.get("timeout_s", 60) + if not isinstance(timeout_s, int) or timeout_s < 1 or timeout_s > 600: + raise ToolInputError("timeout_s must be an integer between 1 and 600") + with context.mutation_lock: + with control_state_guard(context) as control_backup: + before = ( + snapshot_remote_workspace(context) + if bash_audit_required(context) + else None + ) + try: + result = backend.exec(command, cwd=cwd, timeout_s=timeout_s) + finally: + if before is not None: + audit_changed_paths( + context, + tool_name="Bash", + before=before, + after=snapshot_remote_workspace(context), + control_backup=control_backup, + ) + return ToolResult( + name="Bash", + output={ + "cwd": cwd, + "exit_code": result.exit_code, + "stdout": _truncate(result.stdout or ""), + "stderr": _truncate(result.stderr or ""), + }, + is_error=result.exit_code != 0, + ) + + +class RemoteFileReadTool: + def spec(self) -> ToolSpec: + spec = FileReadTool().spec() + return ToolSpec( + name=spec.name, + description="Read a file from the remote sandbox workspace.", + input_schema=spec.input_schema, + aliases=spec.aliases, + is_read_only=spec.is_read_only, + max_result_size_chars=spec.max_result_size_chars, + ) + + def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: + file_path = tool_input["file_path"] + if not isinstance(file_path, str): + raise ToolInputError("file_path must be a string") + if file_path.startswith(("http://", "https://")): + return ToolResult( + name="Read", + output={"error": "The 'Read' tool is for sandbox files; use WebFetch for URLs"}, + is_error=True, + ) + limit = tool_input.get("limit", 2000) + offset = tool_input.get("offset", 1) + pages = tool_input.get("pages") + if not isinstance(limit, int) or limit < 1 or limit > 2000: + raise ToolInputError("limit must be an integer between 1 and 2000") + if not isinstance(offset, int) or offset < 1: + raise ToolInputError("offset must be an integer >= 1") + if pages is not None and not isinstance(pages, str): + raise ToolInputError("pages must be a string when provided") + try: + path = context.resolve_execution_path(file_path) + except ValueError as exc: + raise ToolPermissionError(str(exc)) from exc + backend = _backend(context) + stat = backend.stat(path) + if not stat.exists: + return ToolResult(name="Read", output={"error": f"file not found: {path}"}, is_error=True) + if stat.is_dir: + return ToolResult(name="Read", output={"error": f"path is a directory: {path}"}, is_error=True) + if ( + context.was_remote_file_read_and_unchanged(path) + and pages is None + and "offset" not in tool_input + and "limit" not in tool_input + ): + return ToolResult( + name="Read", output={"type": "file_unchanged", "file": {"filePath": path}} + ) + + suffix = PurePosixPath(path).suffix.lower() + if suffix in {".png", ".jpg", ".jpeg", ".gif", ".webp", ".pdf"}: + data = backend.read_bytes(path) + if len(data) > 5 * 1024 * 1024: + return ToolResult( + name="Read", + output={"error": f"file too large to inline: {path} ({len(data)} bytes)"}, + is_error=True, + ) + context.mark_remote_file_read(path) + if suffix == ".pdf": + if pages is not None and pages.strip(): + return ToolResult( + name="Read", + output={"error": "PDF page-range reads are not supported; omit pages"}, + is_error=True, + ) + return ToolResult( + name="Read", + output={ + "type": "pdf", + "file": { + "filePath": path, + "base64": base64.b64encode(data).decode("ascii"), + "originalSize": len(data), + }, + }, + ) + mime, _ = mimetypes.guess_type(path) + return ToolResult( + name="Read", + output={ + "type": "image", + "file": { + "base64": base64.b64encode(data).decode("ascii"), + "type": mime or "image/png", + "originalSize": len(data), + "filePath": path, + }, + }, + ) + + try: + text = backend.read_text(path) + except Exception as exc: + raise ToolExecutionError(str(exc)) from exc + context.mark_remote_file_read(path) + if suffix == ".ipynb": + try: + cells = json.loads(text).get("cells") + except Exception as exc: + return ToolResult( + name="Read", + output={"error": f"failed to parse notebook: {exc}"}, + is_error=True, + ) + if not isinstance(cells, list): + return ToolResult(name="Read", output={"error": "no cells found"}, is_error=True) + return ToolResult( + name="Read", output={"type": "notebook", "file": {"filePath": path, "cells": cells}} + ) + + lines = text.splitlines() + sliced = lines[offset - 1 : offset - 1 + limit] + numbered = "\n".join(f"{index + offset}\t{line}" for index, line in enumerate(sliced)) + return ToolResult( + name="Read", + output={ + "type": "text", + "file": { + "filePath": path, + "content": numbered, + "numLines": len(sliced), + "startLine": offset, + "totalLines": len(lines), + }, + }, + ) + + +class RemoteFileWriteTool: + def spec(self) -> ToolSpec: + return FileWriteTool().spec() + + def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: + file_path = tool_input["file_path"] + content = tool_input["content"] + if not isinstance(file_path, str): + raise ToolInputError("file_path must be a string") + if not isinstance(content, str): + raise ToolInputError("content must be a string") + try: + path = context.resolve_execution_path(file_path) + except ValueError as exc: + raise ToolPermissionError(str(exc)) from exc + backend = _backend(context) + with context.mutation_lock: + require_owned_path( + context, path, tool_name="Write", execution_path=True + ) + stat = backend.stat(path) + original: str | None = None + if stat.exists: + if stat.is_dir: + raise ToolInputError(f"path is a directory: {path}") + if not context.was_remote_file_read_and_unchanged(path): + raise ToolInputError( + "refusing to overwrite: file must be read first and unchanged since last read" + ) + original = backend.read_text(path) + backend.write_text(path, content) + context.mark_remote_file_read(path) + diff = list( + difflib.unified_diff( + (original or "").splitlines(keepends=True), + content.splitlines(keepends=True), + fromfile=path, + tofile=path, + n=3, + lineterm="", + ) + ) + return ToolResult( + name="Write", + output={ + "type": "update" if original is not None else "create", + "filePath": path, + "content": content, + "structuredPatch": unified_diff_hunks(diff), + "originalFile": original, + }, + ) + + +class RemoteFileEditTool: + def spec(self) -> ToolSpec: + return FileEditTool().spec() + + def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: + file_path = tool_input["file_path"] + old = tool_input["old_string"] + new = tool_input["new_string"] + replace_all = bool(tool_input.get("replace_all", False)) + if not isinstance(file_path, str): + raise ToolInputError("file_path must be a string") + if not isinstance(old, str) or not isinstance(new, str): + raise ToolInputError("old_string/new_string must be strings") + try: + path = context.resolve_execution_path(file_path) + except ValueError as exc: + raise ToolPermissionError(str(exc)) from exc + backend = _backend(context) + with context.mutation_lock: + require_owned_path( + context, path, tool_name="Edit", execution_path=True + ) + stat = backend.stat(path) + if not stat.exists or not stat.is_file: + raise ToolInputError(f"file does not exist: {path}") + if not context.was_remote_file_read_and_unchanged(path): + raise ToolInputError( + "refusing to edit: file must be read first and unchanged since last read" + ) + original = backend.read_text(path) + count = original.count(old) + if count == 0: + raise ToolInputError("old_string not found in file") + if count > 1 and not replace_all: + raise ToolInputError( + "old_string is not unique; provide a larger old_string or set replace_all=true" + ) + updated = original.replace(old, new) if replace_all else original.replace(old, new, 1) + backend.write_text(path, updated) + context.mark_remote_file_read(path) + diff = list( + difflib.unified_diff( + original.splitlines(keepends=True), + updated.splitlines(keepends=True), + fromfile=path, + tofile=path, + n=3, + lineterm="", + ) + ) + return ToolResult( + name="Edit", + output={ + "filePath": path, + "oldString": old, + "newString": new, + "originalFile": original, + "structuredPatch": unified_diff_hunks(diff), + "userModified": False, + "replaceAll": replace_all, + }, + ) + + +class RemoteGlobTool: + def spec(self) -> ToolSpec: + return GlobTool().spec() + + def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: + pattern = tool_input["pattern"] + base = tool_input.get("path") + limit = tool_input.get("limit", 100) + if not isinstance(pattern, str) or not pattern: + raise ToolInputError("pattern must be a non-empty string") + if base is not None and (not isinstance(base, str) or not base): + raise ToolInputError("path must be a non-empty string when provided") + if not isinstance(limit, int) or limit < 1 or limit > 10_000: + raise ToolInputError("limit must be an integer between 1 and 10000") + try: + root = context.resolve_execution_path(base) if base else (context.execution_cwd or "/workspace") + except ValueError as exc: + raise ToolPermissionError(str(exc)) from exc + script = """ +import base64,glob,json,os,sys +p=json.loads(base64.b64decode(sys.argv[1])); root=p['root']; pattern=p['pattern']; limit=p['limit'] +matches=[x for x in glob.glob(os.path.join(root,pattern),recursive=True) if os.path.isfile(x)] +matches.sort(key=lambda x: os.stat(x).st_mtime_ns,reverse=True) +print(json.dumps({'filenames':matches[:limit],'numFiles':min(len(matches),limit),'truncated':len(matches)>limit})) +""".strip() + output = _backend(context).run_json_helper( + script, {"root": root, "pattern": pattern, "limit": limit} + ) + return ToolResult(name="Glob", output=output) + + +class RemoteGrepTool: + def spec(self) -> ToolSpec: + return GrepTool().spec() + + def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: + pattern = tool_input["pattern"] + if not isinstance(pattern, str) or pattern == "": + raise ToolInputError("pattern must be a non-empty string") + base = tool_input.get("path") + glob_pattern = tool_input.get("glob") + type_name = tool_input.get("type") + output_mode = tool_input.get("output_mode", "files_with_matches") + head_limit = tool_input.get("head_limit") + offset = tool_input.get("offset", 0) + if base is not None and (not isinstance(base, str) or not base): + raise ToolInputError("path must be a non-empty string when provided") + if glob_pattern is not None and not isinstance(glob_pattern, str): + raise ToolInputError("glob must be a string when provided") + if type_name is not None and not isinstance(type_name, str): + raise ToolInputError("type must be a string when provided") + if output_mode not in {"content", "files_with_matches", "count"}: + raise ToolInputError("invalid output_mode") + if head_limit is not None and (not isinstance(head_limit, int) or head_limit < 0): + raise ToolInputError("head_limit must be an integer >= 0") + if not isinstance(offset, int) or offset < 0: + raise ToolInputError("offset must be an integer >= 0") + try: + root = context.resolve_execution_path(base) if base else (context.execution_cwd or "/workspace") + except ValueError as exc: + raise ToolPermissionError(str(exc)) from exc + stat = _backend(context).stat(root) + if not stat.exists: + raise ToolInputError(f"path does not exist: {root}") + payload = dict(tool_input) + payload["root"] = root + script = r""" +import base64,fnmatch,json,os,re,sys +p=json.loads(base64.b64decode(sys.argv[1])); root=p['root']; pattern=p['pattern'] +mode=p.get('output_mode','files_with_matches'); flags=re.MULTILINE +if p.get('-i'): flags|=re.IGNORECASE +if p.get('multiline'): flags|=re.DOTALL +try: regex=re.compile(pattern,flags) +except re.error as e: + print(json.dumps({'__error__':f'invalid regex: {e}'})); raise SystemExit +paths=[] +if os.path.isfile(root): paths=[root] +else: + for d,dirs,files in os.walk(root): + dirs[:]=[x for x in dirs if x not in {'.git','.svn','.hg','.bzr','.jj','.sl'}] + paths.extend(os.path.join(d,x) for x in files) +globpat=p.get('glob'); typename=p.get('type') +if globpat: paths=[x for x in paths if fnmatch.fnmatch(os.path.basename(x),globpat) or fnmatch.fnmatch(x,globpat)] +if typename: paths=[x for x in paths if os.path.splitext(x)[1].lower().lstrip('.')==typename.lower()] +matched=[]; lines=[]; total=0 +for path in paths: + try: + text=open(path,encoding='utf-8',errors='replace').read() + except Exception: continue + if not regex.search(text): continue + matched.append(path) + if mode=='content': + for n,line in enumerate(text.splitlines(),1): + found=list(regex.finditer(line)) + if not found: continue + total+=len(found); prefix=f'{path}:{n}:' if p.get('-n') else f'{path}:'; lines.append(prefix+line) + elif mode=='count': total+=len(list(regex.finditer(text))) +offset=p.get('offset',0); head=p.get('head_limit'); default=250 if head is None else head +def page(xs): + if head==0: return xs[offset:],None + sliced=xs[offset:offset+default]; return sliced,default if len(xs)-offset>default else None +if mode=='content': + items,lim=page(lines); out={'mode':mode,'numFiles':len(matched),'filenames':matched,'content':'\n'.join(items),'numLines':len(items),'appliedOffset':offset} +elif mode=='count': + items,lim=page(matched); out={'mode':mode,'numFiles':len(matched),'filenames':items,'numMatches':total,'appliedOffset':offset} +else: + items,lim=page(matched); out={'mode':mode,'numFiles':len(matched),'filenames':items,'appliedOffset':offset} +if lim is not None: out['appliedLimit']=lim +print(json.dumps(out)) +""".strip() + output = _backend(context).run_json_helper(script, payload) + if "__error__" in output: + raise ToolInputError(output["__error__"]) + return ToolResult(name="Grep", output=output) + + +REMOTE_WORKSPACE_TOOLS = ( + RemoteBashTool, + RemoteFileReadTool, + RemoteFileWriteTool, + RemoteFileEditTool, + RemoteGlobTool, + RemoteGrepTool, +) diff --git a/src/tool_system/tools/__init__.py b/src/tool_system/tools/__init__.py index e1d61dc..dabcdf8 100644 --- a/src/tool_system/tools/__init__.py +++ b/src/tool_system/tools/__init__.py @@ -12,16 +12,17 @@ from .lsp import LSPTool from .mcp import MCPTool from .mcp_resources import ListMcpResourcesTool, ReadMcpResourceTool -from .misc import NotebookEditTool, PowerShellTool, REPLTool, RemoteTriggerTool, SendMessageTool, TestingPermissionTool +from .misc import NotebookEditTool, PowerShellTool, ReadMessagesTool, REPLTool, RemoteTriggerTool, SendMessageTool, TestingPermissionTool from .plan_mode import EnterPlanModeTool, ExitPlanModeTool from .read import FileReadTool from .send_user_message import SendUserMessageTool from .sleep import SleepTool from .skill import SkillTool from .structured_output import StructuredOutputTool -from .team import TeamCreateTool, TeamDeleteTool +from .team import TeamAbortTool, TeamCancelTool, TeamConfigureTool, TeamCreateTool, TeamDeleteTool, TeamIntegrateTool, TeamReplanTool, TeamResumeTool, TeamVerifyTool, TeammateCreateTool, TeammateResumeTool, TeammateStopTool, TeamRunTool +from .team_plan import TeamPlanTool from .task_stop import TaskStopTool -from .tasks_v2 import TaskCreateTool, TaskGetTool, TaskListTool, TaskOutputTool, TaskUpdateTool +from .tasks_v2 import TaskCreateTool, TaskGetTool, TaskListTool, TaskOutputTool, TaskRetryTool, TaskUpdateTool from .todo_write import TodoWriteTool from .tool_search import ToolSearchTool from .web_fetch import WebFetchTool @@ -55,17 +56,31 @@ "PowerShellTool", "REPLTool", "RemoteTriggerTool", + "ReadMessagesTool", "SendMessageTool", "SendUserMessageTool", "SkillTool", "SleepTool", "StructuredOutputTool", "TeamCreateTool", + "TeamConfigureTool", + "TeamAbortTool", + "TeamCancelTool", "TeamDeleteTool", + "TeamIntegrateTool", + "TeamPlanTool", + "TeamReplanTool", + "TeamResumeTool", + "TeammateCreateTool", + "TeammateResumeTool", + "TeammateStopTool", + "TeamRunTool", + "TeamVerifyTool", "TaskCreateTool", "TaskGetTool", "TaskListTool", "TaskOutputTool", + "TaskRetryTool", "TaskStopTool", "TaskUpdateTool", "TestingPermissionTool", diff --git a/src/tool_system/tools/bash.py b/src/tool_system/tools/bash.py index 8d59f51..b04761a 100644 --- a/src/tool_system/tools/bash.py +++ b/src/tool_system/tools/bash.py @@ -1,13 +1,21 @@ from __future__ import annotations +import ast import re import shlex import subprocess -from pathlib import Path +import sys +from pathlib import Path, PurePosixPath from typing import Any from ..context import ToolContext from ..errors import ToolInputError, ToolPermissionError +from ..ownership import ( + audit_changed_paths, + bash_audit_required, + control_state_guard, + snapshot_local_workspace, +) from ..protocol import ToolResult from ..registry import ToolSpec @@ -18,11 +26,342 @@ re.compile(r"\breboot\b", re.IGNORECASE), re.compile(r"\bmkfs\b", re.IGNORECASE), re.compile(r"\bdd\b\s+if=", re.IGNORECASE), - re.compile(r"\brm\b.*\s+-rf\s+/\s*$", re.IGNORECASE), - re.compile(r"\brm\b.*\s+-rf\s+/\s+"), re.compile(r":\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:", re.IGNORECASE), ] +_SAFE_RECURSIVE_DELETE_NAMES = { + ".eggs", + ".mypy_cache", + ".nox", + ".pytest_cache", + ".ruff_cache", + ".tox", + ".venv", + "__pycache__", + "build", + "dist", + "node_modules", + "venv", +} + + +def _strict_protocol_v2_team(context: ToolContext) -> bool: + team = context.team or {} + settings = team.get("settings") if isinstance(team.get("settings"), dict) else {} + quality = ( + settings.get("quality_gates") + if isinstance(settings.get("quality_gates"), dict) + else {} + ) + versions = [1] + for raw in ( + team.get("protocol_version"), + settings.get("protocol_version"), + quality.get("protocol_version"), + ): + try: + versions.append(int(raw)) + except (TypeError, ValueError): + continue + return bool(quality.get("strict") and max(versions) >= 2) + + +_SHELL_NAMES = {"bash", "dash", "ksh", "sh", "zsh"} +_SHELL_CONTROL_PREFIXES = { + "!", + "do", + "elif", + "else", + "if", + "then", + "time", + "until", + "while", +} +_COMMAND_WRAPPERS = {"command", "exec", "nohup"} +_HEREDOC_PATTERN = re.compile( + r"(?-)?\s*(?:'(?P[^']+)'|\"(?P[^\"]+)\"|(?P[A-Za-z0-9_]+))" +) + + +def _strip_heredoc_bodies(source: str) -> str: + """Keep shell command lines while removing heredoc data bodies. + + A destructive-looking string written to a source file is data, not an executed + command. Interpreter stdin heredocs are intentionally outside this lexical + guard; teammate post-execution rollback remains the final safety boundary. + """ + + kept: list[str] = [] + pending: list[tuple[str, bool]] = [] + for line in source.splitlines(keepends=True): + if pending: + delimiter, strip_tabs = pending[0] + candidate = line.rstrip("\r\n") + if strip_tabs: + candidate = candidate.lstrip("\t") + if candidate == delimiter: + pending.pop(0) + continue + kept.append(line) + for match in _HEREDOC_PATTERN.finditer(line): + delimiter = ( + match.group("single") + or match.group("double") + or match.group("plain") + ) + pending.append((delimiter, bool(match.group("strip")))) + return "".join(kept) + + +def _normalize_shell_newlines(source: str) -> str: + """Turn executable newlines into command separators without touching quotes.""" + + out: list[str] = [] + quote: str | None = None + escaped = False + comment = False + for index, char in enumerate(source): + if comment: + if char == "\n": + comment = False + out.append(" ; ") + continue + if escaped: + escaped = False + if char != "\n": + out.extend(("\\", char)) + continue + if char == "\\" and quote != "'": + escaped = True + continue + if quote: + out.append(char) + if char == quote: + quote = None + continue + if char in {"'", '"'}: + quote = char + out.append(char) + continue + if char == "#" and ( + index == 0 or source[index - 1].isspace() or source[index - 1] in ";|&()" + ): + comment = True + continue + out.append(" ; " if char == "\n" else char) + return "".join(out) + + +def _shell_command_argvs(source: str) -> list[list[str]]: + normalized = _normalize_shell_newlines(_strip_heredoc_bodies(source)) + lexer = shlex.shlex( + normalized, + posix=True, + punctuation_chars=";&|()", + ) + lexer.whitespace_split = True + lexer.commenters = "" + try: + tokens = list(lexer) + except ValueError: + return [] + commands: list[list[str]] = [] + current: list[str] = [] + for token in tokens: + if token and all(character in ";&|()" for character in token): + if current: + commands.append(current) + current = [] + continue + current.append(token) + if current: + commands.append(current) + return commands + + +def _unwrap_command(argv: list[str]) -> list[str]: + remaining = list(argv) + while remaining: + first = remaining[0] + if first in _SHELL_CONTROL_PREFIXES or re.fullmatch( + r"[A-Za-z_][A-Za-z0-9_]*=.*", first + ): + remaining.pop(0) + continue + name = PurePosixPath(first).name + if name in _COMMAND_WRAPPERS: + remaining.pop(0) + continue + if name == "env": + remaining.pop(0) + while remaining and ( + remaining[0].startswith("-") + or re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*=.*", remaining[0]) + ): + remaining.pop(0) + continue + break + return remaining + + +def _safe_delete_target(target: str) -> bool: + normalized = target.strip().replace("\\", "/").rstrip("/") or "/" + if normalized == "/" or any(marker in normalized for marker in "$`*?[]{}"): + return False + parts = PurePosixPath(normalized).parts + return bool( + any(part in _SAFE_RECURSIVE_DELETE_NAMES for part in parts) + or any(part.endswith(".egg-info") for part in parts) + or ".clawd/task-tests/" in normalized.lstrip("/") + "/" + ) + + +def _rm_delete_targets(argv: list[str]) -> list[str]: + recursive = False + targets: list[str] = [] + options_done = False + for argument in argv[1:]: + if not options_done and argument == "--": + options_done = True + continue + if not options_done and argument.startswith("-"): + if argument == "--recursive" or ( + not argument.startswith("--") + and any(flag in argument[1:] for flag in "rR") + ): + recursive = True + continue + targets.append(argument) + return targets if recursive else [] + + +def _find_delete_targets(argv: list[str]) -> list[str]: + if "-delete" not in argv[1:]: + return [] + roots: list[str] = [] + for argument in argv[1:]: + if argument == "-delete" or argument.startswith("-") or argument in {"!", "("}: + if roots or argument == "-delete": + break + continue + roots.append(argument) + return roots or ["."] + + +def _git_clean_targets(argv: list[str]) -> list[str]: + index = 1 + git_root = "." + while index < len(argv): + argument = argv[index] + if argument == "-C" and index + 1 < len(argv): + git_root = argv[index + 1] + index += 2 + continue + if argument.startswith("-"): + index += 1 + continue + break + if index >= len(argv) or argv[index] != "clean": + return [] + clean_arguments = argv[index + 1 :] + if any( + argument == "--dry-run" + or ( + argument.startswith("-") + and not argument.startswith("--") + and "n" in argument[1:] + ) + for argument in clean_arguments + ): + return [] + return [git_root] + + +def _python_rmtree_targets(argv: list[str]) -> list[str]: + code: str | None = None + for index, argument in enumerate(argv[1:]): + if argument == "-c" and index + 2 < len(argv): + code = argv[index + 2] + break + if code is None: + return [] + try: + tree = ast.parse(code) + except SyntaxError: + return [] + module_aliases = {"shutil"} + function_aliases: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + if alias.name == "shutil": + module_aliases.add(alias.asname or alias.name) + elif isinstance(node, ast.ImportFrom) and node.module == "shutil": + for alias in node.names: + if alias.name == "rmtree": + function_aliases.add(alias.asname or alias.name) + targets: list[str] = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + function = node.func + matched = bool( + isinstance(function, ast.Attribute) + and function.attr == "rmtree" + and isinstance(function.value, ast.Name) + and function.value.id in module_aliases + ) or bool(isinstance(function, ast.Name) and function.id in function_aliases) + if not matched: + continue + if node.args and isinstance(node.args[0], ast.Constant) and isinstance( + node.args[0].value, str + ): + targets.append(node.args[0].value) + else: + targets.append("") + return targets + + +def _destructive_delete_targets(command: str, *, depth: int = 0) -> list[str]: + if depth > 4: + return [""] + targets: list[str] = [] + for raw_argv in _shell_command_argvs(command): + argv = _unwrap_command(raw_argv) + if not argv: + continue + name = PurePosixPath(argv[0]).name.casefold() + if name == "rm": + targets.extend(_rm_delete_targets(argv)) + elif name == "find": + targets.extend(_find_delete_targets(argv)) + elif name == "git": + targets.extend(_git_clean_targets(argv)) + elif name in _SHELL_NAMES: + for index, argument in enumerate(argv[1:]): + if argument.startswith("-") and "c" in argument[1:] and index + 2 < len(argv): + targets.extend( + _destructive_delete_targets(argv[index + 2], depth=depth + 1) + ) + break + elif re.fullmatch(r"python(?:\d+(?:\.\d+)*)?", name): + targets.extend(_python_rmtree_targets(argv)) + return targets + + +def _unsafe_recursive_delete_target(command: str) -> str | None: + """Return the first non-generated target of a destructive command.""" + + return next( + ( + target + for target in _destructive_delete_targets(command) + if not _safe_delete_target(target) + ), + None, + ) + def _truncate(s: str, limit: int = 20000) -> str: if len(s) <= limit: @@ -38,7 +377,7 @@ def _try_extract_cd(command: str) -> Path | None: parts = shlex.split(stripped, posix=True) except ValueError: return None - if len(parts) >= 2 and parts[0] == "cd": + if len(parts) == 2 and parts[0] == "cd": return Path(parts[1]) return None @@ -47,7 +386,10 @@ class BashTool: def spec(self) -> ToolSpec: return ToolSpec( name="Bash", - description="Execute a shell command.", + description=( + "Execute a shell command. The active Clawd Python interpreter is available as " + "$CLAWD_PYTHON and its directory is prepended to PATH." + ), input_schema={ "type": "object", "additionalProperties": False, @@ -73,6 +415,26 @@ def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: if pat.search(command): raise ToolPermissionError("refusing to run potentially dangerous command") + delete_targets = _destructive_delete_targets(command) + if any(target.strip().rstrip("/") in {"", "/"} for target in delete_targets): + raise ToolPermissionError("refusing to run potentially dangerous command") + if _strict_protocol_v2_team(context): + delete_target = next( + ( + target + for target in delete_targets + if not _safe_delete_target(target) + ), + None, + ) + if delete_target is not None: + raise ToolPermissionError( + "strict protocol v2 preserves the best workspace and refuses " + f"recursive deletion of deliverable path {delete_target!r}; edit " + "the owned files in place or use TeamReplan for a recoverable plan " + "replacement. TeamAbort is terminal and is not a restart operation" + ) + explicit_cwd = tool_input.get("cwd") if explicit_cwd is not None: if not isinstance(explicit_cwd, str) or not explicit_cwd.startswith("/"): @@ -94,13 +456,36 @@ def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: if not isinstance(timeout_s, int) or timeout_s < 1 or timeout_s > 600: raise ToolInputError("timeout_s must be an integer between 1 and 600") - completed = subprocess.run( - ["bash", "-lc", command], - cwd=str(cwd), - capture_output=True, - text=True, - timeout=timeout_s, + python_executable = str(Path(sys.executable).absolute()) + python_bin = str(Path(python_executable).parent) + environment_prefix = ( + f"export PATH={shlex.quote(python_bin)}:$PATH\n" + f"export CLAWD_PYTHON={shlex.quote(python_executable)}\n" ) + with context.mutation_lock: + with control_state_guard(context) as control_backup: + before = ( + snapshot_local_workspace(context) + if bash_audit_required(context) + else None + ) + try: + completed = subprocess.run( + ["bash", "-lc", environment_prefix + command], + cwd=str(cwd), + capture_output=True, + text=True, + timeout=timeout_s, + ) + finally: + if before is not None: + audit_changed_paths( + context, + tool_name="Bash", + before=before, + after=snapshot_local_workspace(context), + control_backup=control_backup, + ) stdout = _truncate(completed.stdout or "") stderr = _truncate(completed.stderr or "") @@ -111,4 +496,3 @@ def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: "stderr": stderr, } return ToolResult(name="Bash", output=output, is_error=completed.returncode != 0) - diff --git a/src/tool_system/tools/edit.py b/src/tool_system/tools/edit.py index ae06f30..383a76e 100644 --- a/src/tool_system/tools/edit.py +++ b/src/tool_system/tools/edit.py @@ -8,6 +8,7 @@ from ..permission_handler import PermissionResult from ..protocol import ToolResult from ..diff_utils import unified_diff_hunks +from ..ownership import require_owned_path from ..registry import ToolSpec @@ -65,27 +66,31 @@ def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: path = context.ensure_allowed_path(file_path) - if not path.exists(): - raise ToolInputError(f"file does not exist: {path}") - if not context.was_file_read_and_unchanged(path): - raise ToolInputError("refusing to edit: file must be read first and unchanged since last read") + with context.mutation_lock: + require_owned_path(context, path, tool_name="Edit") + if not path.exists(): + raise ToolInputError(f"file does not exist: {path}") + if not context.was_file_read_and_unchanged(path): + raise ToolInputError( + "refusing to edit: file must be read first and unchanged since last read" + ) - original_file = path.read_text(encoding="utf-8", errors="replace") - count = original_file.count(old) - if count == 0: - raise ToolInputError("old_string not found in file") - if count > 1 and not replace_all: - raise ToolInputError("old_string is not unique; provide a larger old_string or set replace_all=true") + original_file = path.read_text(encoding="utf-8", errors="replace") + count = original_file.count(old) + if count == 0: + raise ToolInputError("old_string not found in file") + if count > 1 and not replace_all: + raise ToolInputError( + "old_string is not unique; provide a larger old_string or set replace_all=true" + ) - if replace_all: - updated = original_file.replace(old, new) - replaced = count - else: - updated = original_file.replace(old, new, 1) - replaced = 1 + if replace_all: + updated = original_file.replace(old, new) + else: + updated = original_file.replace(old, new, 1) - path.write_text(updated, encoding="utf-8") - context.mark_file_read(path) + path.write_text(updated, encoding="utf-8") + context.mark_file_read(path) before_lines = original_file.splitlines(keepends=True) after_lines = updated.splitlines(keepends=True) diff_lines = list( diff --git a/src/tool_system/tools/misc.py b/src/tool_system/tools/misc.py index 3490d17..c4fecc7 100644 --- a/src/tool_system/tools/misc.py +++ b/src/tool_system/tools/misc.py @@ -1,8 +1,11 @@ from __future__ import annotations import platform +import time +import uuid from typing import Any +from ...teammate.models import Message from ..context import ToolContext from ..errors import ToolInputError, ToolPermissionError from ..protocol import ToolResult @@ -13,7 +16,10 @@ class SendMessageTool: def spec(self) -> ToolSpec: return ToolSpec( name="SendMessage", - description="Send a message to another recipient (best-effort, local only).", + description=( + "Send and persist a direct message to any teammate or to the lead. " + "Peer-to-peer messages do not need to pass through the lead." + ), input_schema={ "type": "object", "additionalProperties": False, @@ -24,7 +30,7 @@ def spec(self) -> ToolSpec: }, "required": ["to", "message"], }, - is_read_only=True, + is_read_only=False, max_result_size_chars=100_000, ) @@ -36,8 +42,143 @@ def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: raise ToolInputError("to must be a non-empty string") if summary is not None and not isinstance(summary, str): raise ToolInputError("summary must be a string when provided") - context.outbox.append({"tool": "SendMessage", "to": to, "summary": summary, "message": message}) - return ToolResult(name="SendMessage", output={"success": True, "message": f"Message queued for {to}"}) + if context.team is None: + raise ToolInputError("SendMessage requires an active team") + + team_id = str(context.team["team_id"]) + lead_id = str(context.team["lead_agent_id"]) + recipient_name = to.strip() + if recipient_name.lower() == "lead" or recipient_name == lead_id: + recipient_id = lead_id + else: + recipient = context.team_store.find_agent(team_id, recipient_name) + if recipient is None: + raise ToolInputError(f"unknown message recipient: {recipient_name}") + recipient_id = recipient.agent_id + sender_id = context.actor_id or lead_id + if sender_id != lead_id and context.team_store.load_agent(team_id, sender_id) is None: + raise ToolInputError(f"unknown message sender: {sender_id}") + + persisted = Message( + message_id=uuid.uuid4().hex, + team_id=team_id, + sender_id=sender_id, + recipient_id=recipient_id, + content=message, + summary=summary, + ) + persisted.transition_to("delivered") + path = context.team_store.save_message(persisted) + context.team_store.append_event(team_id, "message.delivered", {"message": persisted.to_dict()}) + context.outbox.append( + { + "tool": "SendMessage", + "message_id": persisted.message_id, + "from": sender_id, + "to": recipient_id, + "summary": summary, + "message": message, + } + ) + return ToolResult( + name="SendMessage", + output={ + "success": True, + "message_id": persisted.message_id, + "sender_id": sender_id, + "recipient_id": recipient_id, + "status": persisted.status, + "message_file_path": str(path), + }, + ) + + +class ReadMessagesTool: + def spec(self) -> ToolSpec: + return ToolSpec( + name="ReadMessages", + description=( + "Read and consume newly delivered team messages for the current agent. " + "Teammates may use wait_s during parallel work. Leads should call TeamRun before " + "expecting an idle or newly created teammate to send messages." + ), + input_schema={ + "type": "object", + "additionalProperties": False, + "properties": {"wait_s": {"type": "number"}}, + }, + is_read_only=False, + max_result_size_chars=100_000, + strict=True, + ) + + def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: + if context.team is None: + raise ToolInputError("ReadMessages requires an active team") + wait_s = tool_input.get("wait_s", 0) + if isinstance(wait_s, bool) or not isinstance(wait_s, (int, float)): + raise ToolInputError("wait_s must be numeric") + if wait_s < 0 or wait_s > 60: + raise ToolInputError("wait_s must be between 0 and 60") + + team_id = str(context.team["team_id"]) + lead_id = str(context.team["lead_agent_id"]) + recipient_id = context.actor_id or lead_id + if recipient_id != lead_id and context.team_store.load_agent(team_id, recipient_id) is None: + raise ToolInputError(f"unknown message recipient: {recipient_id}") + + deadline = time.monotonic() + float(wait_s) + incoming = context.team_store.consume_messages(team_id, recipient_id) + if not incoming and wait_s > 0 and context.actor_id is None: + context.reload_team_state() + agents = context.team_store.list_agents(team_id) + running = any(agent.status == "running" for agent in agents) + in_progress = any( + task.get("status") == "in_progress" for task in context.tasks.values() + ) + if not running and not in_progress: + has_tasks = bool(context.tasks) + next_tool = "TeamRun" if has_tasks else "TaskCreate" + instruction = ( + "Call TeamRun to execute the pending tasks before waiting for worker messages." + if has_tasks + else "Create a teammate-owned task with TaskCreate, then call TeamRun." + ) + return ToolResult( + name="ReadMessages", + output={ + "messages": [], + "wait_skipped": True, + "warning": "No teammate is currently running; waiting cannot produce a new message.", + "next_required_actions": [ + {"tool": next_tool, "instruction": instruction} + ], + }, + ) + while not incoming and time.monotonic() < deadline: + time.sleep(min(0.1, max(0.0, deadline - time.monotonic()))) + incoming = context.team_store.consume_messages(team_id, recipient_id) + + names = { + agent.agent_id: agent.name for agent in context.team_store.list_agents(team_id) + } + names[lead_id] = "lead" + return ToolResult( + name="ReadMessages", + output={ + "messages": [ + { + "message_id": message.message_id, + "from": names.get(message.sender_id, message.sender_id), + "sender_id": message.sender_id, + "summary": message.summary, + "message": message.content, + "status": message.status, + } + for message in incoming + ] + }, + ) class RemoteTriggerTool: @@ -117,4 +258,3 @@ def spec(self) -> ToolSpec: def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: return ToolResult(name="TestingPermission", output="TestingPermission executed successfully", content_type="text") - diff --git a/src/tool_system/tools/tasks_v2.py b/src/tool_system/tools/tasks_v2.py index 0f6607a..118481c 100644 --- a/src/tool_system/tools/tasks_v2.py +++ b/src/tool_system/tools/tasks_v2.py @@ -1,46 +1,146 @@ from __future__ import annotations import uuid +import re from typing import Any from ..context import ToolContext from ..errors import ToolInputError from ..protocol import ToolResult from ..registry import ToolSpec +from ...teammate.models import TeamTask, utc_now -_TASK_STATUSES = {"pending", "in_progress", "completed"} +_TASK_STATUSES = TeamTask.STATUSES def _new_task_id() -> str: return uuid.uuid4().hex[:12] +def _team_protocol_version(context: ToolContext) -> int: + team = context.team or {} + settings = team.get("settings") if isinstance(team.get("settings"), dict) else {} + quality = ( + settings.get("quality_gates") + if isinstance(settings.get("quality_gates"), dict) + else {} + ) + versions = [1] + for raw in ( + team.get("protocol_version"), + settings.get("protocol_version"), + quality.get("protocol_version"), + ): + try: + versions.append(int(raw)) + except (TypeError, ValueError): + continue + return max(versions) + + +def _require_legacy_task_mutation(context: ToolContext, tool_name: str) -> None: + if context.team is not None and _team_protocol_version(context) >= 2: + raise ToolInputError( + f"{tool_name} cannot change a protocol v2 plan; submit one complete " + "TeamPlan replacement instead" + ) + + +def _task_key(subject: str) -> str: + normalized = re.sub(r"[^a-z0-9]+", "-", subject.strip().lower()).strip("-") + return normalized or _new_task_id() + + +def _resolve_task_id(tasks: dict[str, dict[str, Any]], identity: str) -> str | None: + if identity in tasks: + return identity + normalized = identity.strip().lower() + matches = [ + task_id + for task_id, task in tasks.items() + if str(task.get("key") or "").lower() == normalized + ] + return matches[0] if len(matches) == 1 else None + + +def _resolve_owner(context: ToolContext, identity: str) -> str: + if context.team is None: + return identity + team_id = str(context.team["team_id"]) + lead_id = str(context.team["lead_agent_id"]) + if identity.strip().lower() == "lead" or identity == lead_id: + return lead_id + agent = context.team_store.find_agent(team_id, identity) + if agent is None: + raise ToolInputError(f"unknown task owner: {identity}") + return agent.agent_id + + +def _resolve_dependencies(context: ToolContext, identities: Any, field_name: str) -> list[str]: + if identities is None: + return [] + if not isinstance(identities, list) or not all(isinstance(item, str) and item.strip() for item in identities): + raise ToolInputError(f"{field_name} must be an array of task IDs or keys") + resolved: list[str] = [] + for identity in identities: + task_id = _resolve_task_id(context.tasks, identity) + if task_id is None: + raise ToolInputError(f"unknown task dependency: {identity}") + if task_id not in resolved: + resolved.append(task_id) + return resolved + + +def _string_list(value: Any, field_name: str) -> list[str]: + if value is None: + return [] + if not isinstance(value, list) or not all( + isinstance(item, str) and item.strip() for item in value + ): + raise ToolInputError(f"{field_name} must be an array of non-empty strings") + return list(dict.fromkeys(item.strip() for item in value)) + + class TaskCreateTool: def spec(self) -> ToolSpec: return ToolSpec( name="TaskCreate", - description="Create a task in the task list.", + description=( + "Create a task. For strict teammate work, declare concrete ownedFiles, " + "provided/consumed interfaces, and acceptanceChecks so TeamRun can reject " + "overlapping or unverifiable plans before workers start." + ), input_schema={ "type": "object", "additionalProperties": False, "properties": { + "key": {"type": "string"}, "subject": {"type": "string"}, "description": {"type": "string"}, "activeForm": {"type": "string"}, + "owner": {"type": "string"}, + "blockedBy": {"type": "array", "items": {"type": "string"}}, + "ownedFiles": {"type": "array", "items": {"type": "string"}}, + "providesInterfaces": {"type": "array", "items": {"type": "string"}}, + "dependsOnInterfaces": {"type": "array", "items": {"type": "string"}}, + "acceptanceChecks": {"type": "array", "items": {"type": "string"}}, "metadata": {"type": "object"}, }, "required": ["subject", "description"], }, - is_read_only=True, + is_read_only=False, max_result_size_chars=100_000, strict=True, ) def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: + _require_legacy_task_mutation(context, "TaskCreate") subject = tool_input.get("subject") description = tool_input.get("description") active_form = tool_input.get("activeForm") or "" + requested_key = tool_input.get("key") + owner = tool_input.get("owner") metadata = tool_input.get("metadata") or {} if not isinstance(subject, str) or not subject.strip(): raise ToolInputError("subject must be a non-empty string") @@ -48,30 +148,102 @@ def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: raise ToolInputError("description must be a non-empty string") if not isinstance(active_form, str): raise ToolInputError("activeForm must be a string when provided") + if requested_key is not None and (not isinstance(requested_key, str) or not requested_key.strip()): + raise ToolInputError("key must be a non-empty string when provided") + if owner is not None and (not isinstance(owner, str) or not owner.strip()): + raise ToolInputError("owner must be a non-empty string when provided") if not isinstance(metadata, dict): raise ToolInputError("metadata must be an object when provided") + key = requested_key.strip() if isinstance(requested_key, str) else _task_key(subject) + if any(str(task.get("key") or "").lower() == key.lower() for task in context.tasks.values()): + raise ToolInputError(f"task key already exists: {key}") + dependencies = _resolve_dependencies(context, tool_input.get("blockedBy"), "blockedBy") + owned_files = _string_list(tool_input.get("ownedFiles"), "ownedFiles") + provides_interfaces = _string_list( + tool_input.get("providesInterfaces"), "providesInterfaces" + ) + depends_on_interfaces = _string_list( + tool_input.get("dependsOnInterfaces"), "dependsOnInterfaces" + ) + acceptance_checks = _string_list( + tool_input.get("acceptanceChecks"), "acceptanceChecks" + ) task_id = _new_task_id() - context.tasks[task_id] = { - "id": task_id, - "subject": subject, - "description": description, - "activeForm": active_form, - "status": "pending", - "owner": None, - "blocks": [], - "blockedBy": [], - "metadata": dict(metadata), - "output": "", - } - return ToolResult(name="TaskCreate", output={"task": {"id": task_id, "subject": subject}}) + context.tasks[task_id] = TeamTask( + id=task_id, + subject=subject.strip(), + description=description, + key=key, + activeForm=active_form, + owner=_resolve_owner(context, owner.strip()) if isinstance(owner, str) else None, + blockedBy=dependencies, + metadata=dict(metadata), + owned_files=owned_files, + provides_interfaces=provides_interfaces, + depends_on_interfaces=depends_on_interfaces, + acceptance_checks=acceptance_checks, + ).to_dict() + for dependency_id in dependencies: + blocks = list(context.tasks[dependency_id].get("blocks") or []) + if task_id not in blocks: + blocks.append(task_id) + context.tasks[dependency_id]["blocks"] = blocks + context.tasks[dependency_id]["updated_at"] = utc_now() + context.persist_tasks() + if context.team is not None: + context.team_store.append_event( + str(context.team["team_id"]), "task.created", {"task": context.tasks[task_id]} + ) + next_required_actions: list[dict[str, str]] = [] + resolved_owner = context.tasks[task_id].get("owner") + if context.team is not None: + lead_id = str(context.team["lead_agent_id"]) + if resolved_owner is None or resolved_owner == lead_id: + next_required_actions.append( + { + "tool": "TaskUpdate", + "instruction": "Assign this teammate task to a created worker before running the team.", + } + ) + else: + next_required_actions.append( + { + "tool": "TeamRun", + "instruction": "Run pending teammate-owned tasks; TaskCreate does not start the worker.", + } + ) + return ToolResult( + name="TaskCreate", + output={ + "task": { + "id": task_id, + "key": key, + "subject": subject, + "owner": resolved_owner, + "blockedBy": dependencies, + "ownedFiles": owned_files, + "providesInterfaces": provides_interfaces, + "dependsOnInterfaces": depends_on_interfaces, + "acceptanceChecks": acceptance_checks, + }, + **( + { + "task_started": False, + "next_required_actions": next_required_actions, + } + if next_required_actions + else {} + ), + }, + ) class TaskGetTool: def spec(self) -> ToolSpec: return ToolSpec( name="TaskGet", - description="Retrieve a task by ID.", + description="Retrieve a task by internal ID or stable key.", input_schema={ "type": "object", "additionalProperties": False, @@ -84,10 +256,11 @@ def spec(self) -> ToolSpec: ) def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: - task_id = tool_input.get("taskId") - if not isinstance(task_id, str) or not task_id.strip(): - raise ToolInputError("taskId must be a non-empty string") - task = context.tasks.get(task_id) + identity = tool_input.get("taskId") + if not isinstance(identity, str) or not identity.strip(): + raise ToolInputError("taskId must be a non-empty task ID or key") + task_id = _resolve_task_id(context.tasks, identity) + task = context.tasks.get(task_id) if task_id is not None else None if task is None: return ToolResult(name="TaskGet", output={"task": None}) return ToolResult( @@ -95,11 +268,18 @@ def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: output={ "task": { "id": task["id"], + "key": task.get("key"), "subject": task["subject"], "description": task["description"], "status": task["status"], "blocks": list(task.get("blocks") or []), "blockedBy": list(task.get("blockedBy") or []), + "owner": task.get("owner"), + "output": task.get("output") or "", + "ownedFiles": list(task.get("owned_files") or []), + "providesInterfaces": list(task.get("provides_interfaces") or []), + "dependsOnInterfaces": list(task.get("depends_on_interfaces") or []), + "acceptanceChecks": list(task.get("acceptance_checks") or []), } }, ) @@ -122,10 +302,14 @@ def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: tasks.append( { "id": t["id"], + "key": t.get("key"), "subject": t["subject"], "status": t["status"], **({"owner": t["owner"]} if t.get("owner") else {}), "blockedBy": list(t.get("blockedBy") or []), + "ownedFiles": list(t.get("owned_files") or []), + "providesInterfaces": list(t.get("provides_interfaces") or []), + "dependsOnInterfaces": list(t.get("depends_on_interfaces") or []), } ) tasks.sort(key=lambda x: x["id"]) @@ -136,7 +320,10 @@ class TaskUpdateTool: def spec(self) -> ToolSpec: return ToolSpec( name="TaskUpdate", - description="Update a task.", + description=( + "Update a task by internal ID or stable key. Canonical statuses are pending, " + "in_progress, completed, failed, and cancelled; done is accepted as completed." + ), input_schema={ "type": "object", "additionalProperties": False, @@ -149,30 +336,74 @@ def spec(self) -> ToolSpec: "addBlocks": {"type": "array", "items": {"type": "string"}}, "addBlockedBy": {"type": "array", "items": {"type": "string"}}, "owner": {"type": "string"}, + "output": {"type": "string"}, "metadata": {"type": "object"}, }, "required": ["taskId"], }, - is_read_only=True, + is_read_only=False, max_result_size_chars=100_000, strict=True, ) def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: - task_id = tool_input.get("taskId") - if not isinstance(task_id, str) or not task_id.strip(): - raise ToolInputError("taskId must be a non-empty string") - task = context.tasks.get(task_id) + identity = tool_input.get("taskId") + if not isinstance(identity, str) or not identity.strip(): + raise ToolInputError("taskId must be a non-empty task ID or key") + task_id = _resolve_task_id(context.tasks, identity) + task = context.tasks.get(task_id) if task_id is not None else None if task is None: return ToolResult( name="TaskUpdate", - output={"success": False, "taskId": task_id, "updatedFields": [], "error": "Task not found"}, + output={"success": False, "taskId": identity, "updatedFields": [], "error": "Task not found"}, ) + if context.actor_id is not None and context.current_task_id is not None and task_id != context.current_task_id: + raise ToolInputError("teammates may only update their current task") + + if _team_protocol_version(context) >= 2: + if context.actor_id is None: + _require_legacy_task_mutation(context, "TaskUpdate") + disallowed = set(tool_input) - {"taskId", "status", "output"} + if disallowed: + raise ToolInputError( + "protocol v2 teammates may only update their own status and output; " + "immutable fields: " + ", ".join(sorted(disallowed)) + ) updated_fields: list[str] = [] status_change: dict[str, str] | None = None + requested_status = tool_input.get("status") + if requested_status == "done": + requested_status = "completed" + if context.actor_id is not None: + structural = {"owner", "addBlocks", "addBlockedBy"} + if structural.intersection(tool_input): + raise ToolInputError("teammates cannot change task ownership or dependencies") + if requested_status == "deleted": + raise ToolInputError("teammates cannot delete tasks") + if requested_status is not None: + if not isinstance(requested_status, str) or ( + requested_status not in _TASK_STATUSES and requested_status != "deleted" + ): + raise ToolInputError( + "status must be pending|in_progress|completed|failed|cancelled|deleted when provided" + ) + if requested_status == "deleted": + context.tasks.pop(task_id, None) + context.persist_tasks() + return ToolResult( + name="TaskUpdate", + output={"success": True, "taskId": task_id, "updatedFields": ["deleted"]}, + ) + if requested_status != task.get("status"): + task_state = TeamTask.from_dict(task) + try: + task_state.transition_to(requested_status) + except ValueError as exc: + raise ToolInputError(str(exc)) from exc + status_change = {"from": str(task.get("status")), "to": requested_status} - for field in ("subject", "description", "activeForm", "owner"): + for field in ("subject", "description", "activeForm", "output"): if field in tool_input and tool_input[field] is not None: v = tool_input[field] if not isinstance(v, str): @@ -181,26 +412,24 @@ def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: task[field] = v updated_fields.append(field) - if "status" in tool_input and tool_input["status"] is not None: - status = tool_input["status"] - if not isinstance(status, str) or status not in _TASK_STATUSES and status != "deleted": - raise ToolInputError("status must be pending|in_progress|completed|deleted when provided") - if status == "deleted": - context.tasks.pop(task_id, None) - return ToolResult( - name="TaskUpdate", - output={"success": True, "taskId": task_id, "updatedFields": ["deleted"]}, - ) - if status != task.get("status"): - status_change = {"from": str(task.get("status")), "to": status} - task["status"] = status - updated_fields.append("status") + if "owner" in tool_input and tool_input["owner"] is not None: + value = tool_input["owner"] + if not isinstance(value, str) or not value.strip(): + raise ToolInputError("owner must be a non-empty string when provided") + resolved_owner = _resolve_owner(context, value.strip()) + if resolved_owner != task.get("owner"): + task["owner"] = resolved_owner + updated_fields.append("owner") + + if status_change is not None: + task["status"] = requested_status + updated_fields.append("status") for rel_field, input_key in (("blocks", "addBlocks"), ("blockedBy", "addBlockedBy")): if input_key in tool_input and tool_input[input_key] is not None: - ids = tool_input[input_key] - if not isinstance(ids, list) or not all(isinstance(x, str) for x in ids): - raise ToolInputError(f"{input_key} must be an array of strings when provided") + ids = _resolve_dependencies(context, tool_input[input_key], input_key) + if task_id in ids: + raise ToolInputError("a task cannot depend on or block itself") cur = list(task.get(rel_field) or []) for x in ids: if x not in cur: @@ -208,6 +437,14 @@ def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: if cur != task.get(rel_field): task[rel_field] = cur updated_fields.append(rel_field) + reciprocal = "blocks" if rel_field == "blockedBy" else "blockedBy" + for related_id in ids: + related = context.tasks[related_id] + values = list(related.get(reciprocal) or []) + if task_id not in values: + values.append(task_id) + related[reciprocal] = values + related["updated_at"] = utc_now() if "metadata" in tool_input and tool_input["metadata"] is not None: md = tool_input["metadata"] @@ -222,6 +459,10 @@ def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: task["metadata"] = existing updated_fields.append("metadata") + if updated_fields: + task["updated_at"] = utc_now() + context.persist_tasks() + out: dict[str, Any] = {"success": True, "taskId": task_id, "updatedFields": updated_fields} if status_change is not None: out["statusChange"] = status_change @@ -232,7 +473,7 @@ class TaskOutputTool: def spec(self) -> ToolSpec: return ToolSpec( name="TaskOutput", - description="Get output for a task (best-effort).", + description="Get output for a task by internal ID or stable key (best-effort).", input_schema={ "type": "object", "additionalProperties": False, @@ -250,11 +491,12 @@ def spec(self) -> ToolSpec: ) def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: - task_id = tool_input.get("task_id") - if not isinstance(task_id, str) or not task_id.strip(): - raise ToolInputError("task_id must be a non-empty string") + identity = tool_input.get("task_id") + if not isinstance(identity, str) or not identity.strip(): + raise ToolInputError("task_id must be a non-empty task ID or key") - task = context.tasks.get(task_id) + task_id = _resolve_task_id(context.tasks, identity) + task = context.tasks.get(task_id) if task_id is not None else None if task is None: return ToolResult(name="TaskOutput", output={"retrieval_status": "success", "task": None}) @@ -274,3 +516,55 @@ def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: }, ) + +class TaskRetryTool: + def spec(self) -> ToolSpec: + return ToolSpec( + name="TaskRetry", + description="Reset a failed or cancelled task to pending for an explicit retry.", + input_schema={ + "type": "object", + "additionalProperties": False, + "properties": { + "taskId": {"type": "string"}, + "clearOutput": {"type": "boolean"}, + }, + "required": ["taskId"], + }, + is_read_only=False, + max_result_size_chars=100_000, + strict=True, + ) + + def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: + _require_legacy_task_mutation(context, "TaskRetry") + if context.actor_id is not None: + raise ToolInputError("only the lead may retry tasks") + identity = tool_input.get("taskId") + if not isinstance(identity, str) or not identity.strip(): + raise ToolInputError("taskId must be a non-empty task ID or key") + task_id = _resolve_task_id(context.tasks, identity) + if task_id is None: + raise ToolInputError(f"unknown task: {identity}") + task = TeamTask.from_dict(context.tasks[task_id]) + if task.status not in {"failed", "cancelled"}: + raise ToolInputError("only failed or cancelled tasks can be retried") + previous = task.status + task.transition_to("pending") + task.lease_id = None + task.lease_expires_at = None + task.completed_at = None + if bool(tool_input.get("clearOutput", True)): + task.output = "" + context.tasks[task.id] = task.to_dict() + context.persist_tasks() + if context.team is not None: + context.team_store.append_event( + str(context.team["team_id"]), + "task.retry_requested", + {"task_id": task.id, "from": previous, "attempt": task.attempt}, + ) + return ToolResult( + name="TaskRetry", + output={"success": True, "taskId": task.id, "status": "pending"}, + ) diff --git a/src/tool_system/tools/team.py b/src/tool_system/tools/team.py index 8f1efcf..6415424 100644 --- a/src/tool_system/tools/team.py +++ b/src/tool_system/tools/team.py @@ -1,20 +1,92 @@ from __future__ import annotations -import json import uuid from typing import Any +from ...teammate.control import cancel_team, resume_teammate, stop_teammate +from ...teammate.models import AgentRecord, utc_now +from ...teammate.worktree import TeammateWorktreeManager from ..context import ToolContext from ..errors import ToolInputError from ..protocol import ToolResult from ..registry import ToolSpec +def _team_protocol_version(team: dict[str, Any] | None) -> int: + value = team or {} + settings = value.get("settings") if isinstance(value.get("settings"), dict) else {} + quality = ( + settings.get("quality_gates") + if isinstance(settings.get("quality_gates"), dict) + else {} + ) + versions = [1] + for raw in ( + value.get("protocol_version"), + settings.get("protocol_version"), + quality.get("protocol_version"), + ): + try: + versions.append(int(raw)) + except (TypeError, ValueError): + continue + return max(versions) + + +def _reject_v2_incremental_mutation(context: ToolContext, tool_name: str) -> None: + if _team_protocol_version(context.team) >= 2: + raise ToolInputError( + f"{tool_name} cannot mutate a protocol v2 team; submit a complete " + "TeamPlan replacement instead. If the current non-terminal plan must " + "be restarted, call TeamReplan first; TeamAbort is terminal and must " + "not be used as a restart operation" + ) + + +def _strict_v2(team: Any) -> bool: + settings = team.settings if isinstance(team.settings, dict) else {} + quality = ( + settings.get("quality_gates") + if isinstance(settings.get("quality_gates"), dict) + else {} + ) + versions = [1] + for raw in ( + getattr(team, "protocol_version", 1), + settings.get("protocol_version"), + quality.get("protocol_version"), + ): + try: + versions.append(int(raw)) + except (TypeError, ValueError): + continue + return bool(quality.get("strict")) and max(versions) >= 2 + + +def _active_team_work(context: ToolContext, team_id: str) -> tuple[list[str], list[str]]: + active_tasks = [ + str(task.get("key") or task_id) + for task_id, task in context.team_store.load_tasks(team_id).items() + if task.get("status") == "in_progress" + ] + running_agents = [ + agent.name + for agent in context.team_store.list_agents(team_id) + if agent.status in {"running", "stopping"} + ] + return active_tasks, running_agents + + class TeamCreateTool: def spec(self) -> ToolSpec: return ToolSpec( name="TeamCreate", - description="Create a lightweight team context for multi-agent workflows.", + description=( + "Create a team only when the lead decides delegation is worth its cost. " + "The lead chooses the team shape; no fixed roles or topology are required. " + "For quality-gated work, follow creation with one atomic TeamPlan and TeamRun. " + "Creating a team does not start any worker." + ), input_schema={ "type": "object", "additionalProperties": False, @@ -22,10 +94,11 @@ def spec(self) -> ToolSpec: "team_name": {"type": "string"}, "description": {"type": "string"}, "agent_type": {"type": "string"}, + "quality_gates": {"type": "boolean"}, }, "required": ["team_name"], }, - is_read_only=True, + is_read_only=False, max_result_size_chars=100_000, strict=True, ) @@ -41,15 +114,834 @@ def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: if agent_type is not None and not isinstance(agent_type, str): raise ToolInputError("agent_type must be a string when provided") - lead_agent_id = uuid.uuid4().hex[:12] - team_file = context.workspace_root / ".clawd" / "team.json" - team_file.parent.mkdir(parents=True, exist_ok=True) - team = {"team_name": team_name, "description": description, "agent_type": agent_type, "lead_agent_id": lead_agent_id} - team_file.write_text(json.dumps(team, ensure_ascii=False, indent=2), encoding="utf-8") - context.team = team + try: + team = context.team_store.create_team(team_name.strip(), description, agent_type) + except ValueError as exc: + active = context.team_store.load_active_team() + if active is not None and _strict_v2(active): + lifecycle = str(active.lifecycle_state or active.status) + if lifecycle in {"completed", "aborted", "budget_exhausted"}: + raise ToolInputError( + f"active strict team is terminal (lifecycle={lifecycle}) and " + "cannot be replaced inside this rollout; preserve it for scoring " + "and start a new top-level rollout" + ) from exc + raise ToolInputError( + "an active recoverable strict team already exists; call TeamReplan " + "to request a fresh complete TeamPlan while preserving the workspace. " + "Do not call TeamAbort or delete files merely to restart" + ) from exc + raise ToolInputError(str(exc)) from exc + if bool(tool_input.get("quality_gates", False)): + team.settings["quality_gates"] = { + "strict": True, + "configured": False, + "validation": {"status": "pending"}, + } + context.team_store.save_team(team) + context.team = team.to_dict() + context.tasks = {} return ToolResult( name="TeamCreate", - output={"team_name": team_name, "team_file_path": str(team_file), "lead_agent_id": lead_agent_id}, + output={ + "team_id": team.team_id, + "team_name": team.team_name, + "team_file_path": str(context.team_store.team_dir(team.team_id) / "team.json"), + "lead_agent_id": team.lead_agent_id, + "team_started": False, + "quality_gates": bool(tool_input.get("quality_gates", False)), + "next_required_actions": ( + [ + { + "tool": "TeamPlan", + "instruction": ( + "Atomically define the contract, real workers, owned tasks, " + "acceptance checks, validation, and execution settings." + ), + }, + { + "tool": "TeamRun", + "instruction": ( + "Run the committed plan; protocol v2 performs acceptance " + "and final verification automatically." + ), + }, + ] + if bool(tool_input.get("quality_gates", False)) + else [ + { + "tool": "TeammateCreate", + "instruction": "Create each task-specific worker with its role and tool allowlist.", + }, + { + "tool": "TaskCreate", + "instruction": "Create at least one task owned by a teammate.", + }, + { + "tool": "TeamRun", + "instruction": "Run the owned tasks; team creation alone does not start workers.", + }, + ] + ), + }, + ) + + +class TeamConfigureTool: + def spec(self) -> ToolSpec: + return ToolSpec( + name="TeamConfigure", + description=( + "Configure strict team architecture and final validation gates before TeamRun. " + "The install and import checks run in a fresh system-site-packages virtual " + "environment; the integration check runs only after every teammate task finishes." + ), + input_schema={ + "type": "object", + "additionalProperties": False, + "properties": { + "architecture_contract": {"type": "string"}, + "install_command": {"type": "string"}, + "import_command": {"type": "string"}, + "integration_command": {"type": "string"}, + }, + "required": [ + "architecture_contract", + "install_command", + "import_command", + "integration_command", + ], + }, + is_read_only=False, + max_result_size_chars=100_000, + strict=True, + ) + + def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: + if context.actor_id is not None: + raise ToolInputError("only the lead may configure team quality gates") + if context.team is None: + raise ToolInputError("no active team") + _reject_v2_incremental_mutation(context, "TeamConfigure") + values: dict[str, str] = {} + for name in ( + "architecture_contract", + "install_command", + "import_command", + "integration_command", + ): + value = tool_input.get(name) + if not isinstance(value, str) or not value.strip(): + raise ToolInputError(f"{name} must be a non-empty string") + values[name] = value.strip() + team_id = str(context.team["team_id"]) + team = context.team_store.load_team(team_id) + if team is None: + raise ToolInputError("active team state is unavailable") + quality = dict(team.settings.get("quality_gates") or {}) + quality.update( + { + "strict": True, + "configured": True, + **values, + "validation": {"status": "pending"}, + } + ) + team.settings["quality_gates"] = quality + context.team_store.save_team(team) + context.team_store.append_event( + team_id, + "team.quality_configured", + { + "architecture_contract": values["architecture_contract"], + "validation_stages": ["install", "import", "integration"], + }, + ) + context.reload_team_state() + return ToolResult( + name="TeamConfigure", + output={ + "team_id": team_id, + "strict": True, + "configured": True, + "validation_stages": ["install", "import", "integration"], + "next_required_actions": [ + { + "tool": "TaskCreate", + "instruction": ( + "Create at least two independently ready teammate tasks with " + "non-overlapping ownedFiles and acceptanceChecks." + ), + }, + { + "tool": "TeamRun", + "instruction": "Run the validated plan, then call TeamVerify.", + }, + ], + }, + ) + + +class TeammateCreateTool: + def spec(self) -> ToolSpec: + return ToolSpec( + name="TeammateCreate", + description=( + "Create one persistent teammate with a lead-defined role, model, tool allowlist, " + "and workspace mode. Roles are task-specific rather than predefined. The new " + "teammate remains idle until it owns a TaskCreate task and the lead calls TeamRun." + ), + input_schema={ + "type": "object", + "additionalProperties": False, + "properties": { + "name": {"type": "string"}, + "role": {"type": "string"}, + "instructions": {"type": "string"}, + "tools": {"type": "array", "items": {"type": "string"}}, + "model": {"type": "string"}, + "workspace_mode": {"type": "string", "enum": ["shared", "worktree"]}, + "auto_integrate": {"type": "boolean"}, + }, + "required": ["name", "role", "instructions", "tools"], + }, + is_read_only=False, + max_result_size_chars=100_000, + strict=True, + ) + + def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: + if context.team is None: + raise ToolInputError( + "no active team: call TeamCreate first, then retry TeammateCreate; " + "afterward create an owned task and call TeamRun" + ) + _reject_v2_incremental_mutation(context, "TeammateCreate") + name = tool_input.get("name") + role = tool_input.get("role") + instructions = tool_input.get("instructions") + tools = tool_input.get("tools") + model = tool_input.get("model") + workspace_mode = tool_input.get("workspace_mode", "shared") + auto_integrate = bool(tool_input.get("auto_integrate", False)) + for field_name, value in (("name", name), ("role", role), ("instructions", instructions)): + if not isinstance(value, str) or not value.strip(): + raise ToolInputError(f"{field_name} must be a non-empty string") + if not isinstance(tools, list) or not tools or not all(isinstance(item, str) and item.strip() for item in tools): + raise ToolInputError("tools must be a non-empty array of tool names") + if model is not None and (not isinstance(model, str) or not model.strip()): + raise ToolInputError("model must be a non-empty string when provided") + validate_model = getattr(context.teammate_runtime, "validate_model", None) + if callable(validate_model): + try: + model = validate_model(model) + except ValueError as exc: + raise ToolInputError(str(exc)) from exc + if workspace_mode not in {"shared", "worktree"}: + raise ToolInputError("workspace_mode must be shared or worktree") + if workspace_mode == "worktree" and context.workspace_backend is not None: + raise ToolInputError( + "worktree teammates are not supported by the remote sandbox backend; " + "use workspace_mode=shared" + ) + if auto_integrate and workspace_mode != "worktree": + raise ToolInputError("auto_integrate requires workspace_mode=worktree") + + team_id = str(context.team["team_id"]) + if context.team_store.find_agent(team_id, name.strip()) is not None: + raise ToolInputError(f"teammate name already exists: {name.strip()}") + normalized_tools = list(dict.fromkeys(item.strip() for item in tools)) + validate_tools = getattr(context.teammate_runtime, "validate_tools", None) + if callable(validate_tools): + try: + normalized_tools = validate_tools(normalized_tools) + except ValueError as exc: + raise ToolInputError(str(exc)) from exc + agent = AgentRecord( + agent_id=uuid.uuid4().hex[:12], + team_id=team_id, + name=name.strip(), + role=role.strip(), + session_id=uuid.uuid4().hex, + model=model, + instructions=instructions.strip(), + tools=normalized_tools, + workspace_mode=workspace_mode, + auto_integrate=auto_integrate, + ) + if workspace_mode == "worktree": + try: + agent.workspace_path = str( + TeammateWorktreeManager(context.workspace_root).create( + team_id, agent.agent_id, agent.name + ) + ) + except (ValueError, RuntimeError) as exc: + raise ToolInputError(str(exc)) from exc + path = context.team_store.save_agent(agent) + context.team_store.save_session( + team_id, + agent.session_id, + { + "session_id": agent.session_id, + "team_id": team_id, + "agent_id": agent.agent_id, + "model": agent.model, + "conversation": {"messages": [], "max_history": 300}, + }, + ) + context.team_store.append_event(team_id, "agent.created", {"agent": agent.to_dict()}) + return ToolResult( + name="TeammateCreate", + output={ + "agent_id": agent.agent_id, + "name": agent.name, + "role": agent.role, + "session_id": agent.session_id, + "workspace_mode": agent.workspace_mode, + "workspace_path": agent.workspace_path, + "auto_integrate": agent.auto_integrate, + "agent_file_path": str(path), + "worker_started": False, + "next_required_actions": [ + { + "tool": "TaskCreate", + "instruction": f"Create a task with owner={agent.name!r}.", + }, + { + "tool": "TeamRun", + "instruction": "Call TeamRun after owned tasks exist; TeammateCreate does not execute the worker.", + }, + ], + }, + ) + + +_RUN_PROPERTIES: dict[str, dict[str, Any]] = { + "max_workers": {"type": "integer"}, + "max_batches": {"type": "integer"}, + "timeout_s": {"type": "number"}, + "token_budget": {"type": "integer"}, + "turn_budget": {"type": "integer"}, + "max_retries": {"type": "integer"}, + "lease_timeout_s": {"type": "integer"}, +} + + +def _run_options(tool_input: dict[str, Any]) -> dict[str, Any]: + options = {key: tool_input[key] for key in _RUN_PROPERTIES if key in tool_input} + bounds = { + "max_workers": (1, 16), + "max_batches": (1, 10_000), + "timeout_s": (1, 86_400), + "token_budget": (1, 100_000_000), + "turn_budget": (1, 100_000), + "max_retries": (0, 10), + "lease_timeout_s": (5, 86_400), + } + for name, value in options.items(): + minimum, maximum = bounds[name] + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ToolInputError(f"{name} must be numeric") + if value < minimum or value > maximum: + raise ToolInputError(f"{name} must be between {minimum} and {maximum}") + if name != "timeout_s" and not isinstance(value, int): + raise ToolInputError(f"{name} must be an integer") + return options + + +def _add_recovery_guidance(output: dict[str, Any]) -> dict[str, Any]: + status = str(output.get("status") or "") + guided = dict(output) + if status == "repair_required": + guided["recovery_guidance"] = ( + "Stop active workers and call TeamReplan first so the current workspace and " + "artifacts are checkpointed, then submit one complete replacement TeamPlan " + "revision and call TeamRun. Do not use TeamAbort for restart." + ) + elif status in {"failed", "blocked", "cancelled"}: + guided["recovery_guidance"] = ( + "This non-terminal team may be recoverable. After active workers stop, call " + "TeamReplan for a fresh complete plan while preserving the workspace. " + "TeamAbort is only for an intentional terminal failed outcome." + ) + elif status == "paused": + guided["recovery_guidance"] = ( + "Call TeamResume for a transient pause. If the plan itself must change, call " + "TeamReplan; both preserve the workspace. Do not use TeamAbort for restart." + ) + elif status == "aborted": + guided["recovery_guidance"] = ( + "This team is terminal and cannot be resumed or replanned." + ) + elif status == "budget_exhausted": + guided["recovery_guidance"] = ( + "This rollout exhausted its frozen execution budget and is terminal. " + "The workspace is preserved for scoring, but TeamResume and TeamReplan " + "cannot add budget; start a new top-level rollout if more work is required." + ) + return guided + + +class TeamRunTool: + def spec(self) -> ToolSpec: + return ToolSpec( + name="TeamRun", + description=( + "Run ready teammate tasks with optional parallelism, retries, leases, budgets, " + "and a batch limit so the lead can inspect and adapt the team between batches. " + "For protocol v2, this also runs task acceptance and final Team verification " + "automatically, returning completed or repair_required." + ), + input_schema={ + "type": "object", + "additionalProperties": False, + "properties": _RUN_PROPERTIES, + }, + is_read_only=False, + max_result_size_chars=200_000, + strict=True, + ) + + def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: + if context.team is None: + raise ToolInputError("no active team") + if context.teammate_runtime is None: + raise ToolInputError("teammate runtime is not configured") + output = _add_recovery_guidance( + context.teammate_runtime.run_team(context, **_run_options(tool_input)) + ) + return ToolResult( + name="TeamRun", + output=output, + is_error=output.get("status") + in { + "failed", + "blocked", + "cancelled", + "aborted", + "budget_exhausted", + "repair_required", + }, + ) + + +class TeamVerifyTool: + def spec(self) -> ToolSpec: + return ToolSpec( + name="TeamVerify", + description=( + "Run the configured clean-install, import-smoke, and integration checks. " + "A strict team cannot become completed until all three checks pass. Protocol " + "v2 normally invokes this automatically through TeamRun; repeated calls after " + "success are idempotent." + ), + input_schema={ + "type": "object", + "additionalProperties": False, + "properties": {"timeout_s": {"type": "integer"}}, + }, + is_read_only=False, + max_result_size_chars=200_000, + strict=True, + ) + + def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: + if context.actor_id is not None: + raise ToolInputError("only the lead may verify a team") + if context.team is None: + raise ToolInputError("no active team") + if context.teammate_runtime is None: + raise ToolInputError("teammate runtime is not configured") + timeout_s = tool_input.get("timeout_s", 300) + if isinstance(timeout_s, bool) or not isinstance(timeout_s, int): + raise ToolInputError("timeout_s must be an integer") + if timeout_s < 1 or timeout_s > 900: + raise ToolInputError("timeout_s must be between 1 and 900") + output = context.teammate_runtime.verify_team(context, timeout_s=timeout_s) + return ToolResult( + name="TeamVerify", + output=output, + is_error=output.get("status") != "completed", + ) + +class TeamResumeTool: + def spec(self) -> ToolSpec: + return ToolSpec( + name="TeamResume", + description="Resume a failed or cancelled team, recovering expired leases and optionally retrying failed tasks.", + input_schema={ + "type": "object", + "additionalProperties": False, + "properties": { + **_RUN_PROPERTIES, + "retry_failed": {"type": "boolean"}, + "retry_cancelled": {"type": "boolean"}, + }, + }, + is_read_only=False, + max_result_size_chars=200_000, + strict=True, + ) + + def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: + if context.team is None: + raise ToolInputError("no active team") + if context.teammate_runtime is None: + raise ToolInputError("teammate runtime is not configured") + output = _add_recovery_guidance( + context.teammate_runtime.run_team( + context, + resume=True, + retry_failed=bool(tool_input.get("retry_failed", True)), + retry_cancelled=bool(tool_input.get("retry_cancelled", True)), + **_run_options(tool_input), + ) + ) + return ToolResult( + name="TeamResume", + output=output, + is_error=output.get("status") + in { + "failed", + "blocked", + "cancelled", + "aborted", + "budget_exhausted", + "repair_required", + }, + ) + + +class TeamCancelTool: + def spec(self) -> ToolSpec: + return ToolSpec( + name="TeamCancel", + description=( + "Request cooperative cancellation of active workers without deleting team " + "state or workspace artifacts. For a strict-team restart, wait for workers " + "to stop and then call TeamReplan; TeamAbort is terminal." + ), + input_schema={ + "type": "object", + "additionalProperties": False, + "properties": {"reason": {"type": "string"}}, + }, + is_read_only=False, + max_result_size_chars=100_000, + strict=True, + ) + + def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: + reason = tool_input.get("reason") + if reason is not None and not isinstance(reason, str): + raise ToolInputError("reason must be a string when provided") + try: + output = cancel_team(context.team_store, reason) + except ValueError as exc: + raise ToolInputError(str(exc)) from exc + context.reload_team_state() + if _team_protocol_version(context.team) >= 2: + output = { + **output, + "terminal": False, + "artifacts_preserved": True, + "next_required_action": ( + "Wait for active workers to stop, then call TeamReplan for a " + "recoverable restart or TeamResume to continue the same plan." + ), + } + return ToolResult(name="TeamCancel", output=output) + + +class TeammateStopTool: + def spec(self) -> ToolSpec: + return ToolSpec( + name="TeammateStop", + description="Stop one teammate without cancelling the team; unfinished tasks may be requeued or cancelled.", + input_schema={ + "type": "object", + "additionalProperties": False, + "properties": { + "teammate": {"type": "string"}, + "reason": {"type": "string"}, + "task_policy": { + "type": "string", + "enum": ["requeue", "cancel"], + }, + }, + "required": ["teammate"], + }, + is_read_only=False, + max_result_size_chars=100_000, + strict=True, + ) + + def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: + if context.actor_id is not None: + raise ToolInputError("only the lead may stop teammates") + identity = tool_input.get("teammate") + reason = tool_input.get("reason") + policy = tool_input.get("task_policy", "requeue") + if not isinstance(identity, str) or not identity.strip(): + raise ToolInputError("teammate must be a non-empty name or ID") + if reason is not None and not isinstance(reason, str): + raise ToolInputError("reason must be a string when provided") + if not isinstance(policy, str): + raise ToolInputError("task_policy must be requeue or cancel") + try: + output = stop_teammate( + context.team_store, + identity.strip(), + task_policy=policy, + reason=reason, + ) + except ValueError as exc: + raise ToolInputError(str(exc)) from exc + context.reload_team_state() + return ToolResult(name="TeammateStop", output=output) + + +class TeammateResumeTool: + def spec(self) -> ToolSpec: + return ToolSpec( + name="TeammateResume", + description="Resume a fully stopped teammate so the lead may assign new work.", + input_schema={ + "type": "object", + "additionalProperties": False, + "properties": {"teammate": {"type": "string"}}, + "required": ["teammate"], + }, + is_read_only=False, + max_result_size_chars=100_000, + strict=True, + ) + + def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: + if context.actor_id is not None: + raise ToolInputError("only the lead may resume teammates") + identity = tool_input.get("teammate") + if not isinstance(identity, str) or not identity.strip(): + raise ToolInputError("teammate must be a non-empty name or ID") + try: + output = resume_teammate(context.team_store, identity.strip()) + except ValueError as exc: + raise ToolInputError(str(exc)) from exc + context.reload_team_state() + return ToolResult(name="TeammateResume", output=output) + + +class TeamIntegrateTool: + def spec(self) -> ToolSpec: + return ToolSpec( + name="TeamIntegrate", + description="Commit and cherry-pick an isolated teammate worktree into the lead workspace.", + input_schema={ + "type": "object", + "additionalProperties": False, + "properties": {"teammate": {"type": "string"}}, + "required": ["teammate"], + }, + is_read_only=False, + max_result_size_chars=100_000, + strict=True, + ) + + def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: + if context.actor_id is not None: + raise ToolInputError("only the lead may integrate teammate worktrees") + if context.team is None: + raise ToolInputError("no active team") + identity = tool_input.get("teammate") + if not isinstance(identity, str) or not identity.strip(): + raise ToolInputError("teammate must be a non-empty name or ID") + agent = context.team_store.find_agent(str(context.team["team_id"]), identity.strip()) + if agent is None: + raise ToolInputError(f"unknown teammate: {identity}") + try: + result = TeammateWorktreeManager(context.workspace_root).integrate(agent) + except (ValueError, RuntimeError) as exc: + raise ToolInputError(str(exc)) from exc + context.team_store.append_event( + agent.team_id, + "worktree.integrated", + {"agent_id": agent.agent_id, **result}, + ) + return ToolResult(name="TeamIntegrate", output=result) + + +class TeamReplanTool: + def spec(self) -> ToolSpec: + return ToolSpec( + name="TeamReplan", + description=( + "Request a recoverable replacement of a non-terminal strict protocol v2 " + "plan. This preserves the current workspace, artifacts, usage, and history; " + "it never deletes files or terminates the rollout. Use it before a fresh " + "TeamPlan when ownership, contracts, or task partitioning must change. " + "TeamAbort is terminal and must not be used for restart/replan." + ), + input_schema={ + "type": "object", + "additionalProperties": False, + "properties": { + "reason": {"type": "string"}, + "replace_completed_work": {"type": "boolean"}, + }, + "required": ["reason"], + }, + aliases=("TeamReset",), + is_read_only=False, + max_result_size_chars=100_000, + strict=True, + ) + + def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: + if context.actor_id is not None: + raise ToolInputError("only the lead may request a team replan") + reason = tool_input.get("reason") + if not isinstance(reason, str) or not reason.strip(): + raise ToolInputError("reason must be a non-empty string") + replace_completed_work = tool_input.get("replace_completed_work", False) + if not isinstance(replace_completed_work, bool): + raise ToolInputError("replace_completed_work must be a boolean") + + team = context.team_store.load_active_team() + if team is None: + raise ToolInputError("no active team") + if not _strict_v2(team): + raise ToolInputError( + "TeamReplan is only available to strict protocol v2 teams" + ) + try: + team, checkpoint = context.team_store.request_team_replan( + team.team_id, + reason=reason.strip(), + replace_completed_work=replace_completed_work, + ) + except ValueError as exc: + raise ToolInputError(str(exc)) from exc + context.reload_team_state() + return ToolResult( + name="TeamReplan", + output={ + "status": "replan_required", + "team_id": team.team_id, + "lifecycle_state": "repair_required", + "reason": reason.strip(), + "artifacts_preserved": True, + "workspace_preserved": True, + "workspace_action": "none", + "checkpoint": checkpoint, + "next_required_action": { + "tool": "TeamPlan", + "instruction": ( + "Submit one complete replacement plan against the checkpointed " + "revision. Reuse the existing workspace; do not delete or recreate it." + ), + }, + }, + ) + + +class TeamAbortTool: + def spec(self) -> ToolSpec: + return ToolSpec( + name="TeamAbort", + description=( + "Explicitly terminate an unrecoverable strict protocol v2 team while " + "preserving its active state and artifacts for scoring and diagnosis. " + "This is a terminal failure outcome: it cannot be resumed or replanned. " + "For restart or a fresh plan, use TeamReplan instead." + ), + input_schema={ + "type": "object", + "additionalProperties": False, + "properties": {"reason": {"type": "string"}}, + "required": ["reason"], + }, + is_read_only=False, + max_result_size_chars=100_000, + strict=True, + ) + + def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: + if context.actor_id is not None: + raise ToolInputError("only the lead may abort a team") + reason = tool_input.get("reason") + if not isinstance(reason, str) or not reason.strip(): + raise ToolInputError("reason must be a non-empty string") + team = context.team_store.load_active_team() + if team is None: + raise ToolInputError("no active team") + if not _strict_v2(team): + raise ToolInputError("TeamAbort is only available to strict protocol v2 teams") + if team.lifecycle_state == "completed": + raise ToolInputError( + "a completed team cannot be aborted or reopened; preserve it for scoring" + ) + if team.lifecycle_state == "budget_exhausted": + return ToolResult( + name="TeamAbort", + output={ + "status": "budget_exhausted", + "team_id": team.team_id, + "lifecycle_state": "budget_exhausted", + "artifacts_preserved": True, + "terminal": True, + "already_terminal": True, + }, + is_error=True, + ) + if team.lifecycle_state == "aborted": + return ToolResult( + name="TeamAbort", + output={ + "status": "aborted", + "team_id": team.team_id, + "lifecycle_state": "aborted", + "artifacts_preserved": True, + "terminal": True, + "already_aborted": True, + }, + ) + active_tasks, running_agents = _active_team_work(context, team.team_id) + if active_tasks or running_agents: + details = [] + if active_tasks: + details.append("active tasks: " + ", ".join(active_tasks)) + if running_agents: + details.append("running teammates: " + ", ".join(running_agents)) + raise ToolInputError( + "cannot abort while workers are active. For recovery, call TeamCancel, " + "wait for cooperative shutdown, then call TeamReplan. Call TeamAbort " + "after shutdown only when a terminal failed outcome is intended (" + + "; ".join(details) + + ")" + ) + if team.status != "cancelled": + team.transition_to("cancelled") + team.set_lifecycle_state("aborted") + team.cancel_requested_at = team.cancel_requested_at or utc_now() + context.team_store.save_team(team) + context.team_store.append_event( + team.team_id, + "team.aborted", + {"reason": reason.strip(), "usage": team.usage}, + ) + context.reload_team_state() + return ToolResult( + name="TeamAbort", + output={ + "status": "aborted", + "team_id": team.team_id, + "lifecycle_state": "aborted", + "reason": reason.strip(), + "artifacts_preserved": True, + "terminal": True, + "replan_allowed": False, + }, ) @@ -57,9 +949,13 @@ class TeamDeleteTool: def spec(self) -> ToolSpec: return ToolSpec( name="TeamDelete", - description="Disband the current team context.", + description=( + "Disband a legacy team context. Strict protocol v2 teams retain their state " + "for scoring. Recoverable strict teams use TeamReplan; TeamAbort is only " + "for an intentional terminal failed outcome." + ), input_schema={"type": "object", "additionalProperties": False, "properties": {}}, - is_read_only=True, + is_read_only=False, max_result_size_chars=100_000, strict=True, ) @@ -67,12 +963,69 @@ def spec(self) -> ToolSpec: def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: if context.team is None: return ToolResult(name="TeamDelete", output={"success": False, "message": "No active team"}) + team_settings = context.team.get("settings") or {} + quality = dict(team_settings.get("quality_gates") or {}) + versions = [1] + for raw_version in ( + context.team.get("protocol_version"), + team_settings.get("protocol_version"), + quality.get("protocol_version"), + ): + try: + versions.append(int(raw_version)) + except (TypeError, ValueError): + continue + protocol_version = max(versions) + lifecycle_state = str(context.team.get("lifecycle_state") or "draft") + if ( + bool(quality.get("strict")) + and protocol_version >= 2 + ): + terminal = lifecycle_state in { + "completed", + "aborted", + "budget_exhausted", + } + return ToolResult( + name="TeamDelete", + output={ + "success": False, + "status": "blocked", + "message": ( + "TeamDelete is disabled for strict protocol v2 because its active " + "state, workspace, and artifacts must remain available for scoring. " + + ( + "This team is terminal and cannot be reopened." + if terminal + else "Call TeamReplan for a recoverable fresh plan; do not use " + "TeamAbort as a restart operation." + ) + ), + "team_id": context.team.get("team_id"), + "lifecycle_state": lifecycle_state, + "artifacts_preserved": True, + "next_required_action": None if terminal else "TeamReplan", + }, + is_error=True, + ) team_name = context.team.get("team_name") - context.team = None - team_file = context.workspace_root / ".clawd" / "team.json" - if team_file.exists(): + retained_worktrees: list[str] = [] + for agent in context.team_store.list_agents(str(context.team["team_id"])): + if agent.workspace_mode != "worktree" or not agent.workspace_path: + continue try: - team_file.unlink() - except Exception: - pass - return ToolResult(name="TeamDelete", output={"success": True, "message": "Team deleted", "team_name": team_name}) + TeammateWorktreeManager(context.workspace_root).remove(agent) + except (ValueError, RuntimeError): + retained_worktrees.append(agent.workspace_path) + context.team_store.disband_active_team() + context.team = None + context.tasks = {} + return ToolResult( + name="TeamDelete", + output={ + "success": True, + "message": "Team deleted", + "team_name": team_name, + "retained_worktrees": retained_worktrees, + }, + ) diff --git a/src/tool_system/tools/team_plan.py b/src/tool_system/tools/team_plan.py new file mode 100644 index 0000000..8d131cf --- /dev/null +++ b/src/tool_system/tools/team_plan.py @@ -0,0 +1,1812 @@ +from __future__ import annotations + +import ast +import hashlib +import json +import posixpath +import re +import shlex +import uuid +from typing import Any + +from ...teammate.models import AgentRecord, TeamTask +from ..context import ToolContext +from ..errors import ToolInputError +from ..protocol import ToolResult +from ..registry import ToolSpec + + +_DEFAULT_TOOLS = ["Read", "Write", "Edit", "Bash"] +_MODULE_NAME = re.compile(r"^[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*$") +_EXECUTION_BOUNDS: dict[str, tuple[int, int]] = { + "max_workers": (1, 16), + "timeout_s": (1, 86_400), + "token_budget": (1, 100_000_000), + "turn_budget": (1, 100_000), + "max_retries": (0, 10), + "lease_timeout_s": (5, 86_400), + "verify_timeout_s": (1, 86_400), +} +_EXECUTION_DEFAULTS: dict[str, int | float | bool | None] = { + "timeout_s": None, + "token_budget": None, + "turn_budget": None, + "max_retries": 0, + "lease_timeout_s": 900, + "verify_timeout_s": 900, + "auto_verify": True, +} +_JSON_COMPATIBLE_FIELDS: dict[str, type[Any]] = { + "contract": dict, + "workers": list, + "tasks": list, + "validation": dict, +} +_SOURCE_SUFFIXES = { + ".py", + ".pyi", + ".c", + ".cc", + ".cpp", + ".cxx", + ".h", + ".hh", + ".hpp", + ".rs", + ".go", + ".java", + ".js", + ".jsx", + ".ts", + ".tsx", +} +_CEREMONIAL_ROOTS = { + ".github", + ".circleci", + "ci", + "docs", + "doc", + "examples", + "example", + "tests", + "test", +} +_CEREMONIAL_FILES = { + "readme", + "license", + "copying", + "changelog", + "authors", + "contributors", + "code_of_conduct", + "contributing", + "security", + "pyproject.toml", + "setup.cfg", + "setup.py", + "tox.ini", + "noxfile.py", + "mkdocs.yml", + "mkdocs.yaml", + "manifest.in", +} + + +def _issue( + code: str, + path: str, + message: str, + suggestion: str, + **details: Any, +) -> dict[str, Any]: + return { + "code": code, + "path": path, + "message": message, + "suggestion": suggestion, + **details, + } + + +def _non_empty_string( + value: Any, + path: str, + issues: list[dict[str, Any]], + *, + required: bool = True, +) -> str | None: + if value is None and not required: + return None + if not isinstance(value, str) or not value.strip(): + issues.append( + _issue( + "INVALID_STRING", + path, + f"{path} must be a non-empty string", + "Provide a concise non-empty value.", + ) + ) + return None + return value.strip() + + +def _string_list( + value: Any, + path: str, + issues: list[dict[str, Any]], + *, + required: bool = False, +) -> list[str]: + if value is None: + if required: + issues.append( + _issue( + "MISSING_LIST", + path, + f"{path} is required", + "Provide an array of non-empty strings.", + ) + ) + return [] + if not isinstance(value, list) or not all( + isinstance(item, str) and item.strip() for item in value + ): + issues.append( + _issue( + "INVALID_LIST", + path, + f"{path} must be an array of non-empty strings", + "Remove empty entries and submit an array of strings.", + ) + ) + return [] + return list(dict.fromkeys(item.strip() for item in value)) + + +def _normalize_owned_path(value: str) -> str | None: + normalized = value.strip().replace("\\", "/") + if normalized == "/workspace": + normalized = "" + elif normalized.startswith("/workspace/"): + normalized = normalized[len("/workspace/") :] + while normalized.startswith("./"): + normalized = normalized[2:] + normalized = posixpath.normpath(normalized).rstrip("/") + if normalized in {"", "."}: + return None + if normalized.startswith("/") or ".." in normalized.split("/"): + return None + if any(mark in normalized for mark in "*?[]"): + return None + return normalized + + +def _paths_overlap(left: str, right: str) -> bool: + return bool( + left == right + or left.startswith(right + "/") + or right.startswith(left + "/") + ) + + +def _task_id() -> str: + return uuid.uuid4().hex[:12] + + +def _agent_id() -> str: + return uuid.uuid4().hex[:12] + + +def _session_id() -> str: + return uuid.uuid4().hex + + +def _is_trivial_check(command: str) -> bool: + normalized = " ".join(command.strip().lower().split()) + if bool( + normalized in {"true", ":", "ls", "pwd"} + or re.fullmatch(r"(?:echo|printf)(?:\s+.*)?", normalized) + or re.fullmatch(r"(?:/bin/)?ls(?:\s+.*)?", normalized) + or re.fullmatch(r"test\s+-[efd]\s+.*", normalized) + or re.fullmatch(r"\[\s+-[efd]\s+.*\s+\]", normalized) + ): + return True + shell_control = _unquoted_shell_control(command) + if "||" in shell_control: + return True + if re.search( + r"(?:;|\n)\s*(?::|true\b|exit\s+0\b)\s*$", + shell_control, + flags=re.IGNORECASE, + ): + return True + if re.search(r"[;&|\n]", shell_control): + return False + try: + parts = shlex.split(command) + except ValueError: + return False + code: str | None = None + for index, part in enumerate(parts): + executable = posixpath.basename(part).lower() + if not re.fullmatch(r"python(?:\d+(?:\.\d+)*)?", executable): + continue + try: + code_flag = parts.index("-c", index + 1) + except ValueError: + continue + code = parts[code_flag + 1] if code_flag + 1 < len(parts) else "" + break + return code is not None and _is_trivial_python(code) + + +def _decode_json_compatible_field( + value: Any, + path: str, + expected_type: type[Any], + issues: list[dict[str, Any]], +) -> Any: + """Decode a model-stringified complex field before normal validation. + + This is deliberately only a transport compatibility shim. Parsed values are + still passed through every existing semantic validator below. + """ + + if not isinstance(value, str): + return value + try: + decoded = json.loads(value) + except json.JSONDecodeError as exc: + issues.append( + _issue( + "INVALID_JSON_STRING", + path, + f"{path} contains invalid JSON: {exc.msg}", + f"Pass a native {expected_type.__name__} or valid JSON encoding one.", + line=exc.lineno, + column=exc.colno, + ) + ) + return None + if not isinstance(decoded, expected_type): + issues.append( + _issue( + "INVALID_JSON_VALUE", + path, + ( + f"decoded {path} must be a {expected_type.__name__}, " + f"got {type(decoded).__name__}" + ), + f"Encode the same shape accepted by the native {path} field.", + ) + ) + return None + return decoded + + +def _python_inline_code(command: str) -> str | None: + try: + parts = shlex.split(command) + except ValueError: + return None + for index, part in enumerate(parts): + executable = posixpath.basename(part).lower() + if not re.fullmatch(r"python(?:\d+(?:\.\d+)*)?", executable): + continue + try: + code_flag = parts.index("-c", index + 1) + except ValueError: + continue + return parts[code_flag + 1] if code_flag + 1 < len(parts) else "" + return None + + +def _is_weak_acceptance_check(command: str) -> bool: + """Return true for import/existence-only Python API smoke checks. + + Focused test runners, compilation commands, behavioral assertions, and public + signature assertions remain valid. The check intentionally targets the common + ceremonial pattern ``import X; assert hasattr(...); assert callable(...)``. + """ + + normalized = " ".join(command.strip().lower().split()) + if re.search(r"(?:^|\s)(?:pytest|unittest)(?:\s|$)", normalized): + return False + if re.search(r"python(?:\d+(?:\.\d+)*)?\s+-m\s+(?:pytest|unittest)\b", normalized): + return False + code = _python_inline_code(command) + if code is None: + return False + try: + module = ast.parse(code, mode="exec") + except SyntaxError: + return False + + weak_calls = {"hasattr", "callable", "getattr"} + + def call_name(call: ast.Call) -> str: + if isinstance(call.func, ast.Name): + return call.func.id + if isinstance(call.func, ast.Attribute): + return call.func.attr + return "" + + assertions = [node for node in module.body if isinstance(node, ast.Assert)] + if not assertions: + return all( + isinstance(node, (ast.Import, ast.ImportFrom, ast.Assign, ast.AnnAssign)) + for node in module.body + ) + for assertion in assertions: + calls = [node for node in ast.walk(assertion.test) if isinstance(node, ast.Call)] + names = {call_name(call) for call in calls} + if "signature" in names: + return False + if any(name and name not in weak_calls for name in names): + return False + # A comparison against a value/property is behavioral evidence unless its + # only observations are the weak existence/introspection calls above. + if isinstance(assertion.test, (ast.Compare, ast.BoolOp, ast.BinOp)) and not calls: + return False + if not calls and any( + isinstance(node, (ast.Attribute, ast.Subscript)) + for node in ast.walk(assertion.test) + ): + return False + return True + + +def _is_substantive_source_path(value: str) -> bool: + normalized = value.strip().replace("\\", "/").strip("/") + if not normalized: + return False + parts = [part.casefold() for part in normalized.split("/") if part] + first = parts[0] if parts else "" + name = parts[-1] if parts else "" + stem = name.rsplit(".", 1)[0] if "." in name else name + if first in _CEREMONIAL_ROOTS: + return False + if name in _CEREMONIAL_FILES or stem in _CEREMONIAL_FILES: + return False + if name.startswith("requirements") or name.endswith((".lock", ".md", ".rst")): + return False + suffix = posixpath.splitext(name)[1].casefold() + if suffix in _SOURCE_SUFFIXES and name not in {"setup.py", "noxfile.py"}: + return True + # A concrete directory such as ``src/pkg`` or ``package`` denotes a source + # partition unless it is one of the documentation/test/CI roots above. + return not suffix and not name.startswith(".") + + +def _unquoted_shell_control(command: str) -> str: + """Keep shell control text while blanking quoted payloads.""" + + output: list[str] = [] + quote: str | None = None + escaped = False + for char in command: + if escaped: + output.append(" " if quote is not None else char) + escaped = False + continue + if char == "\\" and quote != "'": + output.append(" " if quote is not None else char) + escaped = True + continue + if quote is not None: + if char == quote: + quote = None + output.append(" ") + continue + if char in {"'", '"'}: + quote = char + output.append(" ") + continue + output.append(char) + return "".join(output) + + +def _is_trivial_python(code: str) -> bool: + try: + module = ast.parse(code, mode="exec") + except SyntaxError: + return False + if not module.body: + return True + + def is_zero(value: ast.AST) -> bool: + return isinstance(value, ast.Constant) and value.value in {None, 0} + + def is_success_exit(call: ast.Call) -> bool: + function = call.func + named_exit = isinstance(function, ast.Name) and function.id in {"exit", "quit"} + sys_exit = ( + isinstance(function, ast.Attribute) + and function.attr == "exit" + and isinstance(function.value, ast.Name) + and function.value.id == "sys" + ) + return bool( + (named_exit or sys_exit) + and not call.keywords + and (not call.args or (len(call.args) == 1 and is_zero(call.args[0]))) + ) + + def is_trivial_statement(statement: ast.stmt) -> bool: + if isinstance(statement, ast.Pass): + return True + if isinstance(statement, ast.Assert): + # An assertion containing no runtime name/call can only test a value + # invented by the check itself (for example ``assert True``). + return not any( + isinstance(node, (ast.Name, ast.Attribute, ast.Call, ast.Subscript)) + for node in ast.walk(statement.test) + ) + if isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Call): + call = statement.value + if isinstance(call.func, ast.Name) and call.func.id == "print": + return True + return is_success_exit(call) + if isinstance(statement, ast.Raise) and isinstance(statement.exc, ast.Call): + call = statement.exc + return bool( + isinstance(call.func, ast.Name) + and call.func.id == "SystemExit" + and not call.keywords + and (not call.args or (len(call.args) == 1 and is_zero(call.args[0]))) + ) + return False + + return all(is_trivial_statement(statement) for statement in module.body) + + +def _plan_schema() -> dict[str, Any]: + schema: dict[str, Any] = { + "type": "object", + "additionalProperties": False, + "properties": { + "mode": {"type": "string", "enum": ["replace"]}, + "expected_revision": {"type": "integer", "minimum": 0}, + "idempotency_key": {"type": "string"}, + "contract": { + "type": "object", + "additionalProperties": False, + "properties": { + "summary": {"type": "string"}, + "interfaces": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": False, + "properties": { + "name": {"type": "string"}, + "provider_task": {"type": "string"}, + "consumer_tasks": { + "type": "array", + "items": {"type": "string"}, + }, + "signature": {"type": "string"}, + "mode": { + "type": "string", + "enum": ["frozen", "handoff"], + }, + }, + "required": [ + "name", + "provider_task", + "consumer_tasks", + "signature", + "mode", + ], + }, + }, + }, + "required": ["summary", "interfaces"], + }, + "workers": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": False, + "properties": { + "name": {"type": "string"}, + "role": {"type": "string"}, + "instructions": {"type": "string"}, + "tools": {"type": "array", "items": {"type": "string"}}, + "model": {"type": "string"}, + "workspace_mode": { + "type": "string", + "enum": ["auto", "shared", "worktree"], + }, + "auto_integrate": {"type": "boolean"}, + }, + "required": ["name", "instructions"], + }, + }, + "tasks": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": False, + "properties": { + "key": {"type": "string"}, + "subject": {"type": "string"}, + "instructions": {"type": "string"}, + "description": {"type": "string"}, + "owner": {"type": "string"}, + "kind": { + "type": "string", + "enum": ["implementation", "validation"], + }, + "owned_files": { + "type": "array", + "items": {"type": "string"}, + "description": ( + "Concrete deliverable paths owned by this task; include any " + "persistent project test files. Teammates receive a separate " + "task-private scratch location for disposable self-tests." + ), + }, + "acceptance_checks": { + "type": "array", + "items": {"type": "string"}, + "description": ( + "Executable shell commands that assert behavior; wrap Python " + "expressions with python -c instead of supplying bare Python." + ), + }, + "blocked_by": { + "type": "array", + "items": {"type": "string"}, + }, + "metadata": {"type": "object"}, + }, + "required": ["key", "owner"], + }, + }, + "validation": { + "type": "object", + "additionalProperties": False, + "properties": { + "profile": { + "type": "string", + "enum": ["python-package", "generic"], + }, + "install_command": {"type": "string"}, + "import_command": {"type": "string"}, + "integration_command": {"type": "string"}, + "imports": {"type": "array", "items": {"type": "string"}}, + "commands": {"type": "array", "items": {"type": "string"}}, + }, + }, + "execution": { + "type": "object", + "additionalProperties": False, + "properties": { + **{ + name: { + "type": "number" if name == "timeout_s" else "integer" + } + for name in _EXECUTION_BOUNDS + }, + "auto_verify": {"type": "boolean"}, + }, + }, + }, + "required": ["contract", "workers", "tasks", "validation"], + } + properties = schema["properties"] + for field in _JSON_COMPATIBLE_FIELDS: + native_schema = properties[field] + properties[field] = { + "oneOf": [ + native_schema, + { + "type": "string", + "description": ( + f"JSON-encoded {field}; decoded and subjected to the same " + "semantic validation as the native value." + ), + }, + ] + } + return schema + + +class TeamPlanTool: + def spec(self) -> ToolSpec: + return ToolSpec( + name="TeamPlan", + description=( + "Atomically replace a strict team's complete worker/task plan. Submit the " + "contract, workers, tasks, validation, and execution settings together. " + "The tool normalizes paths and returns structured needs_plan_fix issues " + "without leaving partial workers or tasks. Frozen interfaces permit " + "parallel work; handoff interfaces create task dependencies." + ), + input_schema=_plan_schema(), + is_read_only=False, + max_result_size_chars=200_000, + strict=True, + ) + + def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: + if context.actor_id is not None: + raise ToolInputError("only the lead may submit a team plan") + if context.team is None: + raise ToolInputError("no active team: create a strict team before TeamPlan") + + team_id = str(context.team["team_id"]) + team = context.team_store.load_team(team_id) + if team is None: + raise ToolInputError("active team state is unavailable") + context.reload_team_state() + current_plan = team.settings.get("team_plan") + current_plan = current_plan if isinstance(current_plan, dict) else {} + current_revision = int(current_plan.get("revision") or 0) + issues: list[dict[str, Any]] = [] + decoded_fields = { + field: _decode_json_compatible_field( + tool_input.get(field), field, expected_type, issues + ) + for field, expected_type in _JSON_COMPATIBLE_FIELDS.items() + } + + mode = tool_input.get("mode", "replace") + if mode != "replace": + issues.append( + _issue( + "UNSUPPORTED_MODE", + "mode", + "TeamPlan v2 only supports mode='replace'", + "Resubmit the complete plan with mode='replace'.", + ) + ) + expected_revision = tool_input.get("expected_revision") + if expected_revision is not None and ( + isinstance(expected_revision, bool) + or not isinstance(expected_revision, int) + or expected_revision < 0 + ): + issues.append( + _issue( + "INVALID_REVISION", + "expected_revision", + "expected_revision must be a non-negative integer", + f"Use the current revision, {current_revision}.", + current_revision=current_revision, + ) + ) + idempotency_key = tool_input.get("idempotency_key") + if idempotency_key is not None: + idempotency_key = _non_empty_string( + idempotency_key, "idempotency_key", issues + ) + + busy_tasks = [ + str(task.get("key") or task_id) + for task_id, task in context.tasks.items() + if task.get("status") == "in_progress" + ] + busy_agents = [ + agent.name + for agent in context.team_store.list_agents(team_id) + if agent.status in {"running", "stopping"} + ] + if busy_tasks or busy_agents: + issues.append( + _issue( + "PLAN_BUSY", + "mode", + "a running plan cannot be replaced", + ( + "Call TeamCancel, wait for every worker to stop, then call " + "TeamReplan before replacing the plan. Do not use TeamAbort; " + "it is terminal." + ), + active_tasks=busy_tasks, + active_workers=busy_agents, + ) + ) + + execution = self._execution(tool_input.get("execution"), issues) + validation = self._validation(decoded_fields["validation"], issues) + workers, worker_by_name = self._workers( + decoded_fields["workers"], context, issues + ) + if "max_workers" not in execution: + distinct_workers = {worker["name"].lower() for worker in workers} + execution["max_workers"] = min(max(len(distinct_workers), 1), 16) + task_specs = self._tasks(decoded_fields["tasks"], worker_by_name, issues) + contract = self._contract(decoded_fields["contract"], task_specs, issues) + self._apply_contract(contract, task_specs, issues) + self._validate_task_graph(task_specs, workers, validation, issues) + + if issues: + return self._needs_fix(current_revision, issues) + + canonical = { + "mode": "replace", + "contract": contract, + "workers": workers, + "tasks": task_specs, + "validation": validation, + "execution": execution, + } + encoded = json.dumps( + canonical, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + plan_hash = hashlib.sha256(encoded).hexdigest() + contract_hash = hashlib.sha256( + json.dumps( + contract, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + ).hexdigest() + agents, sessions, tasks = self._materialize( + team_id, workers, task_specs, plan_hash, contract_hash, validation + ) + architecture_contract = self._architecture_contract(contract) + quality = { + "strict": True, + "configured": True, + "protocol_version": 2, + "architecture_contract": architecture_contract, + "contract": contract, + "contract_hash": contract_hash, + "install_command": validation["install_command"], + "import_command": validation["import_command"], + "integration_command": validation["integration_command"], + "validation_profile": validation, + "plan_accepted": False, + "validation": {"status": "pending", "reason": "new plan revision"}, + } + # Protocol v2 has one authoritative execution source: the immutable plan + # plus its execution_manifest. Legacy top-level settings are intentionally + # not populated, because TeamRun/TeamResume previously drifted them away + # from the accepted plan and caused completed work to lose protocol credit. + settings_updates = {"quality_gates": quality} + plan_record = { + "hash": plan_hash, + "contract_hash": contract_hash, + **canonical, + } + + try: + stored_team, changed = context.team_store.replace_team_plan( + team_id, + tasks=tasks, + agents=agents, + sessions=sessions, + settings_updates=settings_updates, + plan_record=plan_record, + expected_revision=expected_revision, + idempotency_key=idempotency_key, + ) + except ValueError as exc: + message = str(exc) + code = ( + "TEAM_TERMINAL" + if "terminal" in message + else "PLAN_BUSY" + if "running plan cannot be replaced" in message + else "REPLAN_REQUIRED" + if "TeamReplan" in message + else "IDEMPOTENCY_KEY_REUSE" + if "idempotency_key" in message + else "REVISION_CONFLICT" + if "revision" in message + else "PLAN_COMMIT_CONFLICT" + ) + return self._needs_fix( + current_revision, + [ + _issue( + code, + ( + "idempotency_key" + if code == "IDEMPOTENCY_KEY_REUSE" + else "mode" + if code + in {"TEAM_TERMINAL", "REPLAN_REQUIRED", "PLAN_BUSY"} + else "expected_revision" + ), + message, + ( + "Keep the completed/aborted team unchanged for scoring." + if code == "TEAM_TERMINAL" + else "Stop active workers, then call TeamReplan before replacing the plan." + if code == "PLAN_BUSY" + else "Call TeamReplan, then submit one complete replacement plan." + if code == "REPLAN_REQUIRED" + else "Reload the active plan and resubmit against its current revision." + ), + ) + ], + ) + + persisted = stored_team.settings.get("team_plan") or {} + revision = int(persisted.get("revision") or current_revision) + context.reload_team_state() + if changed: + context.team_store.append_event( + team_id, + "team.plan_committed", + { + "revision": revision, + "plan_hash": plan_hash, + "protocol_version": 2, + "worker_count": len(agents), + "task_count": len(tasks), + }, + ) + carry_forward = stored_team.settings.get("last_plan_carry_forward") + if ( + isinstance(carry_forward, dict) + and int(carry_forward.get("plan_revision") or 0) == revision + and carry_forward.get("tasks") + ): + context.team_store.append_event( + team_id, + "team.tasks_carried_forward", + { + "from_revision": carry_forward.get("from_revision"), + "plan_revision": revision, + "tasks": carry_forward["tasks"], + "requires_acceptance": True, + }, + ) + carry_forward = stored_team.settings.get("last_plan_carry_forward") + carry_forward = carry_forward if isinstance(carry_forward, dict) else {} + return ToolResult( + name="TeamPlan", + output={ + "status": "ready", + "team_id": team_id, + "protocol_version": 2, + "revision": revision, + "plan_hash": str(persisted.get("hash") or plan_hash), + "idempotent": not changed, + "workers": [ + { + "name": agent.name, + "agent_id": agent.agent_id, + "workspace_mode": agent.workspace_mode, + } + for agent in context.team_store.list_agents(team_id) + ], + "tasks": [ + { + "key": task.get("key"), + "id": task_id, + "owner": task.get("owner"), + "blocked_by": list(task.get("blockedBy") or []), + "owned_files": list(task.get("owned_files") or []), + } + for task_id, task in context.tasks.items() + ], + "contract": persisted.get("contract") or contract, + "validation": persisted.get("validation") or validation, + "execution": persisted.get("execution") or execution, + "carried_forward_tasks": list(carry_forward.get("tasks") or []), + "next_required_actions": [ + { + "tool": "TeamRun", + "instruction": ( + "Run the committed plan. The harness owns final verification." + ), + } + ], + }, + ) + + @staticmethod + def _needs_fix( + revision: int, issues: list[dict[str, Any]] + ) -> ToolResult: + return ToolResult( + name="TeamPlan", + output={ + "status": "needs_plan_fix", + "revision": revision, + "issues": issues, + "next_required_action": ( + "Fix every issue and replace the complete plan in one TeamPlan call." + ), + }, + is_error=True, + ) + + @staticmethod + def _execution( + value: Any, issues: list[dict[str, Any]] + ) -> dict[str, int | float | bool | None]: + if value is None: + return dict(_EXECUTION_DEFAULTS) + if not isinstance(value, dict): + issues.append( + _issue( + "INVALID_EXECUTION", + "execution", + "execution must be an object", + "Provide only supported TeamRun numeric options.", + ) + ) + return dict(_EXECUTION_DEFAULTS) + output: dict[str, int | float | bool | None] = dict(_EXECUTION_DEFAULTS) + for name, raw in value.items(): + if name == "auto_verify": + if raw is not True: + issues.append( + _issue( + "AUTO_VERIFY_REQUIRED", + "execution.auto_verify", + "protocol v2 always performs harness-owned verification", + "Set auto_verify=true or omit it.", + ) + ) + continue + if name not in _EXECUTION_BOUNDS: + issues.append( + _issue( + "UNKNOWN_EXECUTION_OPTION", + f"execution.{name}", + f"unknown execution option {name!r}", + "Remove this option.", + ) + ) + continue + minimum, maximum = _EXECUTION_BOUNDS[name] + valid_type = isinstance(raw, (int, float)) and not isinstance(raw, bool) + if name != "timeout_s": + valid_type = isinstance(raw, int) and not isinstance(raw, bool) + if not valid_type or raw < minimum or raw > maximum: + issues.append( + _issue( + "INVALID_EXECUTION_OPTION", + f"execution.{name}", + f"{name} must be between {minimum} and {maximum}", + "Choose a value within the supported bounds.", + ) + ) + continue + output[name] = raw + return output + + @staticmethod + def _validation(value: Any, issues: list[dict[str, Any]]) -> dict[str, Any]: + if not isinstance(value, dict): + issues.append( + _issue( + "INVALID_VALIDATION", + "validation", + "validation must be an object", + "Provide a python-package or generic validation profile.", + ) + ) + value = {} + profile = value.get("profile", "python-package") + if profile not in {"python-package", "generic"}: + issues.append( + _issue( + "INVALID_VALIDATION_PROFILE", + "validation.profile", + "profile must be 'python-package' or 'generic'", + "Use the profile matching the repository.", + ) + ) + profile = "python-package" + imports = _string_list(value.get("imports"), "validation.imports", issues) + commands = _string_list(value.get("commands"), "validation.commands", issues) + for index, module in enumerate(imports): + if not _MODULE_NAME.fullmatch(module): + issues.append( + _issue( + "INVALID_IMPORT", + f"validation.imports[{index}]", + f"invalid Python module name {module!r}", + "Use a dotted import name such as package.submodule.", + ) + ) + for index, command in enumerate(commands): + if _is_trivial_check(command): + issues.append( + _issue( + "TRIVIAL_VALIDATION_CHECK", + f"validation.commands[{index}]", + f"validation command {command!r} does not verify behavior", + "Run a real import, API, integration, or test-suite assertion.", + ) + ) + install = value.get("install_command") + import_command = value.get("import_command") + integration = value.get("integration_command") + if profile == "python-package": + install = install or "python -m pip install -e . --no-deps --no-build-isolation" + if not import_command and imports: + import_command = "python -c " + shlex.quote( + "; ".join(f"import {module}" for module in imports) + ) + integration = integration or ( + " && ".join(commands) if commands else "python -m pytest -q" + ) + else: + install = install or "true" + import_command = import_command or "true" + integration = integration or (" && ".join(commands) if commands else None) + install = _non_empty_string( + install, "validation.install_command", issues + ) + import_command = _non_empty_string( + import_command, "validation.import_command", issues + ) + integration = _non_empty_string( + integration, "validation.integration_command", issues + ) + if integration and _is_trivial_check(integration): + issues.append( + _issue( + "TRIVIAL_VALIDATION_CHECK", + "validation.integration_command", + f"integration command {integration!r} does not verify behavior", + "Run a real integration assertion or repository test suite.", + ) + ) + return { + "profile": profile, + "imports": imports, + "commands": commands, + "install_command": install or "", + "import_command": import_command or "", + "integration_command": integration or "", + } + + @staticmethod + def _workers( + value: Any, + context: ToolContext, + issues: list[dict[str, Any]], + ) -> tuple[list[dict[str, Any]], dict[str, dict[str, Any]]]: + if not isinstance(value, list): + issues.append( + _issue( + "INVALID_WORKERS", + "workers", + "workers must be an array", + "Provide at least two task-specific workers.", + ) + ) + value = [] + if len(value) < 2: + issues.append( + _issue( + "MIN_WORKERS", + "workers", + "strict TeamPlan requires at least two workers", + "Create at least two workers with distinct owned tasks.", + ) + ) + workers: list[dict[str, Any]] = [] + by_name: dict[str, dict[str, Any]] = {} + for index, raw in enumerate(value): + path = f"workers[{index}]" + if not isinstance(raw, dict): + issues.append( + _issue( + "INVALID_WORKER", + path, + "worker must be an object", + "Replace it with a worker definition.", + ) + ) + continue + name = _non_empty_string(raw.get("name"), f"{path}.name", issues) + instructions = _non_empty_string( + raw.get("instructions"), f"{path}.instructions", issues + ) + role = _non_empty_string( + raw.get("role", "implementation"), f"{path}.role", issues + ) + if name is None: + continue + normalized_name = name.lower() + if normalized_name in by_name: + issues.append( + _issue( + "DUPLICATE_WORKER", + f"{path}.name", + f"worker name {name!r} is duplicated", + "Use a unique worker name.", + ) + ) + continue + tools = _string_list(raw.get("tools", _DEFAULT_TOOLS), f"{path}.tools", issues) + validate_tools = getattr(context.teammate_runtime, "validate_tools", None) + if callable(validate_tools) and tools: + try: + tools = validate_tools(tools) + except ValueError as exc: + issues.append( + _issue( + "INVALID_WORKER_TOOLS", + f"{path}.tools", + str(exc), + "Use only tools available to teammate workers.", + ) + ) + model = raw.get("model") + if model is not None: + model = _non_empty_string(model, f"{path}.model", issues) + validate_model = getattr(context.teammate_runtime, "validate_model", None) + if callable(validate_model): + try: + model = validate_model(model) + except ValueError as exc: + issues.append( + _issue( + "INVALID_WORKER_MODEL", + f"{path}.model", + str(exc), + "Omit model to inherit the lead endpoint model.", + ) + ) + requested_workspace = raw.get("workspace_mode", "auto") + workspace_mode = "shared" if requested_workspace == "auto" else requested_workspace + if workspace_mode == "worktree" and context.workspace_backend is not None: + workspace_mode = "shared" + elif workspace_mode == "worktree": + issues.append( + _issue( + "WORKTREE_REQUIRES_INCREMENTAL_SETUP", + f"{path}.workspace_mode", + "atomic TeamPlan does not create local git worktrees", + "Use workspace_mode='auto' or 'shared'.", + ) + ) + workspace_mode = "shared" + if workspace_mode not in {"shared", "worktree"}: + issues.append( + _issue( + "INVALID_WORKSPACE_MODE", + f"{path}.workspace_mode", + "workspace_mode must be auto, shared, or worktree", + "Use auto so the harness selects a supported mode.", + ) + ) + workspace_mode = "shared" + auto_integrate = bool(raw.get("auto_integrate", False)) + if auto_integrate and workspace_mode != "worktree": + issues.append( + _issue( + "INVALID_AUTO_INTEGRATE", + f"{path}.auto_integrate", + "auto_integrate requires a worktree workspace", + "Disable auto_integrate for shared/AGS workspaces.", + ) + ) + worker = { + "name": name, + "role": role or "implementation", + "instructions": instructions or "", + "tools": tools, + "model": model, + "workspace_mode": workspace_mode, + "auto_integrate": auto_integrate, + } + workers.append(worker) + by_name[normalized_name] = worker + return workers, by_name + + @staticmethod + def _tasks( + value: Any, + workers: dict[str, dict[str, Any]], + issues: list[dict[str, Any]], + ) -> list[dict[str, Any]]: + if not isinstance(value, list): + issues.append( + _issue( + "INVALID_TASKS", + "tasks", + "tasks must be an array", + "Provide at least two implementation tasks.", + ) + ) + value = [] + task_specs: list[dict[str, Any]] = [] + keys: set[str] = set() + for index, raw in enumerate(value): + path = f"tasks[{index}]" + if not isinstance(raw, dict): + issues.append( + _issue( + "INVALID_TASK", + path, + "task must be an object", + "Replace it with a task definition.", + ) + ) + continue + key = _non_empty_string(raw.get("key"), f"{path}.key", issues) + owner = _non_empty_string(raw.get("owner"), f"{path}.owner", issues) + description_value = raw.get("instructions", raw.get("description")) + description = _non_empty_string( + description_value, f"{path}.instructions", issues + ) + subject = _non_empty_string( + raw.get("subject", key or "task"), f"{path}.subject", issues + ) + if key is None or owner is None: + continue + normalized_key = key.lower() + if normalized_key in keys: + issues.append( + _issue( + "DUPLICATE_TASK", + f"{path}.key", + f"task key {key!r} is duplicated", + "Use a stable unique task key.", + ) + ) + continue + keys.add(normalized_key) + worker = workers.get(owner.lower()) + if worker is None: + issues.append( + _issue( + "UNKNOWN_OWNER", + f"{path}.owner", + f"unknown worker {owner!r}", + "Choose a name declared in workers.", + ) + ) + raw_owned = _string_list(raw.get("owned_files"), f"{path}.owned_files", issues) + owned_files: list[str] = [] + for owned_index, owned in enumerate(raw_owned): + normalized = _normalize_owned_path(owned) + if normalized is None: + issues.append( + _issue( + "INVALID_OWNED_PATH", + f"{path}.owned_files[{owned_index}]", + f"owned path {owned!r} is not a concrete workspace path", + "Use a relative concrete path without '..' or glob syntax.", + ) + ) + elif normalized not in owned_files: + owned_files.append(normalized) + kind = raw.get("kind", "implementation") + if kind not in {"implementation", "validation"}: + issues.append( + _issue( + "INVALID_TASK_KIND", + f"{path}.kind", + "kind must be implementation or validation", + "Use validation only for a read-only integration task.", + ) + ) + kind = "implementation" + if kind != "validation" and not owned_files: + issues.append( + _issue( + "MISSING_OWNED_FILES", + f"{path}.owned_files", + f"implementation task {key!r} has no owned files", + "Declare the concrete files or directories this task owns.", + ) + ) + if ( + kind == "implementation" + and owned_files + and not any(_is_substantive_source_path(path) for path in owned_files) + ): + issues.append( + _issue( + "CEREMONIAL_IMPLEMENTATION_TASK", + f"{path}.owned_files", + ( + f"implementation task {key!r} owns only documentation, tests, " + "CI, examples, or packaging metadata" + ), + ( + "Assign this worker a real source partition (.py/.pyi or another " + "core source file/directory), or mark the task as validation." + ), + ) + ) + acceptance = _string_list( + raw.get("acceptance_checks"), f"{path}.acceptance_checks", issues + ) + if kind == "implementation" and not acceptance: + issues.append( + _issue( + "MISSING_ACCEPTANCE_CHECKS", + f"{path}.acceptance_checks", + f"implementation task {key!r} has no behavioral acceptance check", + "Add a focused compile, import, API, or test assertion for this task.", + ) + ) + for check_index, check in enumerate(acceptance): + if _is_trivial_check(check): + issues.append( + _issue( + "TRIVIAL_ACCEPTANCE_CHECK", + f"{path}.acceptance_checks[{check_index}]", + f"acceptance check {check!r} only confirms a happy path or file presence", + "Assert compilation, an API contract, integration behavior, or focused tests.", + ) + ) + if ( + kind == "implementation" + and acceptance + and all(_is_weak_acceptance_check(check) for check in acceptance) + ): + issues.append( + _issue( + "WEAK_ACCEPTANCE_CHECK", + f"{path}.acceptance_checks", + ( + f"implementation task {key!r} only checks imports or API " + "existence (hasattr/callable)" + ), + ( + "Add a behavioral assertion, a public API signature assertion, " + "or a focused pytest/unittest command." + ), + ) + ) + metadata = raw.get("metadata", {}) + if not isinstance(metadata, dict): + issues.append( + _issue( + "INVALID_METADATA", + f"{path}.metadata", + "metadata must be an object", + "Use a JSON object for optional task metadata.", + ) + ) + metadata = {} + blocked_by = _string_list( + raw.get("blocked_by"), f"{path}.blocked_by", issues + ) + task_specs.append( + { + "key": key, + "subject": subject or key, + "description": description or "", + "owner": worker["name"] if worker is not None else owner, + "kind": kind, + "owned_files": owned_files, + "acceptance_checks": acceptance, + "blocked_by": blocked_by, + "provides_interfaces": [], + "depends_on_interfaces": [], + "metadata": {**metadata, "task_type": kind}, + } + ) + return task_specs + + @staticmethod + def _contract( + value: Any, + tasks: list[dict[str, Any]], + issues: list[dict[str, Any]], + ) -> dict[str, Any]: + if not isinstance(value, dict): + issues.append( + _issue( + "INVALID_CONTRACT", + "contract", + "contract must be an object", + "Provide a summary and an interfaces array.", + ) + ) + value = {} + summary = _non_empty_string(value.get("summary"), "contract.summary", issues) + raw_interfaces = value.get("interfaces", []) + if not isinstance(raw_interfaces, list): + issues.append( + _issue( + "INVALID_INTERFACES", + "contract.interfaces", + "interfaces must be an array", + "Provide zero or more frozen/handoff interface objects.", + ) + ) + raw_interfaces = [] + task_keys = {task["key"].lower(): task["key"] for task in tasks} + interfaces: list[dict[str, Any]] = [] + names: set[str] = set() + for index, raw in enumerate(raw_interfaces): + path = f"contract.interfaces[{index}]" + if not isinstance(raw, dict): + issues.append( + _issue( + "INVALID_INTERFACE", + path, + "interface must be an object", + "Provide its name, signature, provider, consumers, and mode.", + ) + ) + continue + name = _non_empty_string(raw.get("name"), f"{path}.name", issues) + signature = _non_empty_string( + raw.get("signature"), f"{path}.signature", issues + ) + provider = _non_empty_string( + raw.get("provider_task"), f"{path}.provider_task", issues + ) + consumers = _string_list( + raw.get("consumer_tasks"), f"{path}.consumer_tasks", issues, required=True + ) + mode = raw.get("mode") + if mode not in {"frozen", "handoff"}: + issues.append( + _issue( + "INVALID_INTERFACE_MODE", + f"{path}.mode", + "interface mode must be frozen or handoff", + "Use frozen for parallel implementation or handoff for an artifact dependency.", + ) + ) + mode = "frozen" + if name is None or provider is None: + continue + normalized_name = name.lower() + if normalized_name in names: + issues.append( + _issue( + "DUPLICATE_INTERFACE", + f"{path}.name", + f"interface {name!r} is duplicated", + "Define each interface exactly once.", + ) + ) + continue + names.add(normalized_name) + provider_key = task_keys.get(provider.lower()) + if provider_key is None: + issues.append( + _issue( + "UNKNOWN_PROVIDER_TASK", + f"{path}.provider_task", + f"unknown provider task {provider!r}", + "Use a key declared in tasks.", + ) + ) + provider_key = provider + canonical_consumers: list[str] = [] + for consumer_index, consumer in enumerate(consumers): + consumer_key = task_keys.get(consumer.lower()) + if consumer_key is None: + issues.append( + _issue( + "UNKNOWN_CONSUMER_TASK", + f"{path}.consumer_tasks[{consumer_index}]", + f"unknown consumer task {consumer!r}", + "Use a key declared in tasks.", + ) + ) + continue + if consumer_key == provider_key: + issues.append( + _issue( + "SELF_INTERFACE_DEPENDENCY", + f"{path}.consumer_tasks[{consumer_index}]", + f"task {consumer_key!r} cannot consume its own interface", + "Remove the provider from consumer_tasks.", + ) + ) + continue + if consumer_key not in canonical_consumers: + canonical_consumers.append(consumer_key) + interfaces.append( + { + "name": name, + "signature": signature or "", + "provider_task": provider_key, + "consumer_tasks": canonical_consumers, + "mode": mode, + } + ) + return {"summary": summary or "", "interfaces": interfaces} + + @staticmethod + def _apply_contract( + contract: dict[str, Any], + tasks: list[dict[str, Any]], + issues: list[dict[str, Any]], + ) -> None: + by_key = {task["key"].lower(): task for task in tasks} + for interface in contract.get("interfaces", []): + provider = by_key.get(str(interface["provider_task"]).lower()) + if provider is None: + continue + name = str(interface["name"]) + provider["provides_interfaces"].append(name) + provider["metadata"].setdefault("interface_contracts", {})[name] = { + "role": "provider", + "mode": interface["mode"], + "signature": interface["signature"], + } + for consumer_key in interface.get("consumer_tasks", []): + consumer = by_key.get(str(consumer_key).lower()) + if consumer is None: + continue + consumer["depends_on_interfaces"].append(name) + consumer["metadata"].setdefault("interface_contracts", {})[name] = { + "role": "consumer", + "mode": interface["mode"], + "signature": interface["signature"], + "provider_task": provider["key"], + } + if ( + interface["mode"] == "handoff" + and provider["key"] not in consumer["blocked_by"] + ): + consumer["blocked_by"].append(provider["key"]) + + @staticmethod + def _validate_task_graph( + tasks: list[dict[str, Any]], + workers: list[dict[str, Any]], + validation: dict[str, Any], + issues: list[dict[str, Any]], + ) -> None: + by_key = {task["key"].lower(): task for task in tasks} + implementation = [task for task in tasks if task["kind"] == "implementation"] + if len(implementation) < 2: + issues.append( + _issue( + "MIN_IMPLEMENTATION_TASKS", + "tasks", + "strict TeamPlan requires at least two implementation tasks", + "Split real implementation work across at least two workers.", + ) + ) + implementation_owners = { + task["owner"].lower() + for task in implementation + if task["owned_files"] + and task["acceptance_checks"] + and any( + _is_substantive_source_path(path) for path in task["owned_files"] + ) + and not all( + _is_weak_acceptance_check(check) + for check in task["acceptance_checks"] + ) + } + if len(implementation_owners) < 2: + issues.append( + _issue( + "MIN_IMPLEMENTATION_OWNERS", + "tasks", + "real implementation work is not owned by two distinct workers", + ( + "Give at least two workers non-overlapping source partitions " + "and behavioral acceptance_checks." + ), + ) + ) + worker_owners = {task["owner"].lower() for task in tasks} + for index, worker in enumerate(workers): + if worker["name"].lower() not in worker_owners: + issues.append( + _issue( + "UNASSIGNED_WORKER", + f"workers[{index}].name", + f"worker {worker['name']!r} owns no task", + "Remove the ceremonial worker or assign it real work.", + ) + ) + for index, task in enumerate(tasks): + canonical_dependencies: list[str] = [] + for dep_index, identity in enumerate(task["blocked_by"]): + dependency = by_key.get(identity.lower()) + if dependency is None: + issues.append( + _issue( + "UNKNOWN_TASK_DEPENDENCY", + f"tasks[{index}].blocked_by[{dep_index}]", + f"unknown task dependency {identity!r}", + "Use a key declared in tasks.", + ) + ) + continue + if dependency["key"] == task["key"]: + issues.append( + _issue( + "SELF_TASK_DEPENDENCY", + f"tasks[{index}].blocked_by[{dep_index}]", + "a task cannot block on itself", + "Remove this dependency.", + ) + ) + continue + if dependency["key"] not in canonical_dependencies: + canonical_dependencies.append(dependency["key"]) + task["blocked_by"] = canonical_dependencies + if task["kind"] == "validation" and not task["acceptance_checks"]: + task["acceptance_checks"] = [validation["integration_command"]] + + paths: list[tuple[int, dict[str, Any], str]] = [] + for index, task in enumerate(tasks): + for owned in task["owned_files"]: + paths.append((index, task, owned)) + for position, (left_index, left_task, left_path) in enumerate(paths): + for right_index, right_task, right_path in paths[position + 1 :]: + if left_task["key"] == right_task["key"]: + continue + if left_task["owner"].lower() == right_task["owner"].lower(): + continue + if _paths_overlap(left_path, right_path): + issues.append( + _issue( + "PATH_OVERLAP", + f"tasks[{right_index}].owned_files", + ( + f"cross-owner path overlap: {left_path!r} owned by " + f"{left_task['owner']!r}, {right_path!r} owned by " + f"{right_task['owner']!r}" + ), + "Split ownership into non-overlapping concrete paths.", + conflicts_with=f"tasks[{left_index}].owned_files", + ) + ) + + visiting: set[str] = set() + visited: set[str] = set() + + def visit(key: str, trail: list[str]) -> None: + if key in visited: + return + if key in visiting: + cycle = trail[trail.index(key) :] + [key] + issues.append( + _issue( + "TASK_DEPENDENCY_CYCLE", + "tasks", + "task dependency cycle: " + " -> ".join(cycle), + "Change at least one handoff to frozen or remove a dependency.", + ) + ) + return + visiting.add(key) + task = by_key.get(key) + if task is not None: + for dependency in task["blocked_by"]: + visit(dependency.lower(), [*trail, dependency.lower()]) + visiting.remove(key) + visited.add(key) + + for key in by_key: + visit(key, [key]) + ready_owners = { + task["owner"].lower() + for task in implementation + if not task["blocked_by"] + } + if len(ready_owners) < 2: + issues.append( + _issue( + "INSUFFICIENT_PARALLEL_START", + "tasks", + "fewer than two distinct workers have initially ready tasks", + "Use frozen contracts for work that can start from a shared signature.", + ) + ) + + @staticmethod + def _materialize( + team_id: str, + workers: list[dict[str, Any]], + task_specs: list[dict[str, Any]], + plan_hash: str, + contract_hash: str, + validation: dict[str, Any], + ) -> tuple[list[AgentRecord], dict[str, dict[str, Any]], dict[str, dict[str, Any]]]: + agents: list[AgentRecord] = [] + sessions: dict[str, dict[str, Any]] = {} + agent_by_name: dict[str, AgentRecord] = {} + for worker in workers: + agent = AgentRecord( + agent_id=_agent_id(), + team_id=team_id, + name=worker["name"], + role=worker["role"], + session_id=_session_id(), + model=worker["model"], + instructions=worker["instructions"], + tools=list(worker["tools"]), + workspace_mode=worker["workspace_mode"], + auto_integrate=worker["auto_integrate"], + ) + agents.append(agent) + agent_by_name[agent.name.lower()] = agent + sessions[agent.session_id] = { + "session_id": agent.session_id, + "team_id": team_id, + "agent_id": agent.agent_id, + "model": agent.model, + "conversation": {"messages": [], "max_history": 300}, + } + task_id_by_key = {task["key"].lower(): _task_id() for task in task_specs} + tasks: dict[str, dict[str, Any]] = {} + for task_spec in task_specs: + task_id = task_id_by_key[task_spec["key"].lower()] + dependency_ids = [ + task_id_by_key[identity.lower()] + for identity in task_spec["blocked_by"] + ] + task = TeamTask( + id=task_id, + key=task_spec["key"], + subject=task_spec["subject"], + description=task_spec["description"], + owner=agent_by_name[task_spec["owner"].lower()].agent_id, + blockedBy=dependency_ids, + metadata={ + **task_spec["metadata"], + "plan_hash": plan_hash, + "contract_hash": contract_hash, + "task_contract_fingerprint": ( + TeamPlanTool._task_contract_fingerprint( + task_spec, contract_hash + ) + ), + "validation_profile": validation["profile"], + }, + owned_files=list(task_spec["owned_files"]), + provides_interfaces=list(task_spec["provides_interfaces"]), + depends_on_interfaces=list(task_spec["depends_on_interfaces"]), + acceptance_checks=list(task_spec["acceptance_checks"]), + ) + tasks[task_id] = task.to_dict() + for task in tasks.values(): + for dependency_id in task["blockedBy"]: + tasks[dependency_id]["blocks"].append(task["id"]) + return agents, sessions, tasks + + @staticmethod + def _task_contract_fingerprint( + task_spec: dict[str, Any], contract_hash: str + ) -> str: + """Hash only the stable artifact contract, never revision/runtime state.""" + + payload = { + "schema_version": 1, + "contract_hash": contract_hash, + "key": str(task_spec["key"]), + "subject": str(task_spec["subject"]), + "description": str(task_spec["description"]), + "kind": str(task_spec["kind"]), + "owned_files": sorted(str(path) for path in task_spec["owned_files"]), + "acceptance_checks": [ + str(command) for command in task_spec["acceptance_checks"] + ], + "blocked_by": sorted( + str(identity).lower() for identity in task_spec["blocked_by"] + ), + "provides_interfaces": sorted( + str(name) for name in task_spec["provides_interfaces"] + ), + "depends_on_interfaces": sorted( + str(name) for name in task_spec["depends_on_interfaces"] + ), + "metadata": task_spec["metadata"], + } + encoded = json.dumps( + payload, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + @staticmethod + def _architecture_contract(contract: dict[str, Any]) -> str: + lines = [str(contract["summary"])] + for interface in contract.get("interfaces", []): + consumers = ", ".join(interface["consumer_tasks"]) or "none" + lines.append( + f"- {interface['name']}: {interface['signature']} " + f"[{interface['mode']}] {interface['provider_task']} -> {consumers}" + ) + return "\n".join(lines) diff --git a/src/tool_system/tools/tool_search.py b/src/tool_system/tools/tool_search.py index a01f3c6..fda88c6 100644 --- a/src/tool_system/tools/tool_search.py +++ b/src/tool_system/tools/tool_search.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from typing import Any from ..context import ToolContext @@ -15,7 +16,7 @@ def __init__(self, registry: ToolRegistry): def spec(self) -> ToolSpec: return ToolSpec( name="ToolSearch", - description="Search for available tools by name or keywords.", + description="Search registered tools by name or keywords. Use '*' to list all tools.", input_schema={ "type": "object", "additionalProperties": False, @@ -43,27 +44,76 @@ def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: if lowered.startswith("select:"): name = q.split(":", 1)[1].strip() tool = self._registry.get(name) - matches = [tool.spec().name] if tool else [] - return ToolResult( - name="ToolSearch", - output={ - "matches": matches, - "query": query, - "total_deferred_tools": 0, - }, - ) + specs = [tool.spec()] if tool else [] + return self._result(query, specs) + + all_specs = self._registry.list_specs() + if lowered in {"*", "all", "list all", "all tools"}: + return self._result(query, all_specs[:max_results], total_matches=len(all_specs)) - scored: list[tuple[int, str]] = [] - for spec in self._registry.list_specs(): - hay = f"{spec.name}\n{spec.description}".lower() - if lowered in spec.name.lower(): - scored.append((0, spec.name)) + query_terms = self._query_terms(lowered) + scored: list[tuple[int, int, str, ToolSpec]] = [] + for spec in all_specs: + name = spec.name.lower() + aliases = " ".join(spec.aliases).lower() + hay = f"{name} {aliases} {spec.description.lower()}" + matched_terms = sum(1 for term in query_terms if term in hay) + if lowered == name: + rank = 0 + elif lowered in name: + rank = 1 elif lowered in hay: - scored.append((1, spec.name)) - scored.sort(key=lambda t: (t[0], t[1].lower())) - matches = [name for _, name in scored[:max_results]] + rank = 2 + elif matched_terms: + rank = 3 + else: + continue + scored.append((rank, -matched_terms, name, spec)) + scored.sort(key=lambda item: item[:3]) + matched_specs = [item[3] for item in scored] + return self._result(query, matched_specs[:max_results], total_matches=len(matched_specs)) + + @staticmethod + def _query_terms(query: str) -> set[str]: + terms = set(re.findall(r"[a-z0-9_]+", query)) + synonyms = { + "cat": {"read"}, + "content": {"read"}, + "execute": {"bash"}, + "filesystem": {"file"}, + "local": {"file"}, + "modify": {"edit", "write"}, + "run": {"bash"}, + "shell": {"bash"}, + } + expanded = set(terms) + for term in terms: + expanded.update(synonyms.get(term, set())) + return expanded + + @staticmethod + def _tool_details(spec: ToolSpec) -> dict[str, Any]: + return { + "name": spec.name, + "description": spec.description, + "input_schema": dict(spec.input_schema), + **({"aliases": list(spec.aliases)} if spec.aliases else {}), + } + + def _result( + self, + query: str, + specs: list[ToolSpec], + *, + total_matches: int | None = None, + ) -> ToolResult: return ToolResult( name="ToolSearch", - output={"matches": matches, "query": query, "total_deferred_tools": 0}, + output={ + "matches": [spec.name for spec in specs], + "tools": [self._tool_details(spec) for spec in specs], + "query": query, + "total_matches": len(specs) if total_matches is None else total_matches, + "total_deferred_tools": 0, + }, ) - diff --git a/src/tool_system/tools/write.py b/src/tool_system/tools/write.py index 0d948fa..2780263 100644 --- a/src/tool_system/tools/write.py +++ b/src/tool_system/tools/write.py @@ -9,6 +9,7 @@ from ..permission_handler import PermissionResult from ..protocol import ToolResult from ..diff_utils import unified_diff_hunks +from ..ownership import require_owned_path from ..registry import ToolSpec @@ -61,15 +62,19 @@ def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: path = context.ensure_allowed_path(file_path) - original_file: str | None = None - if path.exists(): - if not context.was_file_read_and_unchanged(path): - raise ToolInputError("refusing to overwrite: file must be read first and unchanged since last read") - original_file = path.read_text(encoding="utf-8", errors="replace") + with context.mutation_lock: + require_owned_path(context, path, tool_name="Write") + original_file: str | None = None + if path.exists(): + if not context.was_file_read_and_unchanged(path): + raise ToolInputError( + "refusing to overwrite: file must be read first and unchanged since last read" + ) + original_file = path.read_text(encoding="utf-8", errors="replace") - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(content, encoding="utf-8") - context.mark_file_read(path) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + context.mark_file_read(path) before_lines = (original_file or "").splitlines(keepends=True) after_lines = content.splitlines(keepends=True) diff_lines = list( diff --git a/teammate-evals/nl2repo-pilot/README.md b/teammate-evals/nl2repo-pilot/README.md new file mode 100644 index 0000000..ee830a2 --- /dev/null +++ b/teammate-evals/nl2repo-pilot/README.md @@ -0,0 +1,397 @@ +# NL2Repo Pilot Benchmark + +This adapter evaluates Clawd against a pinned external checkout of +[NL2Repo-Bench](https://github.com/multimodal-art-projection/NL2RepoBench). +The upstream task documents and hidden tests are not copied into this repository. +The default pinned commit is `781a1da1ee41fb8edb0bed22f586d69111610edf`. + +The pilot compares solo execution with adaptive lead-controlled collaboration. +`adaptive-team-v2` and `forced-team` use protocol v2: the lead submits one atomic +`TeamPlan`, the harness materializes exactly two real implementation workers, freezes +file ownership and interface contracts, runs the task DAG, and performs fresh-environment +acceptance before the Team can complete. A failed verification requires a replacement +plan revision; the lead cannot reopen a completed Team by mutating legacy tasks. + +Scoring separates three questions instead of hiding Team failures behind one number: + +- **Q (code quality):** hidden-test score for every intact, scoreable workspace, even + when the Team protocol failed. +- **P (protocol yield):** fraction of eligible rollouts that completed the committed + TeamPlan, including real task attempts, produced events, harness acceptance, and final + verification. +- **E (effective quality):** delivery-valid quality multiplied by protocol credit. + +Rollout or scorer infrastructure failures are retryable and excluded from Q/P/E. Candidate +timeouts and incomplete Teams remain candidate/protocol outcomes. Results and the dashboard +also expose stable `failure_domain`, `failure_class`, `reward_outcome`, and metric-eligibility +fields so infrastructure does not silently become a zero-quality sample. + +## Docker environment + +The scorer requires a running Docker-compatible daemon with `linux/amd64` +emulation. On Apple Silicon with Colima: + +```bash +brew install colima docker +colima start --cpu 6 --memory 12 --disk 100 --vm-type vz --vz-rosetta +docker run --rm --platform linux/amd64 alpine:3.20 uname -m +``` + +The first benchmark invocation clones the pinned upstream repository into +`~/.cache/clawd-code/nl2repo-bench/`. Set `NL2REPO_BENCH_ROOT` or pass +`--upstream-root` to use an existing checkout. + +## Tencent AGS environment + +AGS can replace Docker for agent execution, scoring, or both. Install the +customized SWE-ReX checkout next to this repository and the AGS transport +dependencies: + +```bash +cd /Users/dexter/Desktop/workspace/Multi-agent/Clawd-Code +.venv/bin/python -m pip install -e ../sandbox/SWE-ReX +.venv/bin/python -m pip install -e '.[ags]' +``` + +Configure `AGS_SECRET_ID`, `AGS_SECRET_KEY`, and preferably an existing +`AGS_TOOL_ID`. The launcher automatically discovers `../sandbox/ags/.env`, or +you can select it explicitly with `--ags-env-file`. Credentials are never +copied into the sandbox or written to benchmark results. + +Validate imports, credentials, task metadata, and image selection without +starting a cloud instance. The recommended first configuration runs the agent +in AGS and keeps the existing network-isolated Docker scorer: + +```bash +.venv/bin/python teammate-evals/nl2repo-pilot/benchmark.py \ + --task jsonlines \ + --execution-backend ags \ + --score-backend docker \ + --ags-env-file ../sandbox/ags/.env \ + --validate +``` + +Run the agent in AGS: + +```bash +.venv/bin/python teammate-evals/nl2repo-pilot/benchmark.py \ + --provider anthropic \ + --model glm-5.2 \ + --task jsonlines \ + --mode solo \ + --execution-backend ags \ + --score-backend docker \ + --ags-env-file ../sandbox/ags/.env \ + --ags-timeout 3h \ + --ags-cpu 2 \ + --ags-memory 4Gi +``` + +The agent instance is reset before `start.md` is uploaded, so it cannot see the +image's hidden tests. After the agent exits, its repository is downloaded and +the instance is stopped. The sandbox TTL is a cleanup backstop if the local +process is killed. + +To move scoring to AGS too, create a second reusable SandboxTool configured +with `NetworkConfiguration.NetworkMode=SANDBOX` and save its ID as +`AGS_SCORE_TOOL_ID`. A normal `PUBLIC` tool is deliberately rejected: Tencent +AGS instances inherit their Tool's network mode, while the Docker scorer uses +`--network none`. + +```bash +cd ../sandbox/ags +uv run python main.py \ + --task jsonlines \ + --network-mode SANDBOX \ + --create-tool \ + --cmd 'true' +# Copy the printed tool ID into .env as AGS_SCORE_TOOL_ID=... + +cd ../../Clawd-Code +.venv/bin/python teammate-evals/nl2repo-pilot/benchmark.py \ + --task jsonlines \ + --mode solo \ + --execution-backend ags \ + --score-backend ags \ + --ags-env-file ../sandbox/ags/.env +``` + +With AGS scoring enabled, each case uses a second fresh instance. It keeps the +official task image intact, receives only a stripped candidate overlay, and +runs an outbound-network probe before the hidden suite. The run fails closed +if that probe detects Internet access. + +The AGS image convention is +`swebenchdocker.tencentcloudcr.com/swebench/nl2repo:-1.0`; Docker uses the +upstream GHCR image. Verify score parity for a representative task before a +large run whenever either registry is updated. + +Cloud teammates share `/workspace`. Worktree teammates are intentionally +rejected for AGS runs because a single remote workspace cannot provide the +local worktree isolation and integration semantics yet. + +## Usage + +List upstream tasks and mark the five pilot selections: + +```bash +.venv/bin/python teammate-evals/nl2repo-pilot/benchmark.py --list +``` + +Validate task metadata and official GHCR images without calling a model: + +```bash +.venv/bin/python teammate-evals/nl2repo-pilot/benchmark.py \ + --task jsonlines --task tinydb --validate +``` + +Run a small comparison: + +```bash +.venv/bin/python teammate-evals/nl2repo-pilot/benchmark.py \ + --provider anthropic \ + --model glm-5.2 \ + --task jsonlines \ + --mode both +``` + +The primary five-task pilot is `jsonlines`, `tinydb`, `aiofiles`, +`flask-restful`, and `fastapi-users`. Runs default to 300 lead turns, 80 turns +per worker, and a two-hour agent timeout because these are long-horizon tasks. +Each model turn may use up to 16,384 output tokens by default so large file-write +tool arguments are not truncated; override this with `--max-output-tokens`. + +Each run starts from a Git repository containing only `start.md`. The scorer +removes generated tests and packaging files exactly as the upstream harness +does, overlays the implementation onto the official task image, and installs +declared build dependencies while building the score image. It then disables +network access before running the hidden pytest suite. Results also record token +use, agent roles and permissions, task completion, direct peer messages, and +lead stop/resume/reassign/retry interventions. + +Structured model streaming is enabled by default. While the lead runs, +`progress.jsonl` records model, text-stream, and tool events incrementally, so +provider stalls can be distinguished from active repository work. Use +`--no-stream` only when diagnosing a provider without compatible streaming. +Qwen streaming requests explicitly enable the terminal usage chunk. New result +files separate lead and worker token counts and set `usage.complete`; aggregate +token comparisons should include only complete measurements. Historical Qwen +streaming runs cannot be backfilled exactly because the gateway did not return +Lead usage unless it was requested during generation. + +## 32-task rollout pool + +The `qwen32` task set is the exact deterministic subset used by the concurrency +probe: 32 tasks sampled with seed `20260715` after limiting `start.md` to 64 KiB. +It defaults to adaptive mode, 300 lead turns, and eight concurrent rollouts. +Inspect the resolved plan without launching agents or sandboxes: + +```bash +.venv/bin/python teammate-evals/nl2repo-pilot/benchmark.py \ + --task-set qwen32 \ + --plan +``` + +Run the Qwen evaluation in AGS with an independent four-worker reward pool: + +```bash +.venv/bin/python teammate-evals/nl2repo-pilot/benchmark.py \ + --task-set qwen32 \ + --mode adaptive \ + --provider qwen \ + --model ms-mnhdj86z \ + --max-turns 300 \ + --rollout-concurrency 8 \ + --reward-concurrency 4 \ + --execution-backend ags \ + --score-backend ags \ + --ags-env-file ../sandbox/ags/.env +``` + +The rollout executor always keeps up to eight agent cases active. As soon as +one rollout finishes and its workspace has been downloaded, that slot starts +the next queued case. Hidden-test reward evaluation is submitted to a separate +executor and never consumes a rollout slot. With both phases on AGS, the example +can therefore have up to 12 cloud sandboxes active at once: eight rollout +instances plus four isolated reward instances. Set `--score-backend docker` to +keep reward evaluation local while retaining the same scheduling semantics. + +`scheduler.jsonl` records rollout and reward start/completion events. Individual +case results are persisted immediately below `//result.json`, so +completed cases remain recoverable if the aggregate run is interrupted. + +### Live evaluation dashboard + +Every new run writes `run-metadata.json` before the first rollout starts. The +read-only dashboard combines that manifest with `scheduler.jsonl`, incremental +`progress.jsonl` events, and per-case results, so queued tasks are visible before +their workspace exists and corrected reward results take precedence over older +scheduler events. + +```bash +.venv/bin/python teammate-evals/nl2repo-pilot/dashboard.py \ + --run teammate-evals/nl2repo-pilot/runs/ \ + --port 8765 +``` + +Open `http://127.0.0.1:8765/`. The console refreshes every two seconds and offers +task-table, rollout/reward pipeline, and scheduler-timeline views. Selecting a +task opens its recent structured events, agent response, hidden-test output, and +Docker logs. The server only reads benchmark artifacts and has no run-control +or sandbox-delete endpoints. + +To compare modes across compatible batches, add a comparison link to the +selected run's `run-metadata.json`. Results in the selected run take precedence; +the sibling baseline only fills missing task/mode pairs: + +```json +{ + "comparison": { + "modes": ["adaptive", "forced-team"], + "baseline_runs": {"adaptive": "20260715-qwen32-pool8-v2"} + } +} +``` + +The COMPARE view reports paired quality, rollout time, calls, token coverage, +and per-task deltas. Cross-run latency is labeled because concurrency and service +load may differ. Historical zero token counts from streaming responses are +treated as missing measurements. + +### Continuous evaluation queue + +For sustained utilization, register every queue with the shared global pool. +The supervisor is the only supported worker launcher, including when there is +only one run. Its SQLite/WAL queues can still be updated from another process +while the supervisor allocates the global rollout and reward capacities: + +```bash +RUN=teammate-evals/nl2repo-pilot/runs/qwen-continuous + +# One or more --run arguments share these capacities. +.venv/bin/python teammate-evals/nl2repo-pilot/global_pool_supervisor.py \ + --run "$RUN" \ + --provider qwen \ + --model ms-rns547kc \ + --rollout-capacity 8 \ + --reward-capacity 4 \ + --worker-capacity 8 \ + --ags-env-file ../sandbox/ags/.env +``` + +Do not invoke the queue's internal `serve` command directly or start a second +private pool for handoff/rescoring. Prepare all runs first and pass another +`--run ` for each one; the global allocator shares capacity across them. +The supervisor holds the pilot-wide `runs/global-pool.lock`, so a second NL2Repo +supervisor is rejected even if it is given runs from another directory. To add a new +run to a live pool, stop the supervisor and restart it once with the complete +repeated `--run` list; persisted in-flight queue state is recovered. + +Either stage may be disabled without leaving the global-pool path: use +`--rollout-capacity 0 --reward-capacity 64` for reward-only rescoring, or +`--rollout-capacity 32 --reward-capacity 0` for rollout-only generation. Both +capacities cannot be zero. Completion considers only the enabled stage, so a +rollout-only pass may leave `reward_pending` work for a later reward-only pass. + +Direct `evaluation_queue.py ... serve` and `evaluation_queue.py ... scale` are +rejected before queue state is created or changed, with no command-line bypass. +Queue workers must be direct children registered in the supervisor's live global +lock; setting its internal environment marker manually is insufficient. For +incident recovery, restart `global_pool_supervisor.py` with the intended run list +and capacities. + +The legacy top-level pools in `benchmark.py` and `reward_repair.py` are also +disabled. Reward-only repair uses the same supervisor with +`--rollout-capacity 0`; live latency-probe request pools are disabled as well +(`--dry-run` remains available). + +The supervisor stays alive in `idle` state when all registered queues are empty, +so `evaluation_queue.py ... add` can refill it without a restart. It never exits +because of an empty snapshot; stop it explicitly with `SIGINT`/`SIGTERM` during a +maintenance window. This removes the shutdown race that could otherwise strand a +case added immediately after the final empty check. + +Append validated tasks at any time, including while the worker is running: + +```bash +.venv/bin/python teammate-evals/nl2repo-pilot/evaluation_queue.py \ + --run "$RUN" add --task tinydb --task tablib --mode adaptive + +# Add the deterministic 32-task set, or every upstream task. +.venv/bin/python teammate-evals/nl2repo-pilot/evaluation_queue.py \ + --run "$RUN" add --task-set qwen32 --mode adaptive + +# Add only the 72 tasks outside qwen32. +.venv/bin/python teammate-evals/nl2repo-pilot/evaluation_queue.py \ + --run "$RUN" add --task-set remaining-qwen32 --mode forced-team + +.venv/bin/python teammate-evals/nl2repo-pilot/evaluation_queue.py \ + --run "$RUN" status +``` + +Cases are deduplicated by task and mode within a continuous run. A completed or +failed case can be intentionally rerun with `retry`; its previous artifacts are +archived below `_attempts/` before the new rollout starts. Higher-priority cases +can be inserted with `add --priority N`. + +The success path is `queued → rollout → reward_pending → rewarding → done`; terminal +rollout/harness errors and invalid rewards enter `failed`. Retryable rollout +infrastructure failures are archived and automatically requeued up to +`--rollout-attempts` attempts (three by default). Only `reward_outcome=scored` with a +valid numeric score may enter `done`; skipped, pending, or infrastructure rewards never +become synthetic zero-score completions. +After a runner restart, an interrupted rollout returns to the queue, while an +interrupted reward resumes from its persisted rollout artifact. The dashboard +reads `queue.sqlite3` directly, includes newly appended tasks automatically, and +shows a `QUEUE LOW` warning when fewer tasks are waiting than rollout slots. +The continuous runner retries scorer exceptions and explicit +`hidden_tests.error` results three times by default; configure this with +`--reward-attempts` and `--reward-retry-delay`. Ordinary hidden-test failures +are valid rewards and are never retried. + +For a fixed batch started by an older scorer process, watch and repair only its +infrastructure failures without repeating any agent rollout: + +```bash +.venv/bin/python teammate-evals/nl2repo-pilot/reward_repair.py \ + --run teammate-evals/nl2repo-pilot/runs/ \ + --watch --concurrency 4 +``` + +When the batch finishes, the repair process also rebuilds `results.json` and +`REPORT.md` from the corrected per-case results so stale in-memory reward values +do not survive in the aggregate report. + +## Qwen concurrency latency probe + +Before spending sandbox time on 32 complete agents, measure model-service +queueing with a fixed subset of real NL2Repo specifications: + +```bash +.venv/bin/python teammate-evals/nl2repo-pilot/latency_probe.py --dry-run +.venv/bin/python teammate-evals/nl2repo-pilot/latency_probe.py \ + --subset-size 32 \ + --baseline-concurrency 1 \ + --concurrency 32 + +# Find the useful operating point with the same 32 prompts at every level. +.venv/bin/python teammate-evals/nl2repo-pilot/latency_probe.py \ + --subset-size 32 \ + --sweep 1,2,4,8 +``` + +By default the probe deterministically samples 32 tasks whose `start.md` is no +larger than 64 KiB, using seed `20260715`. It sends the same prompts serially +and concurrently, disables Qwen thinking, requests only a short acknowledgement, +streams without retries, and records TTFT, total latency, throughput, token use, +and errors. It does not execute agents or score repositories, so its results +isolate the model endpoint rather than AGS, tool calls, or hidden tests. + +Artifacts are written below `latency-runs//`. `results.json` contains +only task names, prompt sizes, metrics, and sanitized errors; prompt contents and +the configured AuthToken are not persisted. Use `--output` to choose a stable +result directory. + +The upstream repository does not currently include a root license file. Keep it +as an external pinned dependency unless its maintainers publish redistribution +terms. diff --git a/teammate-evals/nl2repo-pilot/benchmark.py b/teammate-evals/nl2repo-pilot/benchmark.py new file mode 100644 index 0000000..9b64a83 --- /dev/null +++ b/teammate-evals/nl2repo-pilot/benchmark.py @@ -0,0 +1,3523 @@ +from __future__ import annotations + +import argparse +import csv +import dataclasses +import errno +import fcntl +import hashlib +import json +import os +import random +import re +import signal +import shutil +import stat +import subprocess +import sys +import tempfile +import threading +import time +import traceback +import uuid +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable, Mapping, Sequence + + +ROOT = Path(__file__).resolve().parent +REPO_ROOT = ROOT.parents[1] +UPSTREAM_URL = "https://github.com/multimodal-art-projection/NL2RepoBench.git" +UPSTREAM_REF = "781a1da1ee41fb8edb0bed22f586d69111610edf" +IMAGE_ROOT = "ghcr.io/multimodal-art-projection/nl2repobench" +AGS_IMAGE_TEMPLATE = "swebenchdocker.tencentcloudcr.com/swebench/nl2repo:{task}-1.0" +AGS_SCORE_SETUP_CONCURRENCY = max( + 1, int(os.environ.get("AGS_SCORE_SETUP_CONCURRENCY", "8")) +) +AGS_SCORE_SETUP_SLOTS = threading.BoundedSemaphore(AGS_SCORE_SETUP_CONCURRENCY) +PILOT_TASKS = ("jsonlines", "tinydb", "aiofiles", "flask-restful", "fastapi-users") +ROLLOUT32_SEED = 20260715 +ROLLOUT32_SIZE = 32 +ROLLOUT32_MAX_PROMPT_BYTES = 64 * 1024 +RESULT_SCHEMA_VERSION = 2 +SCORE_POLICY_VERSION = "nl2repo-score-v2" +PROTOCOL_POLICY_VERSION = "team-protocol-v3" +PROMPT_VERSION = "nl2repo-harness-v3" +GLOBAL_POOL_LOCK_PATH = ROOT / "runs" / "global-pool.lock" +GLOBAL_POOL_STATE_PATH = ROOT / "runs" / "global-pool-state.json" +GLOBAL_POOL_WORKER_ENV = "CLAWD_NL2REPO_GLOBAL_POOL_WORKER" +GLOBAL_POOL_WORKER_MARKER = "global_pool_supervisor.v1" +_MISSING = object() +SCORE_CONTEXT_IGNORED_NAMES = frozenset( + {".git", ".clawd", "__pycache__", ".pytest_cache"} +) +SCORE_CONTEXT_MAX_FILES = max( + 1, int(os.environ.get("NL2REPO_SCORE_CONTEXT_MAX_FILES", "50000")) +) +SCORE_CONTEXT_MAX_FILE_BYTES = max( + 1, + int( + os.environ.get( + "NL2REPO_SCORE_CONTEXT_MAX_FILE_BYTES", str(256 * 1024 * 1024) + ) + ), +) +SCORE_CONTEXT_MAX_TOTAL_BYTES = max( + 1, + int( + os.environ.get( + "NL2REPO_SCORE_CONTEXT_MAX_TOTAL_BYTES", str(2 * 1024 * 1024 * 1024) + ) + ), +) +PACKAGE_FILES = { + "setup.py", + "pyproject.toml", + "setup.cfg", + "requirements.txt", + "requirements-dev.txt", + "requirements-test.txt", + "tox.ini", + "pytest.ini", + "poetry.lock", + "Pipfile", + "Pipfile.lock", + "environment.yml", + "conda-env.yaml", + "manifest.in", + "MANIFEST.in", +} +TASK_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$") +PYTEST_COMMAND_RE = re.compile( + r"^\s*(?:[A-Za-z_]\w*=[^\s]+\s+)*" + r"(?:pytest|python(?:\d+(?:\.\d+)?)?\s+-m\s+pytest|xvfb-run\b.*\bpytest)" + r"(?:\s|$)" +) + + +def global_pool_is_active(lock_path: Path | None = None) -> bool: + """Return whether the process-wide NL2Repo pool lease is currently held. + + ``global-pool.lock`` is deliberately persistent, so file existence alone is + not evidence of an active supervisor. Probe the kernel lock without + deleting or rewriting the diagnostic file. + """ + path = (lock_path or GLOBAL_POOL_LOCK_PATH).expanduser().resolve() + if not path.is_file(): + return False + try: + handle = path.open("r+", encoding="utf-8") + except FileNotFoundError: + return False + try: + try: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError as exc: + if exc.errno in {errno.EACCES, errno.EAGAIN}: + return True + raise + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + return False + finally: + handle.close() + + +def reject_if_global_pool_active( + operation: str, + *, + lock_path: Path | None = None, +) -> None: + """Reject auxiliary capacity while the global evaluator owns the GPUs.""" + if global_pool_is_active(lock_path): + raise SystemExit( + f"{operation} is disabled while the NL2Repo global pool is active; " + "enqueue the work in global_pool_supervisor.py or wait for it to stop." + ) + + +def enforce_child_launch_policy( + argv: Sequence[str], + *, + environ: Mapping[str, str] | None = None, + lock_path: Path | None = None, + parent_pid: int | None = None, +) -> None: + """Allow private agent children only from a registered queue worker.""" + if not argv or argv[0] != "_run-one": + return + environment = os.environ if environ is None else environ + resolved_lock = (lock_path or GLOBAL_POOL_LOCK_PATH).expanduser().resolve() + supervised = environment.get(GLOBAL_POOL_WORKER_ENV) == GLOBAL_POOL_WORKER_MARKER + supervised = supervised and global_pool_is_active(resolved_lock) + metadata: dict[str, Any] = {} + if supervised: + # The flock inode must remain stable, so its diagnostic metadata is + # updated in place. Retry brief partial reads around that update. + for _attempt in range(3): + try: + loaded = json.loads(resolved_lock.read_text(encoding="utf-8")) + if isinstance(loaded, dict): + metadata = loaded + break + except (OSError, json.JSONDecodeError): + time.sleep(0.01) + try: + owner_pid = int(metadata["pid"]) + except (KeyError, TypeError, ValueError): + supervised = False + owner_pid = -1 + actual_parent = os.getppid() if parent_pid is None else parent_pid + if supervised and int(metadata.get("schema_version") or 0) >= 2: + registered_pids = metadata.get("worker_pids") + state_path = resolved_lock.with_name(GLOBAL_POOL_STATE_PATH.name) + try: + state = json.loads(state_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + state = None + if ( + isinstance(state, dict) + and state.get("pid") == metadata.get("pid") + and isinstance(state.get("worker_pids"), list) + ): + registered_pids = state["worker_pids"] + try: + allowed = {int(value) for value in registered_pids} + except (TypeError, ValueError): + allowed = set() + supervised = actual_parent in allowed + elif supervised: + # A supervisor started before schema v2 has no worker registry. Keep + # its in-flight queue compatible without accepting a public marker from + # arbitrary processes: the agent child's parent must itself be a direct + # child of the lock-owning supervisor. + supervised = _process_parent_pid(actual_parent) == owner_pid + if supervised: + return + raise SystemExit( + "direct 'benchmark.py _run-one' is disabled: agent children must be " + "launched by an evaluation_queue worker owned by the live global pool." + ) + + +def _process_parent_pid(pid: int) -> int | None: + """Read a process's parent without relying on Linux-only /proc.""" + try: + completed = subprocess.run( + ["/bin/ps", "-o", "ppid=", "-p", str(pid)], + check=True, + capture_output=True, + text=True, + timeout=2, + ) + value = completed.stdout.strip() + return int(value) if value else None + except (OSError, ValueError, subprocess.SubprocessError): + return None + + +def start_parent_watchdog( + expected_parent_pid: int, + stop_event: threading.Event, + *, + interval_s: float = 1.0, + on_orphan: Callable[[], Any] | None = None, + parent_pid_loader: Callable[[], int] = os.getppid, +) -> threading.Thread: + """Signal an agent child when its queue-worker parent disappears.""" + if interval_s <= 0: + raise ValueError("parent watchdog interval must be positive") + notify = ( + (lambda: os.kill(os.getpid(), signal.SIGTERM)) + if on_orphan is None + else on_orphan + ) + + def monitor() -> None: + while not stop_event.wait(interval_s): + if parent_pid_loader() == expected_parent_pid: + continue + notify() + return + + thread = threading.Thread( + target=monitor, + name="queue-parent-watchdog", + daemon=True, + ) + thread.start() + return thread + + +def enforce_top_level_pool_policy(args: argparse.Namespace) -> None: + """Keep metadata-only CLI operations, but disable the legacy local pools.""" + # Match main's branch order: --list wins before all other actions, while + # --rescore performs work before --plan/--validate are considered. + if args.list: + return + if not args.rescore and (args.plan or args.validate): + return + raise SystemExit( + "direct benchmark.py rollout/reward pools are disabled. Prepare a queue " + "with evaluation_queue.py and launch it through global_pool_supervisor.py." + ) + + +def _read_json(path: Path) -> Any: + return json.loads(path.read_text(encoding="utf-8")) + + +def _write_json(path: Path, value: Any) -> None: + path.write_text(json.dumps(value, indent=2, ensure_ascii=False), encoding="utf-8") + + +def _append_jsonl(path: Path, value: Any) -> None: + with path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(value, ensure_ascii=False, default=str) + "\n") + + +def _hash_file(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _stable_hash(value: Any) -> str: + payload = json.dumps( + value, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + return hashlib.sha256(payload).hexdigest() + + +def _harness_revision() -> tuple[str | None, bool | None]: + """Return the local harness revision without making metadata creation fragile.""" + try: + revision = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=REPO_ROOT, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + dirty = bool( + subprocess.run( + ["git", "status", "--porcelain", "--untracked-files=no"], + cwd=REPO_ROOT, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + ) + return revision or None, dirty + except (OSError, subprocess.SubprocessError): + return None, None + + +def format_ags_image(template: str, task_name: str) -> str: + """Format a registry-safe image reference without changing the task ID.""" + return template.format(task=task_name.casefold()) + + +def _run_checked(command: list[str], *, cwd: Path | None = None) -> subprocess.CompletedProcess[str]: + return subprocess.run( + command, + cwd=cwd, + check=True, + capture_output=True, + text=True, + ) + + +def default_cache_root() -> Path: + configured = os.environ.get("XDG_CACHE_HOME") + base = Path(configured).expanduser() if configured else Path.home() / ".cache" + return base / "clawd-code" / "nl2repo-bench" + + +def resolve_upstream( + upstream_root: Path | None, + *, + cache_root: Path | None = None, + upstream_url: str = UPSTREAM_URL, + upstream_ref: str = UPSTREAM_REF, +) -> Path: + explicit = upstream_root or ( + Path(os.environ["NL2REPO_BENCH_ROOT"]).expanduser() + if os.environ.get("NL2REPO_BENCH_ROOT") + else None + ) + if explicit is not None: + root = explicit.resolve() + _validate_upstream_root(root) + return root + + destination = (cache_root or default_cache_root()) / upstream_ref[:12] + if destination.exists(): + _validate_upstream_root(destination) + head = _run_checked(["git", "rev-parse", "HEAD"], cwd=destination).stdout.strip() + if head != upstream_ref: + raise ValueError( + f"cached NL2Repo checkout is {head}, expected pinned commit {upstream_ref}: " + f"{destination}" + ) + return destination.resolve() + + destination.parent.mkdir(parents=True, exist_ok=True) + temporary = destination.with_name(f".{destination.name}.tmp-{os.getpid()}") + if temporary.exists(): + raise ValueError(f"temporary clone path already exists: {temporary}") + try: + _run_checked( + ["git", "clone", "--filter=blob:none", "--no-checkout", upstream_url, str(temporary)] + ) + _run_checked(["git", "fetch", "--depth", "1", "origin", upstream_ref], cwd=temporary) + _run_checked(["git", "checkout", "--detach", upstream_ref], cwd=temporary) + temporary.rename(destination) + except Exception: + if temporary.exists(): + shutil.rmtree(temporary) + raise + _validate_upstream_root(destination) + return destination.resolve() + + +def _validate_upstream_root(root: Path) -> None: + if not (root / "test_files" / "task_difficulty.csv").is_file(): + raise ValueError(f"not an NL2Repo-Bench checkout: {root}") + + +def _difficulty_map(upstream_root: Path) -> dict[str, str]: + result: dict[str, str] = {} + with (upstream_root / "test_files" / "task_difficulty.csv").open( + encoding="utf-8", newline="" + ) as handle: + for row in csv.DictReader(handle): + name = str(row.get("task-name") or "").strip() + level = str(row.get("Level") or "").strip() + if name: + result[name.casefold()] = level + return result + + +def list_tasks(upstream_root: Path) -> list[dict[str, Any]]: + difficulty = _difficulty_map(upstream_root) + tasks: list[dict[str, Any]] = [] + for task_dir in sorted((upstream_root / "test_files").iterdir()): + if not task_dir.is_dir() or not (task_dir / "start.md").is_file(): + continue + count_path = task_dir / "test_case_count.txt" + tasks.append( + { + "id": task_dir.name, + "difficulty": difficulty.get(task_dir.name.casefold(), ""), + "expected_tests": int(count_path.read_text(encoding="utf-8").strip()), + "prompt_bytes": (task_dir / "start.md").stat().st_size, + } + ) + return tasks + + +def select_task_subset( + task_metadata: list[dict[str, Any]], + *, + count: int = ROLLOUT32_SIZE, + seed: int = ROLLOUT32_SEED, + max_prompt_bytes: int = ROLLOUT32_MAX_PROMPT_BYTES, +) -> list[str]: + """Select the same deterministic, bounded-context subset as the latency probe.""" + eligible = sorted( + ( + task + for task in task_metadata + if int(task.get("prompt_bytes", 0)) <= max_prompt_bytes + ), + key=lambda task: str(task["id"]), + ) + if len(eligible) < count: + raise ValueError( + f"only {len(eligible)} tasks are <= {max_prompt_bytes} bytes; " + f"cannot select {count}" + ) + selected = random.Random(seed).sample(eligible, count) + return sorted(str(task["id"]) for task in selected) + + +def load_task(upstream_root: Path, task_name: str) -> dict[str, Any]: + if not TASK_NAME_RE.fullmatch(task_name): + raise ValueError(f"invalid task name: {task_name!r}") + task_dir = upstream_root / "test_files" / task_name + required = ("start.md", "test_case_count.txt", "test_commands.json", "test_files.json") + missing = [name for name in required if not (task_dir / name).is_file()] + if missing: + raise ValueError(f"invalid NL2Repo task {task_name}: missing {', '.join(missing)}") + commands = _read_json(task_dir / "test_commands.json") + hidden_paths = _read_json(task_dir / "test_files.json") + if not isinstance(commands, list) or not commands or not all( + isinstance(item, str) and item.strip() for item in commands + ): + raise ValueError(f"invalid test commands for {task_name}") + if not isinstance(hidden_paths, list) or not all(isinstance(item, str) for item in hidden_paths): + raise ValueError(f"invalid hidden test paths for {task_name}") + return { + "id": task_name, + "difficulty": _difficulty_map(upstream_root).get(task_name.casefold(), ""), + "task_dir": str(task_dir), + "document": (task_dir / "start.md").read_text(encoding="utf-8"), + "expected_tests": int((task_dir / "test_case_count.txt").read_text(encoding="utf-8").strip()), + "test_commands": commands, + "hidden_paths": hidden_paths, + "image": f"{IMAGE_ROOT}/{task_name.casefold()}:1.0", + } + + +def _team_execution_budget( + *, + teammate_max_turns: int = 160, + max_output_tokens: int = 16_384, + team_timeout_s: float = 7_200, +) -> dict[str, int | float]: + # Two initially parallel workers share one rollout-wide budget. The token cap + # leaves headroom for input context while preventing runaway repair loops; the + # TeamPlan manifest freezes it so later revisions cannot silently raise it. + team_turn_budget = max(2, min(100_000, 2 * int(teammate_max_turns))) + team_token_budget = max( + 1_000_000, + min( + 100_000_000, + team_turn_budget * max(1, int(max_output_tokens)) * 4, + ), + ) + team_timeout_s = max(1, min(86_400, float(team_timeout_s))) + return { + "max_workers": 2, + "timeout_s": team_timeout_s, + "token_budget": team_token_budget, + "turn_budget": team_turn_budget, + } + + +def build_prompt( + mode: str, + *, + teammate_max_turns: int = 160, + max_output_tokens: int = 16_384, + team_timeout_s: float = 7_200, +) -> str: + team_budget = _team_execution_budget( + teammate_max_turns=teammate_max_turns, + max_output_tokens=max_output_tokens, + team_timeout_s=team_timeout_s, + ) + team_turn_budget = int(team_budget["turn_budget"]) + team_token_budget = int(team_budget["token_budget"]) + team_timeout_s = float(team_budget["timeout_s"]) + common = """Build the complete Python repository described in start.md in this workspace. +Begin by reading the whole specification and inspecting the initially empty repository. +The official upstream tests are hidden and will be run only after you finish. You may +create your own focused tests, but do not fetch, install, copy, or inspect the target +project's implementation from GitHub, PyPI, caches, or another machine. Implement it +from the provided specification. Do not stop to ask for confirmation. Continue through +architecture, implementation, integration, and local validation before summarizing. +""" + if mode == "solo": + return common + """ +Execution protocol: work directly as one agent. Do not create a teammate team or call +Team*/Teammate* tools. Plan, implement, test, and review the repository yourself. +""" + if mode == "adaptive": + return common + """ +Execution protocol: act as the lead and decide whether collaboration is worth its cost. +It is valid to remain solo. If you delegate, choose the number of agents, task-specific +roles, models, tool permissions, workspaces, dependencies, and concurrency from the +repository itself. Agents may communicate directly when useful. Observe progress, +intervene when needed, integrate the work, and personally perform final validation. +The team topology must be your runtime decision, not a predefined role pipeline. +""" + if mode == "adaptive-team-v2": + return common + f""" +Adaptive Team v2 protocol: act as the lead and make an explicit collaboration routing +decision after reading the complete specification. Create a Team when the work contains +at least two substantially independent implementation +streams whose parallel progress is likely to exceed coordination cost. Remain solo for +small, tightly coupled work where delegation would only duplicate context. + +It is valid to complete the rollout without creating a Team when the routing criteria +are not met. If collaboration is justified, create a Team with quality_gates=true and +then use only the atomic TeamPlan -> TeamRun protocol; do not assemble a v2 plan with +TeamConfigure, TeammateCreate, TaskCreate, or TeamVerify. Start with exactly two real +implementation workers. Omit model, tools, and workspace settings so the harness uses +the configured endpoint and shared AGS workspace. Give distinct workers substantive, +initially runnable implementation tasks with concrete repo-relative, non-overlapping +owned_files and behavioral acceptance_checks. Keep architecture, integration, and final +end-to-end judgment as the lead. Include persistent project test files in owned_files; +for non-deliverable checks, prefer inline commands or task-private scratch. A worker may +create a previously absent, unreserved tests/test_*.py as a local self-test, but it may +not edit existing/reserved tests; declare every test intended as a deliverable. + +In TeamPlan, define clean-install, import-smoke, and integration validation. Freeze a +shared interface contract when both sides can start from its declared signature; use a +handoff contract only when a consumer truly cannot begin before its provider artifact. +TeamRun executes the workers, runs their acceptance checks itself, and performs final +verification automatically. If TeamPlan returns needs_plan_fix, correct every structured +issue and replace the whole plan rather than appending compensating tasks. If TeamRun +returns repair_required, stop active workers, call TeamReplan to checkpoint and preserve +the best workspace, then submit one complete replacement TeamPlan and run again. Set +replace_completed_work=true only when the replacement intentionally supersedes produced +work. TeamAbort is terminal and is never a restart/replan operation. In TeamPlan.execution, +set max_workers=2, timeout_s={team_timeout_s:g}, token_budget={team_token_budget}, and +turn_budget={team_turn_budget}; these are rollout-wide caps frozen by the first plan. Call +TeamRun without execution override fields so the accepted manifest cannot drift. Validation must exercise documented behavior, +error cases, and cross-module integration; import/hasattr/callable-only smoke checks are not +acceptance. Do not finish until TeamRun reports completed or TeamAbort records an explicit +terminal failure. +""" + if mode == "forced-team": + return common + f""" +Diagnostic execution protocol v2: the harness has already created the active strict +protocol-v2 Team. Do not call TeamCreate, TeamConfigure, TeammateCreate, TaskCreate, or +TeamVerify. After reading all of start.md, submit one atomic TeamPlan in replace mode, +then call TeamRun. Use exactly two real implementation workers initially and omit model, +tools, and workspace settings so both inherit the configured endpoint and shared AGS +workspace. Assign at least two substantive implementation tasks to distinct workers. +Each implementation task needs concrete repo-relative, cross-owner non-overlapping +owned_files and behavioral acceptance_checks; existence-only checks such as test -e, +true, echo, or ls are invalid. +Persistent project test files must be included in the owning task's owned_files. Prefer +inline behavioral commands or isolated task scratch for non-deliverable checks. A worker +may create a previously absent, unreserved tests/test_*.py as a local self-test, but it +may not edit existing/reserved tests; declare every test intended as a deliverable. + +The TeamPlan must include a concise architecture contract plus clean-install, +import-smoke, and integration validation. Use frozen interface contracts for signatures +both workers can implement against immediately. Use handoff only when a consumer truly +cannot begin before a provider artifact; both workers must still have substantive +implementation work ready at the start. TeamRun executes task acceptance and final +verification automatically. If TeamPlan returns needs_plan_fix, correct every structured +issue and replace the complete plan; never append compensating legacy tasks. If TeamRun +returns repair_required, stop active workers, call TeamReplan to checkpoint and preserve +the best workspace, then submit one complete replacement TeamPlan and run it again. Set +replace_completed_work=true only when the replacement intentionally supersedes produced +work. TeamAbort is terminal and is never a restart/replan operation. In TeamPlan.execution, +set max_workers=2, timeout_s={team_timeout_s:g}, token_budget={team_token_budget}, and +turn_budget={team_turn_budget}; these are rollout-wide caps frozen by the first plan. Call +TeamRun without execution override fields so the accepted manifest cannot drift. Acceptance and final validation must exercise +documented behavior, error cases, and cross-module integration; import/hasattr/callable-only +smoke checks are insufficient. Do not finish until TeamRun reports completed or TeamAbort +records an explicit terminal failure. A ceremonial second worker or an unverified worker +completion is invalid. +""" + raise ValueError(f"unknown mode: {mode}") + + +def prepare_workspace(task: dict[str, Any], workspace: Path) -> str: + if workspace.exists(): + raise ValueError(f"workspace already exists: {workspace}") + workspace.mkdir(parents=True) + task_path = workspace / "start.md" + task_path.write_text(str(task["document"]), encoding="utf-8") + commands = [ + ["git", "init", "-q"], + ["git", "config", "user.name", "NL2Repo Benchmark"], + ["git", "config", "user.email", "benchmark@localhost"], + ["git", "add", "start.md"], + ["git", "commit", "-q", "-m", "add benchmark specification"], + ] + for command in commands: + _run_checked(command, cwd=workspace) + return _hash_file(task_path) + + +def _install_remote_workspace(downloaded: Path, workspace: Path) -> None: + """Mirror agent-visible files locally while retaining local orchestration state.""" + for item in workspace.iterdir(): + if item.name == ".clawd": + continue + if item.is_dir() and not item.is_symlink(): + shutil.rmtree(item) + else: + item.unlink() + for item in downloaded.iterdir(): + target = workspace / item.name + if target.name == ".clawd": + continue + shutil.move(str(item), target) + + +def _is_ags_image_preparing_error(error: Exception) -> bool: + message = str(error).casefold() + return "resourceunavailable" in message and "image is still preparing" in message + + +def _is_rollout_infrastructure_error(error: BaseException) -> bool: + if isinstance(error, (ConnectionError, TimeoutError, OSError)): + return True + message = str(error).casefold() + return any( + marker in message + for marker in ( + "connection reset", + "connection refused", + "connection error", + "service unavailable", + "bad gateway", + "gateway timeout", + "rate limit", + "too many requests", + "sandbox unavailable", + "deployment unavailable", + "ags backend", + "image is still preparing", + "authentication", + "api key", + ) + ) + + +def start_ags_backend_with_retry( + factory: Callable[[], Any], + *, + attempts: int | None = None, + delay_s: float | None = None, + on_retry: Callable[[int, Exception], None] | None = None, + sleep_fn: Callable[[float], None] | None = None, +) -> Any: + """Wait for lazily prepared AGS task images without failing the rollout.""" + retry_attempts = attempts or int(os.environ.get("AGS_IMAGE_PREPARE_ATTEMPTS", "20")) + retry_delay = ( + delay_s + if delay_s is not None + else float(os.environ.get("AGS_IMAGE_PREPARE_RETRY_SEC", "30")) + ) + if retry_attempts < 1 or retry_delay < 0: + raise ValueError("AGS image retry attempts must be positive and delay non-negative") + sleeper = sleep_fn or time.sleep + for attempt in range(1, retry_attempts + 1): + try: + return factory().start() + except Exception as exc: + if not _is_ags_image_preparing_error(exc) or attempt == retry_attempts: + raise + if on_retry is not None: + on_retry(attempt, exc) + sleeper(retry_delay) + raise AssertionError("unreachable") + + +def _run_agent_child( + workspace: Path, + prompt_path: Path, + result_path: Path, + provider: str, + model: str, + max_turns: int, + teammate_max_turns: int, + max_output_tokens: int, + stream: bool, + progress_path: Path, + *, + teammate_min_timeout_s: float | None = None, + mode: str = "adaptive", + execution_backend: str = "local", + ags_image: str | None = None, + ags_env_file: Path | None = None, + ags_timeout: str = "3h", + ags_cpu: str = "2", + ags_memory: str = "4Gi", +) -> int: + if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + from src.runner import run_prompt + + events: list[dict[str, Any]] = [] + + def capture(event: Any) -> None: + payload = dataclasses.asdict(event) + events.append(payload) + _append_jsonl(progress_path, payload) + + def capture_text(content: str) -> None: + _append_jsonl( + progress_path, + { + "kind": "text_chunk", + "content": content, + "created_at": datetime.now(timezone.utc).isoformat(), + }, + ) + + backend: Any | None = None + sandbox_id = "" + payload: dict[str, Any] + failure_phase = "harness_setup" + try: + if mode == "forced-team": + from src.teammate.store import TeamStore + + store = TeamStore(workspace) + team = store.load_active_team() + if team is None: + team = store.create_team( + "nl2repo-forced-team", + description=( + "Harness-created team for the forced-team NL2Repo evaluation protocol" + ), + agent_type="adaptive", + ) + quality = dict(team.settings.get("quality_gates") or {}) + quality.update( + { + "strict": True, + "protocol_version": 2, + "configured": bool(quality.get("configured", False)), + "plan_accepted": bool(quality.get("plan_accepted", False)), + "validation": quality.get("validation") or {"status": "pending"}, + } + ) + team.protocol_version = 2 + team.settings["protocol_version"] = 2 + team.settings["quality_gates"] = quality + if not isinstance(team.settings.get("team_plan"), dict): + team.set_lifecycle_state("draft") + store.save_team(team) + _append_jsonl( + progress_path, + { + "kind": "forced_team_precreated", + "team_id": team.team_id, + "protocol_version": 2, + "lifecycle_state": team.lifecycle_state, + "created_at": datetime.now(timezone.utc).isoformat(), + }, + ) + if execution_backend == "ags": + failure_phase = "ags_provisioning" + if not ags_image: + raise ValueError("AGS execution requires an image") + from src.execution.ags import AGSSettings, AGSWorkspaceBackend + + settings = AGSSettings.from_env( + image=ags_image, + env_file=ags_env_file, + timeout=ags_timeout, + cpu=ags_cpu, + memory=ags_memory, + ) + def record_image_retry(attempt: int, error: Exception) -> None: + _append_jsonl( + progress_path, + { + "kind": "sandbox_image_waiting", + "backend": "ags", + "image": ags_image, + "attempt": attempt, + "retry_in_s": float( + os.environ.get("AGS_IMAGE_PREPARE_RETRY_SEC", "30") + ), + "error_type": type(error).__name__, + "created_at": datetime.now(timezone.utc).isoformat(), + }, + ) + + backend = start_ags_backend_with_retry( + lambda: AGSWorkspaceBackend(settings), + on_retry=record_image_retry, + ) + sandbox_id = backend.sandbox_id + _append_jsonl( + progress_path, + { + "kind": "sandbox_started", + "backend": "ags", + "sandbox_id": sandbox_id, + "image": ags_image, + "created_at": datetime.now(timezone.utc).isoformat(), + }, + ) + failure_phase = "ags_upload" + backend.reset_workspace() + backend.upload_tree(workspace, backend.workspace_root) + failure_phase = "agent_execution" + result = run_prompt( + prompt_path.read_text(encoding="utf-8"), + workspace=workspace, + provider_name=provider, + model=model, + max_turns=max_turns, + teammate_max_turns=teammate_max_turns, + teammate_min_timeout_s=teammate_min_timeout_s, + max_output_tokens=max_output_tokens, + stream=stream, + on_event=capture, + on_text_chunk=capture_text, + workspace_backend=backend, + ) + payload = { + "ok": bool( + result.response_text != "[Max tool turns reached]" + and not getattr(result, "failed", False) + and not getattr(result, "cancelled", False) + ), + "response_text": result.response_text, + "failed": bool(getattr(result, "failed", False)), + "failure_reason": getattr(result, "failure_reason", None), + "cancelled": bool(getattr(result, "cancelled", False)), + "rollout_outcome": ( + "completed" + if not getattr(result, "failed", False) + and not getattr(result, "cancelled", False) + else "team_aborted" + if getattr(result, "failure_reason", None) == "team_aborted" + else "budget_exhausted" + if getattr(result, "failure_reason", None) + == "team_budget_exhausted" + else "candidate_failure" + ), + "rollout_infrastructure": False, + "rollout_retryable": False, + "lead_usage": result.usage or {}, + "lead_turns": result.num_turns, + "lead_model_calls": sum(event["kind"] == "model_response" for event in events), + "lead_tool_calls": sum(event["kind"] == "tool_use" for event in events), + "execution_backend": execution_backend, + "sandbox_id": sandbox_id or None, + } + except BaseException as exc: + infrastructure = bool( + failure_phase in {"harness_setup", "ags_provisioning", "ags_upload"} + or _is_rollout_infrastructure_error(exc) + ) + terminal = next( + ( + event + for event in reversed(events) + if event.get("kind") in {"run_completed", "run_failed", "run_cancelled"} + ), + {}, + ) + lead_turns = int(terminal.get("turn") or 0) + if lead_turns == 0: + lead_turns = max( + (int(event.get("turn") or 0) for event in events if event.get("kind") == "model_response"), + default=0, + ) + payload = { + "ok": False, + "error": str(exc), + "traceback": traceback.format_exc(), + "failure_phase": failure_phase, + "rollout_outcome": "infra_error" if infrastructure else "harness_error", + "rollout_infrastructure": True, + "rollout_retryable": infrastructure, + "lead_usage": terminal.get("usage") or {}, + "lead_turns": lead_turns, + "lead_model_calls": sum(event["kind"] == "model_response" for event in events), + "lead_tool_calls": sum(event["kind"] == "tool_use" for event in events), + "execution_backend": execution_backend, + "sandbox_id": sandbox_id or None, + } + finally: + if backend is not None: + try: + with tempfile.TemporaryDirectory( + prefix="clawd-ags-download-", dir=workspace.parent + ) as temporary: + downloaded = Path(temporary) + backend.download_tree(backend.workspace_root, downloaded) + _install_remote_workspace(downloaded, workspace) + _append_jsonl( + progress_path, + { + "kind": "sandbox_workspace_downloaded", + "backend": "ags", + "sandbox_id": sandbox_id, + "created_at": datetime.now(timezone.utc).isoformat(), + }, + ) + except BaseException as exc: + payload["ok"] = False + payload["workspace_download_error"] = str(exc) + payload["failure_phase"] = "ags_download" + payload["rollout_outcome"] = "infra_error" + payload["rollout_infrastructure"] = True + payload["rollout_retryable"] = True + finally: + try: + backend.close() + except BaseException as exc: + payload["sandbox_close_error"] = str(exc) + _append_jsonl( + progress_path, + { + "kind": "sandbox_stopped", + "backend": "ags", + "sandbox_id": sandbox_id, + "created_at": datetime.now(timezone.utc).isoformat(), + }, + ) + _write_json(result_path, payload) + # A model/protocol terminal failure is still a successfully captured rollout: + # preserve its generated workspace so code quality can be scored independently + # with effective quality/protocol credit set to zero. Reserve a non-zero child + # exit for harness/provider/transfer failures that produced no usable response. + captured_rollout = "response_text" in payload and not payload.get( + "workspace_download_error" + ) + return 0 if captured_rollout else 1 + + +def _team_metrics(workspace: Path) -> dict[str, Any]: + active_path = workspace / ".clawd" / "team.json" + active = active_path.exists() + historical = list((workspace / ".clawd" / "teams").glob("*/team.json")) + team_path = active_path if active else ( + max(historical, key=lambda path: path.stat().st_mtime) if historical else None + ) + if team_path is None: + return { + "present": False, + "active": False, + "status": None, + "agents": [], + "tasks": 0, + "completed_tasks": 0, + "accepted_tasks": 0, + "attempted_tasks": 0, + "produced_tasks": 0, + "plan_revision": 0, + "plan_hash": None, + "plan_hash_valid": False, + "execution_manifest_valid": False, + "manifest_valid": False, + "messages": 0, + "peer_messages": 0, + "worker_usage": {}, + "trace_model_calls": 0, + "trace_tool_calls": 0, + "interventions": {}, + "quality_gates": {}, + "protocol_version": None, + "lifecycle_state": None, + } + team = _read_json(team_path) + team_id = str(team.get("team_id") or team_path.parent.name) + team_dir = workspace / ".clawd" / "teams" / team_id + lead_id = str(team.get("lead_agent_id") or "") + agents: list[dict[str, Any]] = [] + for path in sorted((team_dir / "agents").glob("*.json")): + raw = _read_json(path) + agents.append( + { + "id": raw.get("agent_id"), + "name": raw.get("name"), + "role": raw.get("role"), + "status": raw.get("status"), + "model": raw.get("model"), + "instructions": raw.get("instructions") or "", + "tools": raw.get("tools") or [], + "workspace_mode": raw.get("workspace_mode"), + "auto_integrate": bool(raw.get("auto_integrate")), + } + ) + tasks = _read_json(team_dir / "tasks.json") if (team_dir / "tasks.json").exists() else {} + messages = [_read_json(path) for path in sorted((team_dir / "messages").glob("*.json"))] + event_types: list[str] = [] + events: list[dict[str, Any]] = [] + produced_task_ids: set[str] = set() + events_path = team_dir / "events.jsonl" + if events_path.exists(): + for line in events_path.read_text(encoding="utf-8").splitlines(): + try: + event = json.loads(line) + event_type = str(event.get("type") or "") + event_types.append(event_type) + events.append(event) + if event_type == "task.produced": + data = event.get("data") if isinstance(event.get("data"), dict) else {} + if data.get("task_id"): + produced_task_ids.add(str(data["task_id"])) + except (json.JSONDecodeError, AttributeError): + continue + intervention_types = ( + "agent.stop_requested", + "agent.resumed", + "task.reassigned", + "task.retry_requested", + "team.resumed", + ) + settings = team.get("settings") if isinstance(team.get("settings"), dict) else {} + quality_gates = dict(settings.get("quality_gates") or {}) + validation = dict(quality_gates.get("validation") or {}) + plan = settings.get("team_plan") if isinstance(settings.get("team_plan"), dict) else {} + plan_hash = str(plan.get("hash") or "") + try: + plan_revision = int(plan.get("revision") or 0) + except (TypeError, ValueError): + plan_revision = 0 + task_values = [task for task in tasks.values() if isinstance(task, dict)] + canonical_plan = { + key: plan.get(key) + for key in ("mode", "contract", "workers", "tasks", "validation", "execution") + } + plan_hash_valid = bool( + plan_hash + and all(canonical_plan.get(key) is not None for key in canonical_plan) + and _stable_hash(canonical_plan) == plan_hash + ) + execution_keys = { + "max_workers", + "timeout_s", + "token_budget", + "turn_budget", + "max_retries", + "lease_timeout_s", + "verify_timeout_s", + "auto_verify", + } + expected_execution = ( + plan.get("execution") if isinstance(plan.get("execution"), dict) else {} + ) + execution_manifest = ( + settings.get("execution_manifest") + if isinstance(settings.get("execution_manifest"), dict) + else {} + ) + frozen_execution = ( + execution_manifest.get("execution") + if isinstance(execution_manifest.get("execution"), dict) + else {} + ) + effective_execution = ( + execution_manifest.get("effective_execution") + if isinstance(execution_manifest.get("effective_execution"), dict) + else frozen_execution + ) + + # Protocol v2 has one immutable source of truth: execution_manifest. Runtime + # defaults and sanctioned adjustments are persisted inside that manifest rather + # than copied into mutable top-level team settings. Events remain an audit + # fallback for manifests written by the immediately preceding schema version. + current_plan_event_index = -1 + for index, event in enumerate(events): + if event.get("type") != "team.plan_committed": + continue + data = event.get("data") if isinstance(event.get("data"), dict) else {} + if str(data.get("plan_hash") or "") == plan_hash: + current_plan_event_index = index + execution_adjustments: dict[str, list[dict[str, Any]]] = {} + persisted_adjustments = execution_manifest.get("runtime_adjustments") + if isinstance(persisted_adjustments, dict): + for key, adjustment in persisted_adjustments.items(): + if key in execution_keys and isinstance(adjustment, dict): + execution_adjustments.setdefault(str(key), []).append(adjustment) + if current_plan_event_index >= 0: + for event in events[current_plan_event_index + 1 :]: + if event.get("type") != "team.options_adjusted": + continue + data = event.get("data") if isinstance(event.get("data"), dict) else {} + for key, adjustment in data.items(): + if key in execution_keys and isinstance(adjustment, dict): + execution_adjustments.setdefault(str(key), []).append(adjustment) + + def execution_value_matches(left: Any, right: Any) -> bool: + if isinstance(left, bool) or isinstance(right, bool): + return type(left) is type(right) and left == right + if isinstance(left, (int, float)) and isinstance(right, (int, float)): + return float(left) == float(right) + return left == right + + def documented_runtime_adjustment( + key: str, expected: Any, actual: Any + ) -> bool: + # This is the only adjustment currently emitted by TeammateRuntime. Keep + # the check narrow so a forged/generic event cannot excuse plan drift. + if key != "timeout_s": + return False + return any( + adjustment.get("reason") == "runtime minimum" + and execution_value_matches(adjustment.get("requested"), expected) + and execution_value_matches(adjustment.get("effective"), actual) + for adjustment in execution_adjustments.get(key, []) + ) + + execution_manifest_mismatches: list[dict[str, Any]] = [] + if not execution_manifest: + execution_manifest_mismatches.append( + {"field": "manifest", "reason": "missing"} + ) + if execution_manifest and execution_manifest.get("schema_version") != 2: + execution_manifest_mismatches.append( + { + "field": "schema_version", + "reason": "value_mismatch", + "expected": 2, + "actual": execution_manifest.get("schema_version"), + } + ) + manifest_status = execution_manifest.get("status") + if execution_manifest and manifest_status not in {"frozen", "accepted"}: + execution_manifest_mismatches.append( + { + "field": "status", + "reason": "invalid_status", + "actual": manifest_status, + } + ) + if str(team.get("status") or "") == "completed" and manifest_status != "accepted": + execution_manifest_mismatches.append( + { + "field": "status", + "reason": "completed_without_accepted_manifest", + "expected": "accepted", + "actual": manifest_status, + } + ) + if str(execution_manifest.get("plan_hash") or "") != plan_hash: + execution_manifest_mismatches.append( + { + "field": "plan_hash", + "reason": "value_mismatch", + "expected": plan_hash, + "actual": execution_manifest.get("plan_hash"), + } + ) + try: + manifest_revision = int(execution_manifest.get("plan_revision") or 0) + except (TypeError, ValueError): + manifest_revision = 0 + if manifest_revision != plan_revision: + execution_manifest_mismatches.append( + { + "field": "plan_revision", + "reason": "value_mismatch", + "expected": plan_revision, + "actual": execution_manifest.get("plan_revision"), + } + ) + for key, expected in expected_execution.items(): + if key not in frozen_execution: + execution_manifest_mismatches.append( + { + "field": key, + "reason": "missing_frozen_value", + "expected": expected, + } + ) + continue + frozen = frozen_execution[key] + if not execution_value_matches(frozen, expected): + execution_manifest_mismatches.append( + { + "field": key, + "reason": "frozen_value_mismatch", + "expected": expected, + "actual": frozen, + } + ) + continue + if key not in effective_execution: + execution_manifest_mismatches.append( + { + "field": key, + "reason": "missing_effective_value", + "expected": expected, + } + ) + continue + effective = effective_execution[key] + if execution_value_matches(effective, expected): + continue + if documented_runtime_adjustment(key, expected, effective): + continue + execution_manifest_mismatches.append( + { + "field": key, + "reason": "effective_value_mismatch", + "expected": expected, + "actual": effective, + } + ) + for key in sorted(set(frozen_execution) - set(expected_execution)): + execution_manifest_mismatches.append( + { + "field": key, + "reason": "unexpected_frozen_value", + "actual": frozen_execution[key], + } + ) + + def manifest_budget_value( + container: Any, field: str, path: str, *, allow_none: bool = True + ) -> int | None | object: + if not isinstance(container, dict) or field not in container: + execution_manifest_mismatches.append( + {"field": path, "reason": "missing"} + ) + return _MISSING + value = container[field] + if value is None: + if allow_none: + return None + execution_manifest_mismatches.append( + {"field": path, "reason": "invalid_budget_value", "actual": value} + ) + return _MISSING + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + execution_manifest_mismatches.append( + {"field": path, "reason": "invalid_budget_value", "actual": value} + ) + return _MISSING + return value + + global_cap = execution_manifest.get("global_cap") + budget_window = execution_manifest.get("budget_window") + if execution_manifest: + if not isinstance(global_cap, dict): + execution_manifest_mismatches.append( + {"field": "global_cap", "reason": "missing_or_invalid"} + ) + global_cap = {} + if not isinstance(budget_window, dict): + execution_manifest_mismatches.append( + {"field": "budget_window", "reason": "missing_or_invalid"} + ) + budget_window = {} + if budget_window.get("scope") != "plan_revision": + execution_manifest_mismatches.append( + { + "field": "budget_window.scope", + "reason": "value_mismatch", + "expected": "plan_revision", + "actual": budget_window.get("scope"), + } + ) + baseline = budget_window.get("baseline") + incremental = budget_window.get("incremental_limit") + hard_ceiling = budget_window.get("hard_ceiling") + usage = team.get("usage") if isinstance(team.get("usage"), dict) else {} + for metric, plan_key in ( + ("total_tokens", "token_budget"), + ("turns", "turn_budget"), + ): + baseline_value = manifest_budget_value( + baseline, + metric, + f"budget_window.baseline.{metric}", + allow_none=False, + ) + incremental_value = manifest_budget_value( + incremental, metric, f"budget_window.incremental_limit.{metric}" + ) + hard_value = manifest_budget_value( + hard_ceiling, metric, f"budget_window.hard_ceiling.{metric}" + ) + global_value = manifest_budget_value( + global_cap, metric, f"global_cap.{metric}" + ) + planned_value = expected_execution.get(plan_key) + if ( + incremental_value is not _MISSING + and not execution_value_matches(incremental_value, planned_value) + ): + execution_manifest_mismatches.append( + { + "field": f"budget_window.incremental_limit.{metric}", + "reason": "plan_budget_mismatch", + "expected": planned_value, + "actual": incremental_value, + } + ) + if baseline_value is not _MISSING: + current_usage = usage.get(metric, 0) + if ( + isinstance(current_usage, bool) + or not isinstance(current_usage, int) + or current_usage < baseline_value + ): + execution_manifest_mismatches.append( + { + "field": f"budget_window.baseline.{metric}", + "reason": "exceeds_current_usage", + "expected_at_most": current_usage, + "actual": baseline_value, + } + ) + if ( + baseline_value is _MISSING + or incremental_value is _MISSING + or global_value is _MISSING + or hard_value is _MISSING + ): + continue + allocated = ( + None + if incremental_value is None + else baseline_value + incremental_value + ) + expected_hard = ( + global_value + if allocated is None + else allocated + if global_value is None + else min(allocated, global_value) + ) + if hard_value != expected_hard: + execution_manifest_mismatches.append( + { + "field": f"budget_window.hard_ceiling.{metric}", + "reason": "derived_value_mismatch", + "expected": expected_hard, + "actual": hard_value, + } + ) + if plan_revision == 1: + expected_global = allocated + if global_value != expected_global: + execution_manifest_mismatches.append( + { + "field": f"global_cap.{metric}", + "reason": "initial_cap_mismatch", + "expected": expected_global, + "actual": global_value, + } + ) + expected_budget_integrity = _stable_hash( + { + "plan_hash": plan_hash, + "plan_revision": plan_revision, + "execution": frozen_execution, + "global_cap": global_cap, + "budget_window": budget_window, + } + ) + if execution_manifest.get("budget_integrity_hash") != expected_budget_integrity: + execution_manifest_mismatches.append( + { + "field": "budget_integrity_hash", + "reason": "value_mismatch", + "expected": expected_budget_integrity, + "actual": execution_manifest.get("budget_integrity_hash"), + } + ) + execution_manifest_valid = not execution_manifest_mismatches + expected_worker_specs = { + str(worker.get("name") or "").casefold(): worker + for worker in (plan.get("workers") or []) + if isinstance(worker, dict) and worker.get("name") + } + actual_worker_specs = { + str(agent.get("name") or "").casefold(): agent for agent in agents + } + expected_task_specs = { + str(task.get("key") or "").casefold(): task + for task in (plan.get("tasks") or []) + if isinstance(task, dict) and task.get("key") + } + actual_task_specs = { + str(task.get("key") or "").casefold(): task for task in task_values + } + agent_name_by_id = { + str(agent.get("id") or ""): str(agent.get("name") or "").casefold() + for agent in agents + } + task_key_by_id = { + str(task.get("id") or ""): str(task.get("key") or "") + for task in task_values + } + manifest_errors: list[str] = [] + if not plan_hash_valid: + manifest_errors.append("plan_hash_mismatch") + if plan_revision < 1: + manifest_errors.append("missing_plan_revision") + if execution_manifest_mismatches: + manifest_errors.extend( + f"execution.{item['field']}:{item['reason']}" + for item in execution_manifest_mismatches + ) + if len(agents) != len(expected_worker_specs): + manifest_errors.append("worker_count_mismatch") + if len(task_values) != len(expected_task_specs): + manifest_errors.append("task_count_mismatch") + if set(expected_worker_specs) != set(actual_worker_specs): + manifest_errors.append("worker_identity_mismatch") + if set(expected_task_specs) != set(actual_task_specs): + manifest_errors.append("task_identity_mismatch") + manifest_valid = bool( + plan_hash_valid + and plan_revision >= 1 + and execution_manifest_valid + and len(agents) == len(expected_worker_specs) + and len(task_values) == len(expected_task_specs) + and set(expected_worker_specs) == set(actual_worker_specs) + and set(expected_task_specs) == set(actual_task_specs) + ) + if manifest_valid: + for name, expected in expected_worker_specs.items(): + actual = actual_worker_specs[name] + if not ( + str(actual.get("name") or "") == str(expected.get("name") or "") + and str(actual.get("role") or "") == str(expected.get("role") or "") + and str(actual.get("instructions") or "") + == str(expected.get("instructions") or "") + and list(actual.get("tools") or []) == list(expected.get("tools") or []) + and actual.get("model") == expected.get("model") + and str(actual.get("workspace_mode") or "") + == str(expected.get("workspace_mode") or "") + and bool(actual.get("auto_integrate")) + == bool(expected.get("auto_integrate")) + ): + manifest_valid = False + manifest_errors.append(f"worker_spec_mismatch:{name}") + break + if manifest_valid: + expected_contract_hash = _stable_hash(plan.get("contract") or {}) + manifest_valid = bool( + quality_gates.get("contract_hash") == expected_contract_hash + and quality_gates.get("contract") == plan.get("contract") + and str(quality_gates.get("protocol_version") or "") == "2" + ) + if not manifest_valid: + manifest_errors.append("quality_gate_contract_mismatch") + if manifest_valid: + expected_contract_hash = _stable_hash(plan.get("contract") or {}) + for key, expected in expected_task_specs.items(): + actual = actual_task_specs[key] + metadata = actual.get("metadata") if isinstance(actual.get("metadata"), dict) else {} + expected_metadata = ( + expected.get("metadata") + if isinstance(expected.get("metadata"), dict) + else {} + ) + actual_dependencies = [ + task_key_by_id.get(str(task_id), "") + for task_id in (actual.get("blockedBy") or []) + ] + if not ( + metadata.get("plan_hash") == plan_hash + and metadata.get("contract_hash") == expected_contract_hash + and all( + metadata.get(metadata_key) == metadata_value + for metadata_key, metadata_value in expected_metadata.items() + ) + and str(actual.get("key") or "") == str(expected.get("key") or "") + and str(actual.get("subject") or "") == str(expected.get("subject") or "") + and str(actual.get("description") or "") + == str(expected.get("description") or "") + and str(metadata.get("task_type") or "implementation") + == str(expected.get("kind") or "implementation") + and agent_name_by_id.get(str(actual.get("owner") or ""), "") + == str(expected.get("owner") or "").casefold() + and list(actual.get("owned_files") or []) + == list(expected.get("owned_files") or []) + and list(actual.get("acceptance_checks") or []) + == list(expected.get("acceptance_checks") or []) + and actual_dependencies == list(expected.get("blocked_by") or []) + and list(actual.get("provides_interfaces") or []) + == list(expected.get("provides_interfaces") or []) + and list(actual.get("depends_on_interfaces") or []) + == list(expected.get("depends_on_interfaces") or []) + ): + manifest_valid = False + manifest_errors.append(f"task_spec_mismatch:{key}") + break + return { + "present": True, + "active": active, + "team_id": team_id, + "status": team.get("status"), + "protocol_version": team.get("protocol_version", 1), + "lifecycle_state": team.get("lifecycle_state") or team.get("status"), + "agents": agents, + "tasks": len(tasks), + "completed_tasks": sum( + 1 for task in tasks.values() if isinstance(task, dict) and task.get("status") == "completed" + ), + "accepted_tasks": sum( + 1 + for task in tasks.values() + if isinstance(task, dict) + and task.get("status") == "completed" + and task.get("lifecycle_state") == "accepted" + ), + "attempted_tasks": sum( + 1 + for task in task_values + if str(task.get("attempt") or "0").lstrip("+").isdigit() + and int(task.get("attempt") or 0) > 0 + ), + "produced_tasks": sum( + 1 for task in task_values if str(task.get("id") or "") in produced_task_ids + ), + "plan_revision": plan_revision, + "plan_hash": plan_hash or None, + "plan_hash_valid": plan_hash_valid, + "execution_manifest_valid": execution_manifest_valid, + "execution_manifest_mismatches": execution_manifest_mismatches, + "manifest_valid": manifest_valid, + "manifest_errors": list(dict.fromkeys(manifest_errors)), + "messages": len(messages), + "peer_messages": sum( + str(message.get("sender_id") or "") != lead_id + and str(message.get("recipient_id") or "") != lead_id + for message in messages + ), + "worker_usage": team.get("usage") or {}, + "trace_model_calls": event_types.count("model.response"), + "trace_tool_calls": event_types.count("tool.started"), + "interventions": {name: event_types.count(name) for name in intervention_types}, + "quality_gates": { + "strict": bool(quality_gates.get("strict")), + "configured": bool(quality_gates.get("configured")), + "plan_accepted": bool(quality_gates.get("plan_accepted")), + "validation_status": validation.get("status"), + }, + } + + +def _protocol_ok(mode: str, team: dict[str, Any]) -> bool: + if mode == "solo": + return not team["present"] + if mode in {"adaptive", "adaptive-team-v2"} and not team["present"]: + return True + quality = team.get("quality_gates") or {} + try: + protocol_version = int(team.get("protocol_version") or 1) + except (TypeError, ValueError): + protocol_version = 1 + lifecycle_state = team.get("lifecycle_state") or team.get("status") + if mode == "adaptive-team-v2" and protocol_version < 2: + return False + if protocol_version >= 2: + return bool( + team["present"] + and team.get("status") == "completed" + and lifecycle_state == "completed" + and quality.get("strict") + and quality.get("configured") + and quality.get("plan_accepted") + and quality.get("validation_status") == "passed" + and len(team["agents"]) >= 2 + and team["tasks"] >= 2 + and team["completed_tasks"] == team["tasks"] + and team.get("attempted_tasks", 0) == team["tasks"] + and team.get("produced_tasks", 0) == team["tasks"] + and team.get("accepted_tasks", 0) == team["tasks"] + and team.get("plan_revision", 0) >= 1 + and bool(team.get("plan_hash")) + and team.get("plan_hash_valid") is True + and team.get("manifest_valid") is True + ) + strict_ok = not quality.get("strict") or ( + quality.get("configured") + and quality.get("plan_accepted") + and quality.get("validation_status") == "passed" + ) + minimum = 2 if quality.get("strict") else 1 + return bool( + team["present"] + and team["status"] == "completed" + and len(team["agents"]) >= minimum + and team["tasks"] >= minimum + and team["completed_tasks"] == team["tasks"] + and strict_ok + ) + + +def _combined_usage( + lead_usage: dict[str, Any], + worker_usage: dict[str, Any], + *, + used_team: bool, + lead_turns: int, +) -> dict[str, Any]: + """Combine auditable lead/worker token counts and expose coverage explicitly.""" + lead_input = int(lead_usage.get("input_tokens", 0) or 0) + lead_output = int(lead_usage.get("output_tokens", 0) or 0) + worker_input = int(worker_usage.get("input_tokens", 0) or 0) + worker_output = int(worker_usage.get("output_tokens", 0) or 0) + lead_recorded = lead_input > 0 or lead_output > 0 + worker_recorded = not used_team or worker_input > 0 or worker_output > 0 + return { + "input_tokens": lead_input + worker_input, + "output_tokens": lead_output + worker_output, + "total_tokens": lead_input + lead_output + worker_input + worker_output, + "lead_input_tokens": lead_input, + "lead_output_tokens": lead_output, + "worker_input_tokens": worker_input, + "worker_output_tokens": worker_output, + "lead_turns": lead_turns, + "worker_turns": int(worker_usage.get("turns", 0) or 0), + "lead_recorded": lead_recorded, + "worker_recorded": worker_recorded, + "complete": lead_recorded and worker_recorded, + } + + +def classify_failure( + *, + agent_ok: bool, + integrity_ok: bool, + protocol_ok: bool, + team: dict[str, Any], + hidden: dict[str, Any], + hidden_log: str, +) -> str | None: + """Assign a stable, dashboard-friendly failure class without changing reward.""" + pytest_result = hidden.get("pytest") if isinstance(hidden.get("pytest"), dict) else {} + if hidden.get("error") or hidden.get("infrastructure_timed_out"): + return "scorer_infrastructure" + if hidden.get("timed_out") or pytest_result.get("returncode") == 124: + return "reward_timeout" + if not agent_ok: + return "rollout_failure" + if not integrity_ok: + return "spec_integrity" + if not protocol_ok: + quality = team.get("quality_gates") or {} + if quality.get("strict") and quality.get("validation_status") != "passed": + return "team_validation" + return "team_protocol" + if bool(pytest_result.get("all_passed")): + return None + + lowered = hidden_log.casefold() + if any( + marker in lowered + for marker in ( + "modulenotfounderror", + "no module named", + "could not install packages", + "distributionnotfound", + ) + ): + return "dependency_environment" + if int(pytest_result.get("errors", 0) or 0) > 0: + return "collection_error" + if any( + marker in lowered + for marker in ( + "unexpected keyword argument", + "has no attribute", + "missing 1 required positional argument", + "got an unexpected keyword", + ) + ): + if len(team.get("agents") or []) > 1 and int(team.get("peer_messages", 0) or 0) == 0: + return "cross_module_contract" + return "api_contract" + if int(pytest_result.get("failed", 0) or 0) > 0: + return "functional_test_failure" + return "unknown_failure" + + +def _result_metrics_v2( + *, + agent_ok: bool, + agent_timed_out: bool, + integrity_ok: bool, + protocol_ok: bool, + hidden: dict[str, Any], + failure_class: str | None, + rollout_infrastructure: bool = False, + rollout_retryable: bool = False, + rollout_outcome: str | None = None, +) -> dict[str, Any]: + """Build orthogonal delivery, protocol, and reward metrics. + + A protocol failure is not a reward failure. In particular, a complete + workspace can still provide a valid hidden-test measurement even when the + Team lifecycle was not completed. ``quality_score`` remains the legacy + hidden-test value; dashboards should use the explicit v2 eligibility flags. + """ + if rollout_infrastructure: + return { + "result_schema_version": RESULT_SCHEMA_VERSION, + "rollout_outcome": rollout_outcome or "infra_error", + "code_quality_score": None, + "protocol_status": "not_evaluated", + "protocol_credit": None, + "delivery_valid": False, + "effective_quality_score": None, + "reward_outcome": "pending", + "reward_score_valid": False, + "metric_eligibility": { + "code_quality": False, + "protocol_yield": False, + "effective_quality": False, + }, + "failure_domain": "infrastructure", + "is_infrastructure": True, + "retryable": bool(rollout_retryable), + "timeout_scope": "rollout" if agent_timed_out else None, + "failure_class": failure_class or "rollout_infrastructure", + } + + pytest_result = hidden.get("pytest") if isinstance(hidden.get("pytest"), dict) else {} + delivery_valid = bool(agent_ok and integrity_ok) + protocol_status = "passed" if protocol_ok else "failed" + protocol_credit = 1.0 if protocol_ok else 0.0 + reward_timed_out = bool( + hidden.get("timed_out") or pytest_result.get("returncode") == 124 + ) + infrastructure_timed_out = bool(hidden.get("infrastructure_timed_out")) + reward_skipped = bool(hidden.get("skipped")) + reward_error = bool(hidden.get("error")) + + if infrastructure_timed_out: + reward_outcome = "infra_timeout" + elif reward_error: + reward_outcome = "infra_error" + elif reward_timed_out: + reward_outcome = "candidate_timeout" + elif reward_skipped: + reward_outcome = "missing_artifact" + elif not isinstance(pytest_result.get("quality_score"), (int, float)): + reward_outcome = "pending" + else: + reward_outcome = "scored" + reward_score_valid = bool( + reward_outcome == "scored" + and isinstance(pytest_result.get("quality_score"), (int, float)) + ) + code_quality_score = ( + float(pytest_result["quality_score"]) if reward_score_valid else None + ) + is_infrastructure = bool(reward_error or infrastructure_timed_out) + + # A scorer infrastructure failure makes Q, P, and E unobservable for this + # attempt. It must take precedence over candidate/protocol zero credit; + # otherwise an unavailable scorer is silently counted as E=0 and biases the + # aggregate. With a functioning scorer, invalid delivery or protocol still + # deterministically earns zero effective quality. + effective_quality_score: float | None + if is_infrastructure: + effective_quality_score = None + elif not delivery_valid or protocol_credit == 0.0: + effective_quality_score = 0.0 + elif reward_score_valid: + effective_quality_score = round((code_quality_score or 0.0) * protocol_credit, 2) + else: + effective_quality_score = None + effective_eligible = bool( + not is_infrastructure and effective_quality_score is not None + ) + + timeout_scope: str | None = None + if agent_timed_out: + timeout_scope = "rollout" + elif reward_timed_out or infrastructure_timed_out: + timeout_scope = "reward" + retryable = is_infrastructure + if is_infrastructure: + failure_domain: str | None = "infrastructure" + elif not delivery_valid or not agent_ok: + failure_domain = "candidate" + elif not protocol_ok: + failure_domain = "protocol" + elif not bool(pytest_result.get("all_passed")): + failure_domain = "candidate" + else: + failure_domain = None + + return { + "result_schema_version": RESULT_SCHEMA_VERSION, + "rollout_outcome": rollout_outcome or ( + "completed" if agent_ok else "candidate_timeout" if agent_timed_out else "candidate_failure" + ), + "code_quality_score": code_quality_score, + "protocol_status": protocol_status, + "protocol_credit": protocol_credit, + "delivery_valid": delivery_valid, + "effective_quality_score": effective_quality_score, + "reward_outcome": reward_outcome, + "reward_score_valid": reward_score_valid, + "metric_eligibility": { + "code_quality": reward_score_valid, + "protocol_yield": bool(integrity_ok and not is_infrastructure), + "effective_quality": effective_eligible, + }, + "failure_domain": failure_domain, + "is_infrastructure": is_infrastructure, + "retryable": retryable, + "timeout_scope": timeout_scope, + # Preserve this class as the detailed diagnosis while failure_domain is + # the stable top-level attribution used by aggregate metrics. + "failure_class": failure_class, + } + + +def parse_pytest_output(output: str, expected_tests: int, returncode: int) -> dict[str, Any]: + def last_count(label: str) -> int: + matches = re.findall(rf"(?= expected_tests), + } + + +def _safe_relative_path(value: str) -> Path: + path = Path(value) + if path.is_absolute() or ".." in path.parts: + raise ValueError(f"unsafe upstream test path: {value!r}") + return path + + +def _split_score_commands(commands: list[str]) -> tuple[list[str], list[str]]: + """Split ordered setup commands from the pytest invocation.""" + for index, command in enumerate(commands): + if PYTEST_COMMAND_RE.search(command): + return commands[:index], commands[index:] + return [], commands + + +def _score_shell_command(setup_commands: list[str], test_commands: list[str]) -> str: + """Match upstream: run every command and use the final command's exit status.""" + commands = [*setup_commands, *test_commands] + return "; ".join(f"({command})" for command in commands) or "true" + + +def _validate_score_workspace(workspace: Path) -> dict[str, int]: + """Reject unsafe or excessively large trees before they enter a score context.""" + try: + root_stat = workspace.lstat() + except OSError as error: + raise ValueError(f"cannot inspect score workspace {workspace}: {error}") from error + if stat.S_ISLNK(root_stat.st_mode): + raise ValueError(f"score workspace must not be a symbolic link: {workspace}") + if not stat.S_ISDIR(root_stat.st_mode): + raise ValueError(f"score workspace is not a directory: {workspace}") + + file_count = 0 + directory_count = 1 + total_bytes = 0 + largest_file_bytes = 0 + for current, directory_names, file_names in os.walk( + workspace, topdown=True, followlinks=False + ): + current_path = Path(current) + directory_names[:] = sorted( + name + for name in directory_names + if name not in SCORE_CONTEXT_IGNORED_NAMES + ) + for name in directory_names: + path = current_path / name + try: + entry_stat = path.lstat() + except OSError as error: + raise ValueError(f"cannot inspect score workspace entry {path}: {error}") from error + if stat.S_ISLNK(entry_stat.st_mode): + raise ValueError(f"symbolic link is not allowed in score workspace: {path}") + if not stat.S_ISDIR(entry_stat.st_mode): + raise ValueError(f"non-directory workspace entry is not allowed: {path}") + directory_count += 1 + + for name in sorted(file_names): + if name in SCORE_CONTEXT_IGNORED_NAMES: + continue + path = current_path / name + try: + entry_stat = path.lstat() + except OSError as error: + raise ValueError(f"cannot inspect score workspace entry {path}: {error}") from error + if stat.S_ISLNK(entry_stat.st_mode): + raise ValueError(f"symbolic link is not allowed in score workspace: {path}") + if not stat.S_ISREG(entry_stat.st_mode): + raise ValueError(f"non-regular file is not allowed in score workspace: {path}") + + size = int(entry_stat.st_size) + file_count += 1 + total_bytes += size + largest_file_bytes = max(largest_file_bytes, size) + if file_count > SCORE_CONTEXT_MAX_FILES: + raise ValueError( + "score workspace exceeds file-count limit " + f"({file_count} > {SCORE_CONTEXT_MAX_FILES})" + ) + if size > SCORE_CONTEXT_MAX_FILE_BYTES: + raise ValueError( + f"score workspace file exceeds size limit: {path} " + f"({size} > {SCORE_CONTEXT_MAX_FILE_BYTES} bytes)" + ) + if total_bytes > SCORE_CONTEXT_MAX_TOTAL_BYTES: + raise ValueError( + "score workspace exceeds total-size limit " + f"({total_bytes} > {SCORE_CONTEXT_MAX_TOTAL_BYTES} bytes)" + ) + + return { + "file_count": file_count, + "directory_count": directory_count, + "total_bytes": total_bytes, + "largest_file_bytes": largest_file_bytes, + } + + +def stage_score_context(task: dict[str, Any], workspace: Path, destination: Path) -> dict[str, Any]: + source_stats = _validate_score_workspace(workspace) + if destination.exists(): + shutil.rmtree(destination) + shutil.copytree( + workspace, + destination / "workspace", + ignore=shutil.ignore_patterns(*sorted(SCORE_CONTEXT_IGNORED_NAMES)), + # Never dereference a link introduced between validation and copying. + symlinks=True, + ) + staged_workspace = destination / "workspace" + copied_stats = _validate_score_workspace(staged_workspace) + package_files = sorted( + path.relative_to(staged_workspace).as_posix() + for path in staged_workspace.rglob("*") + if path.is_file() and path.name in PACKAGE_FILES + ) + generated_hidden_paths = [ + value + for value in task["hidden_paths"] + if (staged_workspace / _safe_relative_path(value)).exists() + ] + for path in list(staged_workspace.rglob("*")): + if path.is_file() and path.name in PACKAGE_FILES: + path.unlink() + for value in task["hidden_paths"]: + target = staged_workspace / _safe_relative_path(value) + if target.is_dir(): + shutil.rmtree(target) + elif target.exists(): + target.unlink() + setup_commands, test_commands = _split_score_commands(list(task.get("test_commands", []))) + dockerfile_lines = [ + f"FROM --platform=linux/amd64 {task['image']}", + "COPY workspace /workspace", + "WORKDIR /workspace", + "ENV PYTHONPATH=/workspace:$PYTHONPATH", + "CMD [\"tail\", \"-f\", \"/dev/null\"]", + "", + ] + dockerfile = destination / "Dockerfile" + dockerfile.write_text("\n".join(dockerfile_lines), encoding="utf-8") + staged_stats = _validate_score_workspace(staged_workspace) + return { + "package_files_present": package_files, + "generated_hidden_paths": generated_hidden_paths, + "dockerfile": str(dockerfile), + "setup_commands": setup_commands, + "test_commands": test_commands, + "score_context_stats": { + "source": source_stats, + "copied": copied_stats, + "staged": staged_stats, + "limits": { + "max_files": SCORE_CONTEXT_MAX_FILES, + "max_file_bytes": SCORE_CONTEXT_MAX_FILE_BYTES, + "max_total_bytes": SCORE_CONTEXT_MAX_TOTAL_BYTES, + }, + }, + } + + +def run_hidden_tests( + task: dict[str, Any], + workspace: Path, + case_root: Path, + *, + timeout_s: float, + keep_image: bool = False, +) -> dict[str, Any]: + context = case_root / "score-context" + metadata = stage_score_context(task, workspace, context) + tag = f"clawd-nl2repo-{task['id'].lower()}-{uuid.uuid4().hex[:10]}" + build_started = time.monotonic() + build = subprocess.run( + ["docker", "build", "--platform", "linux/amd64", "-t", tag, "."], + cwd=context, + capture_output=True, + text=True, + timeout=timeout_s, + ) + build_elapsed = time.monotonic() - build_started + (case_root / "docker-build.log").write_text( + f"{build.stdout}\n{build.stderr}".strip(), encoding="utf-8" + ) + if build.returncode != 0: + return { + **metadata, + "image": task["image"], + "build_returncode": build.returncode, + "build_elapsed_s": round(build_elapsed, 3), + "error": "Docker score image build failed", + "pytest": parse_pytest_output("", int(task["expected_tests"]), 1), + } + test_started = time.monotonic() + test_command = _score_shell_command( + metadata["setup_commands"], metadata["test_commands"] + ) + try: + completed = subprocess.run( + [ + "docker", + "run", + "--rm", + "--platform", + "linux/amd64", + "--network", + "none", + tag, + "/bin/bash", + "-lc", + test_command, + ], + capture_output=True, + text=True, + timeout=timeout_s, + ) + test_output = f"{completed.stdout}\n{completed.stderr}".strip() + returncode = completed.returncode + timed_out = False + except subprocess.TimeoutExpired as exc: + test_output = f"{exc.stdout or ''}\n{exc.stderr or ''}".strip() + returncode = 124 + timed_out = True + finally: + if not keep_image: + subprocess.run(["docker", "image", "rm", "-f", tag], capture_output=True, text=True) + test_elapsed = time.monotonic() - test_started + (case_root / "hidden-tests.log").write_text(test_output, encoding="utf-8") + result = { + **metadata, + "image": task["image"], + "built_image": tag if keep_image else None, + "build_returncode": build.returncode, + "build_elapsed_s": round(build_elapsed, 3), + "test_elapsed_s": round(test_elapsed, 3), + "timed_out": timed_out, + "pytest": parse_pytest_output(test_output, int(task["expected_tests"]), returncode), + } + shutil.rmtree(context, ignore_errors=True) + return result + + +def run_hidden_tests_ags( + task: dict[str, Any], + workspace: Path, + case_root: Path, + *, + timeout_s: float, + ags_image: str, + ags_env_file: Path | None, + ags_timeout: str, + ags_cpu: str, + ags_memory: str, + ags_score_tool_id: str | None = None, +) -> dict[str, Any]: + """Score in a fresh AGS instance so hidden tests never enter the agent sandbox.""" + from src.execution.ags import AGSSettings, AGSWorkspaceBackend + + context = case_root / "score-context" + metadata = stage_score_context(task, workspace, context) + settings = AGSSettings.from_env( + image=ags_image, + env_file=ags_env_file, + timeout=ags_timeout, + cpu=ags_cpu, + memory=ags_memory, + ) + score_tool_id = ags_score_tool_id or os.environ.get("AGS_SCORE_TOOL_ID", "").strip() + if not score_tool_id: + raise RuntimeError( + "AGS scoring requires a dedicated no-egress tool; configure AGS_SCORE_TOOL_ID " + "or keep --score-backend docker" + ) + settings.tool_id = score_tool_id + settings.runtime_timeout = max(settings.runtime_timeout, timeout_s + 30) + settings.network_mode = "SANDBOX" + started = time.monotonic() + backend: Any | None = None + sandbox_id = "" + test_output = "" + returncode = 1 + timed_out = False + infrastructure_timed_out = False + infrastructure_error: str | None = None + tests_started = False + test_elapsed = 0.0 + cleanup_error: str | None = None + try: + # Creating many sandboxes and uploading all workspaces in one burst can + # saturate the AGS runtime gateway even though the service can execute + # many tests concurrently. Throttle setup only; release the slot before + # the long-running hidden tests so the reward pool can still reach 64. + with AGS_SCORE_SETUP_SLOTS: + backend = start_ags_backend_with_retry(lambda: AGSWorkspaceBackend(settings)) + sandbox_id = backend.sandbox_id + startup_elapsed = time.monotonic() - started + network_probe = backend.exec( + "python3 - <<'PY'\n" + "import urllib.request\n" + "try:\n" + " urllib.request.urlopen('https://example.com', timeout=5)\n" + "except Exception:\n" + " raise SystemExit(0)\n" + "raise SystemExit(86)\n" + "PY", + cwd=backend.workspace_root, + timeout_s=15, + ) + if network_probe.exit_code != 0: + raise RuntimeError( + "AGS score sandbox has outbound network access; use a SandboxTool whose " + "NetworkConfiguration.NetworkMode is SANDBOX" + ) + # Do not reset /workspace: the fresh task image owns the official package + # metadata and hidden tests. Uploading the stripped candidate overlays only + # implementation files, matching Docker COPY semantics. + backend.upload_tree(context / "workspace", backend.workspace_root) + print( + f"[{task['id']}] ags-score.upload.completed · sandbox={sandbox_id}", + flush=True, + ) + test_command = "export PYTHONPATH=/workspace:${PYTHONPATH:-}; " + _score_shell_command( + metadata["setup_commands"], metadata["test_commands"] + ) + test_started = time.monotonic() + print( + f"[{task['id']}] ags-score.tests.started · timeout={int(timeout_s)}s", + flush=True, + ) + tests_started = True + completed = backend.exec( + test_command, + cwd=backend.workspace_root, + timeout_s=max(1, int(timeout_s)), + ) + test_elapsed = time.monotonic() - test_started + print( + f"[{task['id']}] ags-score.tests.completed · " + f"exit={completed.exit_code} elapsed={test_elapsed:.1f}s", + flush=True, + ) + test_output = f"{completed.stdout}\n{completed.stderr}".strip() + returncode = completed.exit_code + except TimeoutError as exc: + startup_elapsed = time.monotonic() - started + test_output = str(exc) + returncode = 124 + if tests_started: + timed_out = True + else: + infrastructure_timed_out = True + infrastructure_error = str(exc) + except Exception as exc: + startup_elapsed = time.monotonic() - started + test_output = f"{exc}\n{traceback.format_exc()}" + returncode = 1 + infrastructure_error = f"{type(exc).__name__}: {exc}" + finally: + try: + if backend is not None: + try: + backend.close() + except Exception as exc: + # Sandbox cleanup is infrastructure housekeeping. It must + # not overwrite a completed hidden-test result and cause + # the whole reward to be retried or marked failed. + cleanup_error = f"{type(exc).__name__}: {exc}" + finally: + shutil.rmtree(context, ignore_errors=True) + (case_root / "hidden-tests.log").write_text(test_output, encoding="utf-8") + return { + **metadata, + "backend": "ags", + "image": ags_image, + "sandbox_id": sandbox_id or None, + "startup_elapsed_s": round(startup_elapsed, 3), + "test_elapsed_s": round(test_elapsed, 3), + "timed_out": timed_out, + "infrastructure_timed_out": infrastructure_timed_out, + "error": infrastructure_error, + "cleanup_error": cleanup_error, + "pytest": parse_pytest_output(test_output, int(task["expected_tests"]), returncode), + } + + +@dataclasses.dataclass +class RolloutArtifact: + task: dict[str, Any] + mode: str + case_root: Path + workspace: Path + start_hash: str + agent: dict[str, Any] + agent_elapsed_s: float + agent_timed_out: bool + agent_returncode: int + + +def _require_agent_result( + result_path: Path, + *, + returncode: int, + timed_out: bool, + stderr: str, +) -> dict[str, Any]: + """Reject harness-level child failures before they can reach reward scoring.""" + if not result_path.is_file(): + reason = "timed out" if timed_out else f"exited with code {returncode}" + stderr_tail = " ".join(str(stderr).split())[-1_200:] + detail = f"; stderr: {stderr_tail}" if stderr_tail else "" + raise RuntimeError( + f"agent subprocess {reason} without producing {result_path.name}{detail}" + ) + try: + agent = _read_json(result_path) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise RuntimeError(f"agent subprocess produced an invalid {result_path.name}: {exc}") from exc + if not isinstance(agent, dict): + raise RuntimeError( + f"agent subprocess produced a non-object {result_path.name}: " + f"{type(agent).__name__}" + ) + return agent + + +def run_rollout( + task: dict[str, Any], + mode: str, + output_root: Path, + *, + provider: str, + model: str, + max_turns: int, + teammate_max_turns: int, + max_output_tokens: int, + agent_timeout_s: float, + stream: bool, + teammate_min_timeout_s: float | None = None, + execution_backend: str = "local", + ags_image: str | None = None, + ags_env_file: Path | None = None, + ags_timeout: str = "3h", + ags_cpu: str = "2", + ags_memory: str = "4Gi", +) -> RolloutArtifact: + """Run only the agent phase and release its rollout slot before scoring.""" + case_root = output_root / task["id"] / mode + case_root.mkdir(parents=True, exist_ok=True) + workspace = case_root / "workspace" + start_hash = prepare_workspace(task, workspace) + prompt_path = case_root / "PROMPT.md" + prompt_path.write_text( + build_prompt( + mode, + teammate_max_turns=teammate_max_turns, + max_output_tokens=max_output_tokens, + team_timeout_s=agent_timeout_s, + ), + encoding="utf-8", + ) + result_path = case_root / "agent-result.json" + progress_path = case_root / "progress.jsonl" + command = [ + sys.executable, + str(Path(__file__).resolve()), + "_run-one", + "--workspace", + str(workspace), + "--prompt-file", + str(prompt_path), + "--result-file", + str(result_path), + "--provider", + provider, + "--model", + model, + "--max-turns", + str(max_turns), + "--teammate-max-turns", + str(teammate_max_turns), + "--teammate-min-timeout", + str(teammate_min_timeout_s or 0), + "--max-output-tokens", + str(max_output_tokens), + "--progress-file", + str(progress_path), + "--mode", + mode, + ] + if stream: + command.append("--stream") + command.extend(["--execution-backend", execution_backend]) + if execution_backend == "ags": + if not ags_image: + raise ValueError("AGS execution requires an image") + command.extend( + [ + "--ags-image", + ags_image, + "--ags-timeout", + ags_timeout, + "--ags-cpu", + ags_cpu, + "--ags-memory", + ags_memory, + ] + ) + if ags_env_file is not None: + command.extend(["--ags-env-file", str(ags_env_file)]) + started = time.monotonic() + process = subprocess.Popen( + command, + cwd=REPO_ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=os.environ.copy(), + ) + try: + stdout, stderr = process.communicate(timeout=agent_timeout_s) + agent_returncode = process.returncode + agent_timed_out = False + except subprocess.TimeoutExpired: + process.terminate() + try: + stdout, stderr = process.communicate(timeout=90) + except subprocess.TimeoutExpired: + process.kill() + stdout, stderr = process.communicate() + agent_returncode = 124 + agent_timed_out = True + agent_elapsed = time.monotonic() - started + (case_root / "stdout.log").write_text(str(stdout), encoding="utf-8") + (case_root / "stderr.log").write_text(str(stderr), encoding="utf-8") + agent = _require_agent_result( + result_path, + returncode=agent_returncode, + timed_out=agent_timed_out, + stderr=stderr, + ) + if agent_timed_out and not agent.get("workspace_download_error"): + agent.update( + { + "ok": False, + "rollout_outcome": "candidate_timeout", + "rollout_infrastructure": False, + "rollout_retryable": False, + "failure_phase": "agent_timeout", + } + ) + return RolloutArtifact( + task=task, + mode=mode, + case_root=case_root, + workspace=workspace, + start_hash=start_hash, + agent=agent, + agent_elapsed_s=agent_elapsed, + agent_timed_out=agent_timed_out, + agent_returncode=agent_returncode, + ) + + +def score_rollout( + rollout: RolloutArtifact, + *, + provider: str, + model: str, + score_timeout_s: float, + keep_image: bool, + execution_backend: str = "local", + score_backend: str = "docker", + ags_image: str | None = None, + ags_env_file: Path | None = None, + ags_timeout: str = "3h", + ags_cpu: str = "2", + ags_memory: str = "4Gi", + ags_score_tool_id: str | None = None, +) -> dict[str, Any]: + """Score a completed rollout in the independent reward pool.""" + task = rollout.task + mode = rollout.mode + case_root = rollout.case_root + workspace = rollout.workspace + agent = rollout.agent + team = _team_metrics(workspace) + protocol_ok = _protocol_ok(mode, team) + integrity_ok = ( + (workspace / "start.md").is_file() + and _hash_file(workspace / "start.md") == rollout.start_hash + ) + rollout_infrastructure = bool( + agent.get("rollout_infrastructure") or agent.get("workspace_download_error") + ) + rollout_gate_errors: list[str] = [] + if not integrity_ok: + rollout_gate_errors.append("start.md integrity check failed") + + if rollout_infrastructure: + reason = str( + agent.get("workspace_download_error") + or agent.get("error") + or "rollout infrastructure failure" + ) + (case_root / "hidden-tests.log").write_text( + "Reward pending: rollout infrastructure failure: " + reason + "\n", + encoding="utf-8", + ) + hidden = { + "skipped": True, + "skip_reason": "rollout infrastructure failure: " + reason, + "pytest": parse_pytest_output("", int(task["expected_tests"]), 1), + } + elif rollout_gate_errors: + reason = "; ".join(rollout_gate_errors) + (case_root / "hidden-tests.log").write_text( + "Reward skipped: " + reason + "\n", encoding="utf-8" + ) + hidden = { + "skipped": True, + "skip_reason": reason, + "pytest": parse_pytest_output("", int(task["expected_tests"]), 1), + } + elif score_backend == "ags": + if not ags_image: + raise ValueError("AGS scoring requires an image") + hidden = run_hidden_tests_ags( + task, + workspace, + case_root, + timeout_s=score_timeout_s, + ags_image=ags_image, + ags_env_file=ags_env_file, + ags_timeout=ags_timeout, + ags_cpu=ags_cpu, + ags_memory=ags_memory, + ags_score_tool_id=ags_score_tool_id, + ) + else: + hidden = run_hidden_tests( + task, + workspace, + case_root, + timeout_s=score_timeout_s, + keep_image=keep_image, + ) + lead_usage = agent.get("lead_usage") or {} + worker_usage = team.get("worker_usage") or {} + usage = _combined_usage( + lead_usage, + worker_usage, + used_team=bool(team["present"]), + lead_turns=int(agent.get("lead_turns", 0) or 0), + ) + pytest_result = hidden["pytest"] + try: + hidden_log = (case_root / "hidden-tests.log").read_text( + encoding="utf-8", errors="replace" + ) + except OSError: + hidden_log = "" + failure_class = classify_failure( + agent_ok=bool(agent.get("ok")), + integrity_ok=integrity_ok, + protocol_ok=protocol_ok, + team=team, + hidden=hidden, + hidden_log=hidden_log, + ) + if rollout_infrastructure: + failure_class = "rollout_infrastructure" + metrics_v2 = _result_metrics_v2( + agent_ok=bool(agent.get("ok")), + agent_timed_out=rollout.agent_timed_out, + integrity_ok=integrity_ok, + protocol_ok=protocol_ok, + hidden=hidden, + failure_class=failure_class, + rollout_infrastructure=rollout_infrastructure, + rollout_retryable=bool(agent.get("rollout_retryable")), + rollout_outcome=str(agent.get("rollout_outcome") or "") or None, + ) + result = { + "task": task["id"], + "difficulty": task["difficulty"], + "mode": mode, + "prompt_version": PROMPT_VERSION, + "protocol_policy_version": PROTOCOL_POLICY_VERSION, + "score_policy_version": SCORE_POLICY_VERSION, + "provider": provider, + "model": model, + "execution_backend": execution_backend, + "score_backend": score_backend, + "agent_elapsed_s": round(rollout.agent_elapsed_s, 3), + "agent_timed_out": rollout.agent_timed_out, + "agent_returncode": rollout.agent_returncode, + "agent_ok": bool(agent.get("ok")), + "agent_error": agent.get("error") or agent.get("failure_reason"), + "integrity_ok": integrity_ok, + "protocol_ok": protocol_ok, + "used_team": team["present"], + "quality_score": pytest_result["quality_score"] if integrity_ok else 0.0, + "success": bool(agent.get("ok") and integrity_ok and protocol_ok and pytest_result["all_passed"]), + **metrics_v2, + "usage": usage, + "calls": { + "model": team["trace_model_calls"] if team["present"] else agent.get("lead_model_calls", 0), + "tools": team["trace_tool_calls"] if team["present"] else agent.get("lead_tool_calls", 0), + }, + "team": team, + "hidden_tests": hidden, + "reward_skipped": bool(hidden.get("skipped")), + "workspace": str(workspace), + } + _write_json(case_root / "result.json", result) + return result + + +def rescore_existing_case( + task: dict[str, Any], + mode: str, + output_root: Path, + *, + score_backend: str, + score_timeout_s: float, + keep_image: bool, + ags_image: str | None = None, + ags_env_file: Path | None = None, + ags_timeout: str = "3h", + ags_cpu: str = "2", + ags_memory: str = "4Gi", + ags_score_tool_id: str | None = None, +) -> dict[str, Any]: + """Re-run only hidden tests for a persisted rollout workspace.""" + case_root = output_root / task["id"] / mode + workspace = case_root / "workspace" + result_path = case_root / "result.json" + if not workspace.is_dir() or not result_path.is_file(): + raise FileNotFoundError(f"completed case not found: {case_root}") + result = _read_json(result_path) + previous_reward = { + "recorded_at": datetime.now(timezone.utc).isoformat(), + "quality_score": result.get("quality_score"), + "success": result.get("success"), + "hidden_tests": result.get("hidden_tests"), + } + if score_backend == "ags": + if not ags_image: + raise ValueError("AGS scoring requires an image") + hidden = run_hidden_tests_ags( + task, + workspace, + case_root, + timeout_s=score_timeout_s, + ags_image=ags_image, + ags_env_file=ags_env_file, + ags_timeout=ags_timeout, + ags_cpu=ags_cpu, + ags_memory=ags_memory, + ags_score_tool_id=ags_score_tool_id, + ) + else: + hidden = run_hidden_tests( + task, + workspace, + case_root, + timeout_s=score_timeout_s, + keep_image=keep_image, + ) + pytest_result = hidden["pytest"] + result.setdefault("reward_history", []).append(previous_reward) + result["score_backend"] = score_backend + result["hidden_tests"] = hidden + result["quality_score"] = ( + pytest_result["quality_score"] if result.get("integrity_ok") else 0.0 + ) + team = _team_metrics(workspace) + protocol_ok = _protocol_ok(mode, team) + result["team"] = team + result["used_team"] = bool(team["present"]) + result["protocol_ok"] = protocol_ok + result["success"] = bool( + result.get("agent_ok") + and result.get("integrity_ok") + and protocol_ok + and pytest_result["all_passed"] + ) + try: + hidden_log = (case_root / "hidden-tests.log").read_text( + encoding="utf-8", errors="replace" + ) + except OSError: + hidden_log = "" + result["failure_class"] = classify_failure( + agent_ok=bool(result.get("agent_ok")), + integrity_ok=bool(result.get("integrity_ok")), + protocol_ok=protocol_ok, + team=team, + hidden=hidden, + hidden_log=hidden_log, + ) + result.update( + _result_metrics_v2( + agent_ok=bool(result.get("agent_ok")), + agent_timed_out=bool(result.get("agent_timed_out")), + integrity_ok=bool(result.get("integrity_ok")), + protocol_ok=protocol_ok, + hidden=hidden, + failure_class=result["failure_class"], + ) + ) + result["reward_skipped"] = False + result["rescored_at"] = datetime.now(timezone.utc).isoformat() + _write_json(result_path, result) + return result + + +def run_case( + task: dict[str, Any], + mode: str, + output_root: Path, + *, + provider: str, + model: str, + max_turns: int, + teammate_max_turns: int, + max_output_tokens: int, + agent_timeout_s: float, + score_timeout_s: float, + keep_image: bool, + stream: bool, + execution_backend: str = "local", + score_backend: str = "docker", + ags_image: str | None = None, + ags_env_file: Path | None = None, + ags_timeout: str = "3h", + ags_cpu: str = "2", + ags_memory: str = "4Gi", + ags_score_tool_id: str | None = None, +) -> dict[str, Any]: + """Run and score one case sequentially for API compatibility.""" + rollout = run_rollout( + task, + mode, + output_root, + provider=provider, + model=model, + max_turns=max_turns, + teammate_max_turns=teammate_max_turns, + max_output_tokens=max_output_tokens, + agent_timeout_s=agent_timeout_s, + stream=stream, + execution_backend=execution_backend, + ags_image=ags_image, + ags_env_file=ags_env_file, + ags_timeout=ags_timeout, + ags_cpu=ags_cpu, + ags_memory=ags_memory, + ) + return score_rollout( + rollout, + provider=provider, + model=model, + score_timeout_s=score_timeout_s, + keep_image=keep_image, + execution_backend=execution_backend, + score_backend=score_backend, + ags_image=ags_image, + ags_env_file=ags_env_file, + ags_timeout=ags_timeout, + ags_cpu=ags_cpu, + ags_memory=ags_memory, + ags_score_tool_id=ags_score_tool_id, + ) + + +def _failed_case_result( + task: dict[str, Any], + mode: str, + phase: str, + error: Exception, + *, + output_root: Path, + provider: str, + model: str, + execution_backend: str, + score_backend: str, +) -> dict[str, Any]: + """Persist a scheduler-level failure without aborting the remaining cases.""" + case_root = output_root / task["id"] / mode + case_root.mkdir(parents=True, exist_ok=True) + message = f"{type(error).__name__}: {error}" + (case_root / f"{phase}-error.log").write_text(message + "\n", encoding="utf-8") + pytest_result = parse_pytest_output("", int(task["expected_tests"]), 1) + result = { + "task": task["id"], + "difficulty": task["difficulty"], + "mode": mode, + "provider": provider, + "model": model, + "execution_backend": execution_backend, + "score_backend": score_backend, + "agent_elapsed_s": 0.0, + "agent_timed_out": False, + "agent_returncode": 1, + "agent_ok": False, + "agent_error": f"{phase} failed: {message}", + "integrity_ok": False, + "protocol_ok": False, + "used_team": False, + "quality_score": 0.0, + "success": False, + "usage": { + "input_tokens": 0, + "output_tokens": 0, + "total_tokens": 0, + "lead_turns": 0, + "worker_turns": 0, + }, + "calls": {"model": 0, "tools": 0}, + "team": { + "present": False, + "agents": [], + "peer_messages": 0, + "worker_usage": {}, + }, + "hidden_tests": ( + {"error": message, "pytest": pytest_result} + if phase in {"reward", "score", "scorer"} + else { + "skipped": True, + "skip_reason": f"{phase} failed before reward: {message}", + "pytest": pytest_result, + } + ), + "workspace": str(case_root / "workspace"), + "failure_phase": phase, + } + reward_phase = phase in {"reward", "score", "scorer"} + timed_out = isinstance(error, (TimeoutError, subprocess.TimeoutExpired)) + result.update( + { + "result_schema_version": RESULT_SCHEMA_VERSION, + "code_quality_score": None, + "protocol_status": "not_evaluated", + "protocol_credit": 0.0, + "delivery_valid": False, + "effective_quality_score": 0.0, + "reward_outcome": ( + "infra_timeout" if reward_phase and timed_out + else "infra_error" if reward_phase + else "missing_artifact" + ), + "reward_score_valid": False, + "metric_eligibility": { + "code_quality": False, + "protocol_yield": False, + "effective_quality": not reward_phase, + }, + "failure_domain": "infrastructure" if reward_phase else "candidate", + "is_infrastructure": reward_phase, + "retryable": reward_phase, + "timeout_scope": ( + "reward" if reward_phase and timed_out + else "rollout" if timed_out + else None + ), + "failure_class": ( + "reward_timeout" if reward_phase and timed_out + else "scorer_infrastructure" if reward_phase + else "rollout_failure" + ), + "reward_skipped": True, + } + ) + _write_json(case_root / "result.json", result) + return result + + +def run_evaluation_pool( + cases: list[tuple[dict[str, Any], str]], + rollout_fn: Callable[[dict[str, Any], str], RolloutArtifact], + reward_fn: Callable[[RolloutArtifact], dict[str, Any]], + *, + rollout_concurrency: int, + reward_concurrency: int, + failure_fn: Callable[ + [dict[str, Any], str, str, Exception], dict[str, Any] + ] | None = None, + on_event: Callable[[dict[str, Any]], None] | None = None, +) -> list[dict[str, Any]]: + """Continuously refill rollout slots while scoring in a separate pool.""" + if rollout_concurrency < 1 or reward_concurrency < 1: + raise ValueError("rollout and reward concurrency must be positive") + started = time.monotonic() + event_lock = threading.Lock() + + def emit(event: str, task: dict[str, Any], mode: str, **extra: Any) -> None: + if on_event is None: + return + value = { + "event": event, + "task": task["id"], + "mode": mode, + "elapsed_s": round(time.monotonic() - started, 3), + **extra, + } + with event_lock: + on_event(value) + + def perform_rollout( + index: int, task: dict[str, Any], mode: str + ) -> tuple[int, dict[str, Any], str, RolloutArtifact | None, Exception | None]: + emit("rollout.started", task, mode) + try: + artifact = rollout_fn(task, mode) + except Exception as exc: + emit("rollout.failed", task, mode, error_type=type(exc).__name__) + return index, task, mode, None, exc + emit("rollout.completed", task, mode) + return index, task, mode, artifact, None + + def perform_reward( + index: int, task: dict[str, Any], mode: str, artifact: RolloutArtifact + ) -> tuple[int, dict[str, Any], str, dict[str, Any] | None, Exception | None]: + emit("reward.started", task, mode) + try: + result = reward_fn(artifact) + except Exception as exc: + emit("reward.failed", task, mode, error_type=type(exc).__name__) + return index, task, mode, None, exc + emit( + "reward.completed", + task, + mode, + quality_score=result.get("quality_score"), + success=bool(result.get("success")), + ) + return index, task, mode, result, None + + indexed_results: list[tuple[int, dict[str, Any]]] = [] + reward_futures: dict[Any, tuple[int, dict[str, Any], str]] = {} + with ( + ThreadPoolExecutor( + max_workers=rollout_concurrency, thread_name_prefix="nl2repo-rollout" + ) as rollout_pool, + ThreadPoolExecutor( + max_workers=reward_concurrency, thread_name_prefix="nl2repo-reward" + ) as reward_pool, + ): + rollout_futures = [ + rollout_pool.submit(perform_rollout, index, task, mode) + for index, (task, mode) in enumerate(cases) + ] + for future in as_completed(rollout_futures): + index, task, mode, artifact, error = future.result() + if error is not None: + if failure_fn is None: + raise error + indexed_results.append((index, failure_fn(task, mode, "rollout", error))) + continue + assert artifact is not None + reward_future = reward_pool.submit( + perform_reward, index, task, mode, artifact + ) + reward_futures[reward_future] = (index, task, mode) + + for future in as_completed(reward_futures): + index, task, mode, result, error = future.result() + if error is not None: + if failure_fn is None: + raise error + result = failure_fn(task, mode, "reward", error) + assert result is not None + indexed_results.append((index, result)) + + return [result for _, result in sorted(indexed_results, key=lambda item: item[0])] + + +def render_report(results: list[dict[str, Any]], run_id: str, upstream_ref: str) -> str: + def code_quality(result: dict[str, Any]) -> float | None: + value = result.get("code_quality_score", result.get("quality_score")) + hidden = result.get("hidden_tests") or {} + return ( + float(value) + if isinstance(value, (int, float)) and not hidden.get("error") + else None + ) + + def effective_quality(result: dict[str, Any]) -> float | None: + explicit = result.get("effective_quality_score") + if isinstance(explicit, (int, float)): + return float(explicit) + value = code_quality(result) + if value is None: + return None + return value if result.get("protocol_ok", True) else 0.0 + + def protocol_status(result: dict[str, Any]) -> str: + return str( + result.get("protocol_status") + or ("passed" if result.get("protocol_ok", True) else "failed") + ) + + lines = [ + "# NL2Repo Pilot Benchmark", + "", + f"Run: `{run_id}`", + f"Upstream: `{upstream_ref}`", + "", + "| Task | Difficulty | Mode | Code Q | Effective Q | Passed | Seconds | Tokens | Agents | Peer messages | Protocol |", + "|---|---|---|---:|---:|---:|---:|---:|---:|---:|:---:|", + ] + for result in results: + tests = result["hidden_tests"]["pytest"] + code_score = code_quality(result) + effective_score = effective_quality(result) + lines.append( + "| " + + " | ".join( + [ + result["task"], + result["difficulty"] or "-", + result["mode"], + str( + code_score + if code_score is not None + else "-" + ), + str(effective_score if effective_score is not None else "-"), + f"{tests['passed']}/{tests['expected']}", + str(result["agent_elapsed_s"]), + str(result["usage"]["total_tokens"]), + str(len(result["team"]["agents"])), + str(result["team"]["peer_messages"]), + protocol_status(result), + ] + ) + + " |" + ) + success = sum(bool(result["success"]) for result in results) + code_scores = [ + score + for result in results + if (score := code_quality(result)) is not None + and result.get("reward_score_valid", True) + ] + protocol_results = [ + result + for result in results + if (result.get("metric_eligibility") or {}).get( + "protocol_yield", "protocol_ok" in result + ) + ] + effective_scores = [ + score + for result in results + if (score := effective_quality(result)) is not None + and (result.get("metric_eligibility") or {}).get("effective_quality", True) + ] + lines.extend( + [ + "", + f"Strict successful runs: **{success}/{len(results)}**", + f"Code quality: **{sum(code_scores) / len(code_scores):.2f}** " + f"({len(code_scores)}/{len(results)} reward coverage)" + if code_scores + else "Code quality: **not measured**", + "Protocol yield: **" + f"{sum(protocol_status(result) == 'passed' for result in protocol_results) / len(protocol_results):.1%}**" + if protocol_results + else "Protocol yield: **not measured**", + f"Effective quality: **{sum(effective_scores) / len(effective_scores):.2f}**" + if effective_scores + else "Effective quality: **not measured**", + "", + "Code quality is the percentage of hidden upstream pytest cases passed. Effective", + "quality additionally applies delivery and protocol credit. Strict success", + "also requires an intact specification, a valid execution protocol, and a completed", + "agent run. The upstream data is referenced externally and is not vendored here.", + ] + ) + return "\n".join(lines) + "\n" + + +def validate_tasks(tasks: list[dict[str, Any]]) -> list[str]: + errors: list[str] = [] + try: + _run_checked(["docker", "info"]) + except Exception as exc: + errors.append(f"Docker is unavailable: {exc}") + return errors + for task in tasks: + if int(task["expected_tests"]) < 1: + errors.append(f"{task['id']}: expected test count must be positive") + completed = subprocess.run( + ["docker", "manifest", "inspect", task["image"]], + capture_output=True, + text=True, + ) + if completed.returncode != 0: + errors.append(f"{task['id']}: test image unavailable: {task['image']}") + return errors + + +def _child_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("_command") + parser.add_argument("--workspace", type=Path, required=True) + parser.add_argument("--prompt-file", type=Path, required=True) + parser.add_argument("--result-file", type=Path, required=True) + parser.add_argument("--provider", required=True) + parser.add_argument("--model", required=True) + parser.add_argument("--max-turns", type=int, required=True) + parser.add_argument("--teammate-max-turns", type=int, required=True) + parser.add_argument("--teammate-min-timeout", type=float, default=0) + parser.add_argument("--max-output-tokens", type=int, required=True) + parser.add_argument("--progress-file", type=Path, required=True) + parser.add_argument( + "--mode", + choices=("solo", "adaptive", "adaptive-team-v2", "forced-team"), + default="adaptive", + ) + parser.add_argument("--stream", action="store_true") + parser.add_argument("--execution-backend", choices=("local", "ags"), default="local") + parser.add_argument("--ags-image") + parser.add_argument("--ags-env-file", type=Path) + parser.add_argument("--ags-timeout", default="3h") + parser.add_argument("--ags-cpu", default="2") + parser.add_argument("--ags-memory", default="4Gi") + return parser + + +def main() -> int: + enforce_child_launch_policy(sys.argv[1:]) + if len(sys.argv) > 1 and sys.argv[1] == "_run-one": + args = _child_parser().parse_args() + def terminate_child(signum: int, frame: Any) -> None: + raise InterruptedError(f"agent child received signal {signum}") + + signal.signal(signal.SIGTERM, terminate_child) + signal.signal(signal.SIGINT, terminate_child) + parent_stop = threading.Event() + expected_parent = os.getppid() + start_parent_watchdog(expected_parent, parent_stop) + try: + return _run_agent_child( + args.workspace.resolve(), + args.prompt_file.resolve(), + args.result_file.resolve(), + args.provider, + args.model, + args.max_turns, + args.teammate_max_turns, + args.max_output_tokens, + args.stream, + args.progress_file.resolve(), + teammate_min_timeout_s=args.teammate_min_timeout or None, + mode=args.mode, + execution_backend=args.execution_backend, + ags_image=args.ags_image, + ags_env_file=args.ags_env_file.resolve() if args.ags_env_file else None, + ags_timeout=args.ags_timeout, + ags_cpu=args.ags_cpu, + ags_memory=args.ags_memory, + ) + finally: + parent_stop.set() + + parser = argparse.ArgumentParser(description="Run Clawd against pinned NL2Repo-Bench tasks.") + parser.add_argument("--list", action="store_true", help="List all upstream tasks") + parser.add_argument("--validate", action="store_true", help="Validate task metadata and images") + parser.add_argument("--plan", action="store_true", help="Print the resolved cases without running") + parser.add_argument( + "--rescore", + action="store_true", + help="Re-run only reward evaluation for completed --task cases in --output", + ) + parser.add_argument("--task", action="append", help="Task ID; repeat to override --task-set") + parser.add_argument( + "--task-set", + choices=("pilot", "qwen32"), + default="pilot", + help="Built-in task selection; qwen32 is the fixed latency-probe subset", + ) + parser.add_argument( + "--mode", + choices=("solo", "adaptive", "adaptive-team-v2", "forced-team", "both", "all"), + help="Defaults to adaptive for qwen32 and both for the pilot set", + ) + parser.add_argument("--provider", default="anthropic") + parser.add_argument("--model", default="glm-5.2") + parser.add_argument("--max-turns", type=int, default=300) + parser.add_argument("--teammate-max-turns", type=int, default=160) + parser.add_argument( + "--teammate-min-timeout", + type=float, + default=900.0, + help="Minimum effective timeout for each TeamRun call", + ) + parser.add_argument("--max-output-tokens", type=int, default=16384) + parser.add_argument("--agent-timeout", type=float, default=7200.0) + parser.add_argument("--score-timeout", type=float, default=1200.0) + parser.add_argument( + "--rollout-concurrency", + type=int, + help="Agent rollout slots; defaults to 8 for qwen32 and 1 otherwise", + ) + parser.add_argument( + "--reward-concurrency", + type=int, + default=4, + help="Independent hidden-test workers; these never occupy rollout slots", + ) + parser.add_argument("--upstream-root", type=Path) + parser.add_argument("--cache-root", type=Path) + parser.add_argument("--output", type=Path) + parser.add_argument("--keep-image", action="store_true") + parser.add_argument( + "--execution-backend", + choices=("local", "ags"), + default="local", + help="Where Bash and file tools execute", + ) + parser.add_argument( + "--score-backend", + choices=("docker", "ags"), + default="docker", + help="Where the official hidden suite executes", + ) + parser.add_argument("--ags-env-file", type=Path) + parser.add_argument("--ags-timeout", default="3h", help="AGS instance TTL") + parser.add_argument("--ags-cpu", default="2") + parser.add_argument("--ags-memory", default="4Gi") + parser.add_argument( + "--ags-score-tool-id", + help="Dedicated AGS SandboxTool configured with NetworkMode=SANDBOX", + ) + parser.add_argument( + "--ags-image-template", + default=AGS_IMAGE_TEMPLATE, + help="Task image template; {task} is replaced with the NL2Repo task ID", + ) + parser.add_argument( + "--stream", + action=argparse.BooleanOptionalAction, + default=True, + help="Use structured streaming for model calls (default: enabled)", + ) + args = parser.parse_args() + enforce_top_level_pool_policy(args) + + upstream_root = resolve_upstream(args.upstream_root, cache_root=args.cache_root) + if args.list: + for task in list_tasks(upstream_root): + marker = "*" if task["id"] in PILOT_TASKS else " " + print( + f"{marker} {task['id']:<28} {task['difficulty']:<6} " + f"tests={task['expected_tests']:<4} prompt_bytes={task['prompt_bytes']}" + ) + return 0 + + selected_task_set = "custom" if args.task else args.task_set + if args.task: + task_names = args.task + elif args.task_set == "qwen32": + task_names = select_task_subset(list_tasks(upstream_root)) + else: + task_names = list(PILOT_TASKS) + if len(set(task_names)) != len(task_names): + parser.error("task IDs must be unique") + tasks = [load_task(upstream_root, name) for name in task_names] + rollout_concurrency = args.rollout_concurrency + if rollout_concurrency is None: + rollout_concurrency = 8 if selected_task_set == "qwen32" else 1 + if rollout_concurrency < 1: + parser.error("--rollout-concurrency must be positive") + if args.reward_concurrency < 1: + parser.error("--reward-concurrency must be positive") + + selected_mode = args.mode + if selected_mode is None: + selected_mode = "adaptive" if selected_task_set == "qwen32" else "both" + if selected_mode == "both": + modes = ("solo", "adaptive") + elif selected_mode == "all": + modes = ("solo", "adaptive", "forced-team") + else: + modes = (selected_mode,) + cases = [(task, mode) for task in tasks for mode in modes] + + if args.rescore: + if args.output is None: + parser.error("--rescore requires --output pointing to an existing run") + output_root = args.output.resolve() + ags_env_file = args.ags_env_file.resolve() if args.ags_env_file else None + rescored: list[dict[str, Any]] = [] + for task, mode in cases: + print(f"[{task['id']}] rescoring {mode}...", flush=True) + result = rescore_existing_case( + task, + mode, + output_root, + score_backend=args.score_backend, + score_timeout_s=args.score_timeout, + keep_image=args.keep_image, + ags_image=format_ags_image(args.ags_image_template, task["id"]), + ags_env_file=ags_env_file, + ags_timeout=args.ags_timeout, + ags_cpu=args.ags_cpu, + ags_memory=args.ags_memory, + ags_score_tool_id=args.ags_score_tool_id, + ) + tests = result["hidden_tests"]["pytest"] + print( + f" quality={result['quality_score']:.2f} " + f"passed={tests['passed']}/{tests['expected']} " + f"success={result['success']}", + flush=True, + ) + rescored.append(result) + return 0 if all(not result["hidden_tests"].get("error") for result in rescored) else 2 + + if args.plan: + print( + json.dumps( + { + "task_set": selected_task_set, + "tasks": task_names, + "modes": list(modes), + "cases": len(cases), + "max_turns": args.max_turns, + "team_execution_budget": _team_execution_budget( + teammate_max_turns=args.teammate_max_turns, + max_output_tokens=args.max_output_tokens, + team_timeout_s=args.agent_timeout, + ), + "rollout_concurrency": rollout_concurrency, + "reward_concurrency": args.reward_concurrency, + "reward_uses_rollout_slots": False, + }, + indent=2, + ) + ) + return 0 + + if args.validate: + if args.execution_backend == "ags" or args.score_backend == "ags": + from src.execution.ags import AGSSettings, ensure_swerex_importable + + image = format_ags_image(args.ags_image_template, tasks[0]["id"]) + settings = AGSSettings.from_env( + image=image, + env_file=args.ags_env_file, + timeout=args.ags_timeout, + cpu=args.ags_cpu, + memory=args.ags_memory, + ) + settings.validate() + ensure_swerex_importable(settings) + errors = [] + if args.score_backend == "ags": + score_tool_id = args.ags_score_tool_id or os.environ.get( + "AGS_SCORE_TOOL_ID", "" + ).strip() + if not score_tool_id: + errors.append( + "AGS scoring requires AGS_SCORE_TOOL_ID for a dedicated SANDBOX " + "network-mode tool; otherwise use --score-backend docker" + ) + else: + errors = validate_tasks(tasks) + if errors: + for error in errors: + print(f"- {error}") + return 1 + print( + f"{len(tasks)} NL2Repo tasks are configured for " + f"execution={args.execution_backend} scoring={args.score_backend}; " + f"cases={len(cases)} rollout_pool={rollout_concurrency} " + f"reward_pool={args.reward_concurrency}" + ) + return 0 + + started_at = datetime.now(timezone.utc) + run_id = started_at.strftime("%Y%m%dT%H%M%SZ") + output_root = (args.output or ROOT / "runs" / run_id).resolve() + output_root.mkdir(parents=True, exist_ok=True) + harness_commit, harness_dirty = _harness_revision() + _write_json( + output_root / "run-metadata.json", + { + "schema_version": 2, + "result_schema_version": RESULT_SCHEMA_VERSION, + "run_id": run_id, + "started_at": started_at.isoformat(), + "upstream_ref": UPSTREAM_REF, + "task_set": selected_task_set, + "task_set_hash": _stable_hash( + {"tasks": task_names, "modes": list(modes)} + ), + "tasks": task_names, + "modes": list(modes), + "cases": len(cases), + "prompt_version": PROMPT_VERSION, + "protocol_policy_version": PROTOCOL_POLICY_VERSION, + "score_policy_version": SCORE_POLICY_VERSION, + "harness_commit": harness_commit, + "harness_dirty": harness_dirty, + "provider": args.provider, + "model": args.model, + "execution_backend": args.execution_backend, + "score_backend": args.score_backend, + "max_turns": args.max_turns, + "teammate_max_turns": args.teammate_max_turns, + "teammate_min_timeout_s": args.teammate_min_timeout, + "team_execution_budget": _team_execution_budget( + teammate_max_turns=args.teammate_max_turns, + max_output_tokens=args.max_output_tokens, + team_timeout_s=args.agent_timeout, + ), + "rollout_concurrency": rollout_concurrency, + "reward_concurrency": args.reward_concurrency, + "reward_uses_rollout_slots": False, + }, + ) + scheduler_path = output_root / "scheduler.jsonl" + ags_env_file = args.ags_env_file.resolve() if args.ags_env_file else None + + def rollout_task(task: dict[str, Any], mode: str) -> RolloutArtifact: + return run_rollout( + task, + mode, + output_root, + provider=args.provider, + model=args.model, + max_turns=args.max_turns, + teammate_max_turns=args.teammate_max_turns, + teammate_min_timeout_s=args.teammate_min_timeout, + max_output_tokens=args.max_output_tokens, + agent_timeout_s=args.agent_timeout, + stream=args.stream, + execution_backend=args.execution_backend, + ags_image=format_ags_image(args.ags_image_template, task["id"]), + ags_env_file=ags_env_file, + ags_timeout=args.ags_timeout, + ags_cpu=args.ags_cpu, + ags_memory=args.ags_memory, + ) + + def reward_task(rollout: RolloutArtifact) -> dict[str, Any]: + return score_rollout( + rollout, + provider=args.provider, + model=args.model, + score_timeout_s=args.score_timeout, + keep_image=args.keep_image, + execution_backend=args.execution_backend, + score_backend=args.score_backend, + ags_image=format_ags_image(args.ags_image_template, rollout.task["id"]), + ags_env_file=ags_env_file, + ags_timeout=args.ags_timeout, + ags_cpu=args.ags_cpu, + ags_memory=args.ags_memory, + ags_score_tool_id=args.ags_score_tool_id, + ) + + def failed_task( + task: dict[str, Any], mode: str, phase: str, error: Exception + ) -> dict[str, Any]: + return _failed_case_result( + task, + mode, + phase, + error, + output_root=output_root, + provider=args.provider, + model=args.model, + execution_backend=args.execution_backend, + score_backend=args.score_backend, + ) + + def scheduler_event(event: dict[str, Any]) -> None: + _append_jsonl(scheduler_path, event) + event_name = event["event"] + if event_name == "rollout.started": + print(f"[{event['task']}] rollout started ({event['mode']})", flush=True) + elif event_name == "rollout.completed": + print( + f"[{event['task']}] rollout complete; slot released, reward queued", + flush=True, + ) + elif event_name == "reward.completed": + print( + f"[{event['task']}] reward={event.get('quality_score', 0):.2f} " + f"success={event.get('success', False)}", + flush=True, + ) + elif event_name.endswith(".failed"): + print( + f"[{event['task']}] {event_name}: {event.get('error_type')}", + flush=True, + ) + + print( + f"Starting {len(cases)} cases with rollout_pool={rollout_concurrency}, " + f"reward_pool={args.reward_concurrency}, max_turns={args.max_turns}", + flush=True, + ) + results = run_evaluation_pool( + cases, + rollout_task, + reward_task, + rollout_concurrency=rollout_concurrency, + reward_concurrency=args.reward_concurrency, + failure_fn=failed_task, + on_event=scheduler_event, + ) + aggregate = { + "run_id": run_id, + "upstream_url": UPSTREAM_URL, + "upstream_ref": UPSTREAM_REF, + "provider": args.provider, + "model": args.model, + "execution_backend": args.execution_backend, + "score_backend": args.score_backend, + "task_set": selected_task_set, + "run_config": { + "tasks": len(tasks), + "cases": len(cases), + "max_turns": args.max_turns, + "teammate_max_turns": args.teammate_max_turns, + "teammate_min_timeout_s": args.teammate_min_timeout, + "max_output_tokens": args.max_output_tokens, + "agent_timeout_s": args.agent_timeout, + "score_timeout_s": args.score_timeout, + "rollout_concurrency": rollout_concurrency, + "reward_concurrency": args.reward_concurrency, + "reward_uses_rollout_slots": False, + "stream": args.stream, + "ags_timeout": args.ags_timeout if "ags" in {args.execution_backend, args.score_backend} else None, + "ags_cpu": args.ags_cpu if "ags" in {args.execution_backend, args.score_backend} else None, + "ags_memory": args.ags_memory if "ags" in {args.execution_backend, args.score_backend} else None, + "ags_image_template": args.ags_image_template if "ags" in {args.execution_backend, args.score_backend} else None, + }, + "results": results, + } + _write_json(output_root / "results.json", aggregate) + report = render_report(results, run_id, UPSTREAM_REF) + (output_root / "REPORT.md").write_text(report, encoding="utf-8") + print(f"\n{report}\nArtifacts: {output_root}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/teammate-evals/nl2repo-pilot/compare_qwen_repeats.py b/teammate-evals/nl2repo-pilot/compare_qwen_repeats.py new file mode 100755 index 0000000..4653d69 --- /dev/null +++ b/teammate-evals/nl2repo-pilot/compare_qwen_repeats.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import statistics +import sys +from pathlib import Path +from typing import Any + + +def load_results(run: Path) -> list[dict[str, Any]]: + results: list[dict[str, Any]] = [] + for path in sorted(run.glob("*/*/result.json")): + value = json.loads(path.read_text(encoding="utf-8")) + if isinstance(value, dict): + results.append(value) + return results + + +def has_complete_usage(result: dict[str, Any]) -> bool: + usage = result.get("usage") or {} + if not isinstance(usage, dict): + return False + if "complete" in usage: + return bool(usage["complete"]) + return int(usage.get("total_tokens") or 0) > 0 + + +def summarize(results: list[dict[str, Any]]) -> dict[str, Any]: + valid = [r for r in results if not (r.get("hidden_tests") or {}).get("error")] + quality = [float(r.get("quality_score") or 0) for r in valid] + elapsed = [float(r.get("agent_elapsed_s") or 0) for r in results] + tokens = [int((r.get("usage") or {}).get("total_tokens") or 0) for r in results] + token_coverage = sum(has_complete_usage(result) for result in results) + return { + "cases": len(results), + "valid": len(valid), + "infra": len(results) - len(valid), + "quality": statistics.fmean(quality) if quality else 0.0, + "success": sum(bool(r.get("success")) for r in valid), + "success_rate": sum(bool(r.get("success")) for r in valid) / len(valid) if valid else 0, + "elapsed_mean": statistics.fmean(elapsed) if elapsed else 0.0, + "elapsed_median": statistics.median(elapsed) if elapsed else 0.0, + "tokens_total": sum(tokens), + "token_coverage": token_coverage, + "team_rate": sum(bool(r.get("used_team")) for r in results) / len(results) if results else 0, + "backends": ",".join(sorted({str(r.get("score_backend") or "missing") for r in results})), + } + + +def row(run_name: str, scope: str, summary: dict[str, Any]) -> str: + return ( + f"| {run_name} | {scope} | {summary['cases']} | {summary['valid']} | " + f"{summary['infra']} | {summary['quality']:.2f} | " + f"{summary['success']}/{summary['valid']} ({summary['success_rate']:.1%}) | " + f"{summary['elapsed_mean']:.1f} | {summary['elapsed_median']:.1f} | " + f"{summary['tokens_total']:,} ({summary['token_coverage']}/{summary['cases']}) | " + f"{summary['team_rate']:.1%} | {summary['backends']} |" + ) + + +def main() -> int: + runs = [Path(value).resolve() for value in sys.argv[1:]] + if not runs: + raise SystemExit("provide one or more run directories") + print("# Qwen NL2Repo three-repeat comparison (AGS Reward)\n") + print( + "| Run | Scope | Cases | Valid rewards | Infra errors | Mean quality | " + "Success | Mean rollout s | Median rollout s | Total tokens (coverage) | Team usage | Reward backend |" + ) + print("|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|") + for run in runs: + results = load_results(run) + print(row(run.name.rsplit("-", 1)[-1], "all", summarize(results))) + for mode in ("adaptive", "forced-team"): + print(row(run.name.rsplit("-", 1)[-1], mode, summarize([r for r in results if r.get("mode") == mode]))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/teammate-evals/nl2repo-pilot/dashboard.html b/teammate-evals/nl2repo-pilot/dashboard.html new file mode 100644 index 0000000..d5f440c --- /dev/null +++ b/teammate-evals/nl2repo-pilot/dashboard.html @@ -0,0 +1,638 @@ + + + + + + NL2Repo Evaluation Console + + + +
+
NL2RepoEVAL CONSOLE
+
等待评测数据…
+ +
CONNECTING
+
+ + +
+
+ +
+
+
+
Active benchmark run
+

+
+
+
Overall
+
Rollouts
+
Code quality
+
Protocol yield
+
Effective quality
+
+ +
+
总进度 · rollout + reward0%
+
Agent rollouts0%
+
Reward scoring0%
+
+ + + +
+
+

Global evaluation pool

读取全局队列…

+
+
+
Rollout slots
+
Reward slots
+
+
#BatchSlots R + WQueueRolloutReward wait / activeScoredOverall progressLegacy quality
+
+
+
#TaskPhaseTurnsRuntimeLast activityCalls / errorsRewardSignal
+
+
+
+
+

Adaptive vs Forced team

等待可配对结果…

+
+
#TaskAdaptive qualityForced qualityQuality ΔAdaptive timeForced timeRuntime ΔModel callsSource
当前批次没有配置合并比较
+
+
+
+ +
+ +
+ +
+ + + + diff --git a/teammate-evals/nl2repo-pilot/dashboard.py b/teammate-evals/nl2repo-pilot/dashboard.py new file mode 100644 index 0000000..52b4a23 --- /dev/null +++ b/teammate-evals/nl2repo-pilot/dashboard.py @@ -0,0 +1,1605 @@ +#!/usr/bin/env python3 +"""Serve a live dashboard for an NL2Repo benchmark run.""" + +from __future__ import annotations + +import argparse +import json +import math +import os +import re +import sqlite3 +import time +import webbrowser +from collections import Counter, deque +from dataclasses import dataclass, field +from datetime import datetime, timezone +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from statistics import median +from typing import Any +from urllib.parse import parse_qs, urlparse + + +HERE = Path(__file__).resolve().parent +DEFAULT_HTML = HERE / "dashboard.html" + + +def _read_json(path: Path, default: Any = None) -> Any: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return default + + +def _write_json_atomic(path: Path, value: Any) -> None: + temporary = path.with_suffix(f"{path.suffix}.tmp-{os.getpid()}") + temporary.write_text( + json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" + ) + os.replace(temporary, path) + + +def _read_jsonl(path: Path) -> list[dict[str, Any]]: + try: + lines = path.read_text(encoding="utf-8").splitlines() + except OSError: + return [] + values: list[dict[str, Any]] = [] + for line in lines: + try: + value = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(value, dict): + values.append(value) + return values + + +def _timestamp(value: Any) -> float | None: + if not isinstance(value, str) or not value: + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() + except ValueError: + return None + + +def _tail(path: Path, lines: int = 80, max_chars: int = 16_000) -> str: + try: + content = path.read_text(encoding="utf-8", errors="replace") + except OSError: + return "" + return "\n".join(content.splitlines()[-lines:])[-max_chars:] + + +def _compact(value: Any, limit: int = 360) -> str: + if value is None: + return "" + if isinstance(value, str): + text = value + else: + try: + text = json.dumps(value, ensure_ascii=False) + except TypeError: + text = str(value) + text = " ".join(text.split()) + if len(text) <= limit: + return text + head = max(1, int(limit * 0.62)) + tail = max(1, limit - head - 3) + return f"{text[:head]} … {text[-tail:]}" + + +def _number(value: Any) -> float | None: + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + numeric = float(value) + return numeric if math.isfinite(numeric) else None + + +def _average(values: list[float]) -> float | None: + return sum(values) / len(values) if values else None + + +def _normalize_result_metrics(result: dict[str, Any]) -> dict[str, Any]: + """Read v2 metrics or derive them from a legacy result without rewriting it.""" + hidden = result.get("hidden_tests") if isinstance(result.get("hidden_tests"), dict) else {} + pytest = hidden.get("pytest") if isinstance(hidden.get("pytest"), dict) else {} + legacy_quality = _number(result.get("quality_score")) + + timeout_scope = result.get("timeout_scope") + if timeout_scope not in {"rollout", "reward"}: + if result.get("agent_timed_out"): + timeout_scope = "rollout" + elif hidden.get("timed_out") or pytest.get("returncode") == 124: + timeout_scope = "reward" + else: + timeout_scope = None + + reward_outcome = result.get("reward_outcome") + if not isinstance(reward_outcome, str) or not reward_outcome: + if hidden.get("infrastructure_timed_out"): + reward_outcome = "infra_timeout" + elif hidden.get("error"): + reward_outcome = "infra_error" + elif timeout_scope == "reward": + reward_outcome = "candidate_timeout" + elif result.get("reward_skipped") or hidden.get("skipped"): + reward_outcome = ( + "protocol_skipped_legacy" + if result.get("protocol_ok") is False + else "missing_artifact" + ) + elif legacy_quality is not None: + reward_outcome = "scored" + else: + reward_outcome = "pending" + + reward_score_valid = result.get("reward_score_valid") + if not isinstance(reward_score_valid, bool): + reward_score_valid = bool( + reward_outcome == "scored" and legacy_quality is not None + ) + code_quality = _number(result.get("code_quality_score")) + if code_quality is None and reward_score_valid: + code_quality = legacy_quality + + protocol_status = result.get("protocol_status") + if protocol_status not in {"passed", "failed", "not_evaluated"}: + # Very old results did not persist protocol_ok. Treat an otherwise + # scoreable result as the historical pass-through protocol, preserving + # its prior aggregate meaning. + protocol_status = ( + "passed" + if result.get("protocol_ok", True) + else "failed" + ) + protocol_credit = _number(result.get("protocol_credit")) + if protocol_credit is None and protocol_status != "not_evaluated": + protocol_credit = 1.0 if protocol_status == "passed" else 0.0 + + delivery_valid = result.get("delivery_valid") + if not isinstance(delivery_valid, bool): + delivery_valid = bool( + result.get("agent_ok", True) and result.get("integrity_ok", True) + ) + + effective_quality = _number(result.get("effective_quality_score")) + has_explicit_effective = ( + result.get("result_schema_version") == 2 + and "effective_quality_score" in result + ) + if effective_quality is None and not has_explicit_effective: + if not delivery_valid or protocol_credit == 0.0: + effective_quality = 0.0 + elif reward_score_valid and code_quality is not None: + effective_quality = code_quality * protocol_credit + + eligibility = result.get("metric_eligibility") + if not isinstance(eligibility, dict): + eligibility = {} + eligibility = { + "code_quality": bool( + eligibility.get("code_quality", reward_score_valid) + ), + "protocol_yield": bool( + eligibility.get( + "protocol_yield", + bool(result.get("integrity_ok", True)) + and protocol_status in {"passed", "failed"}, + ) + ), + "effective_quality": bool( + eligibility.get( + "effective_quality", + effective_quality is not None + and reward_outcome not in {"infra_error", "infra_timeout"}, + ) + ), + } + + is_infrastructure = result.get("is_infrastructure") + if not isinstance(is_infrastructure, bool): + is_infrastructure = bool( + hidden.get("error") + or reward_outcome in {"infra_error", "infra_timeout"} + or result.get("failure_class") == "scorer_infrastructure" + ) + failure_domain = result.get("failure_domain") + if not isinstance(failure_domain, str) or not failure_domain: + if is_infrastructure: + failure_domain = "infrastructure" + elif not delivery_valid or result.get("agent_ok") is False: + failure_domain = "candidate" + elif protocol_status == "failed": + failure_domain = "protocol" + elif reward_score_valid and not bool(pytest.get("all_passed")): + failure_domain = "candidate" + else: + failure_domain = None + retryable = result.get("retryable") + if not isinstance(retryable, bool): + retryable = is_infrastructure + + return { + "code_quality_score": code_quality, + "protocol_status": protocol_status, + "protocol_credit": protocol_credit, + "delivery_valid": delivery_valid, + "effective_quality_score": effective_quality, + "reward_outcome": reward_outcome, + "reward_score_valid": reward_score_valid, + "metric_eligibility": eligibility, + "failure_domain": failure_domain, + "is_infrastructure": is_infrastructure, + "retryable": retryable, + "timeout_scope": timeout_scope, + } + + +def _team_directory(case_root: Path) -> Path | None: + candidates: list[Path] = [] + for teams_root in ( + case_root / "workspace" / ".clawd" / "teams", + case_root / ".clawd" / "teams", + ): + try: + candidates.extend(path for path in teams_root.iterdir() if path.is_dir()) + except OSError: + continue + if not candidates: + return None + return max( + candidates, + key=lambda path: (path / "events.jsonl").stat().st_mtime + if (path / "events.jsonl").exists() + else 0, + ) + + +def _team_detail(case_root: Path) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]: + """Return a compact team snapshot and an actor-aware trace for the drawer.""" + team_root = _team_directory(case_root) + if team_root is None: + return None, [] + + team = _read_json(team_root / "team.json", {}) + tasks = _read_json(team_root / "tasks.json", {}) + if not isinstance(team, dict): + team = {} + if not isinstance(tasks, dict): + tasks = {} + + trace: list[dict[str, Any]] = [] + actor_turns: dict[str, int] = {} + actor_names: dict[str, str] = {} + actor_stats: dict[str, Counter[str]] = {} + for event in _read_jsonl(team_root / "events.jsonl"): + event_type = str(event.get("type") or "unknown") + data = event.get("data") + if not isinstance(data, dict): + data = {} + + nested_agent = data.get("agent") + if isinstance(nested_agent, dict): + agent_id = str(nested_agent.get("agent_id") or "") + agent_name = str(nested_agent.get("name") or "") + if agent_id and agent_name: + actor_names[agent_id] = agent_name + agent_id = str(data.get("actor_id") or data.get("agent_id") or "") + actor = str( + data.get("actor_name") + or data.get("name") + or actor_names.get(agent_id) + or ("team" if event_type.startswith(("team.", "task.", "agent.")) else "lead") + ) + turn = data.get("turn") + if isinstance(turn, int): + actor_turns[actor] = turn + else: + turn = actor_turns.get(actor) + + tool = str(data.get("tool_name") or "") + detail = "" + if event_type == "tool.started": + detail = _compact(data.get("tool_input")) + elif event_type in {"tool.completed", "tool.failed"}: + output = data.get("tool_output") + if isinstance(output, dict): + parts: list[str] = [] + if output.get("exit_code") is not None: + parts.append(f"exit {output['exit_code']}") + parts.append(_compact(output.get("stderr") or output.get("stdout") or output)) + detail = " · ".join(part for part in parts if part) + else: + detail = _compact(output or data.get("error")) + elif event_type == "model.response": + detail = _compact(data.get("content") or data.get("error"), limit=720) + elif event_type.startswith("task."): + task = data.get("task") + if isinstance(task, dict): + detail = _compact(task.get("subject") or task.get("output") or task.get("last_error")) + else: + detail = _compact(data.get("subject") or data.get("output") or data.get("error")) + elif event_type.startswith("agent."): + detail = _compact( + (nested_agent or {}).get("role") + if isinstance(nested_agent, dict) + else data.get("status") + ) + + stats = actor_stats.setdefault(actor, Counter()) + if event_type == "model.response": + stats["turns"] += 1 + if event_type == "tool.started": + stats["tools"] += 1 + if event_type.endswith("failed"): + stats["errors"] += 1 + trace.append( + { + "kind": event_type.replace(".", "_"), + "actor": actor, + "turn": turn, + "tool": tool, + "tool_use_id": data.get("tool_use_id"), + "created_at": event.get("created_at"), + "is_error": event_type.endswith("failed"), + "duration_ms": data.get("duration_ms"), + "detail": detail, + } + ) + + task_rows = [] + for task in tasks.values(): + if not isinstance(task, dict): + continue + owner_id = str(task.get("owner") or "") + task_rows.append( + { + "subject": str(task.get("subject") or task.get("description") or "task"), + "status": str(task.get("status") or "unknown"), + "owner": actor_names.get(owner_id, owner_id or "unassigned"), + "output": _compact(task.get("output") or task.get("last_error"), limit=520), + } + ) + actors = [ + { + "name": name, + "turns": stats["turns"], + "tools": stats["tools"], + "errors": stats["errors"], + } + for name, stats in actor_stats.items() + if name != "team" + ] + snapshot = { + "id": str(team.get("team_id") or team_root.name), + "name": str(team.get("team_name") or "team"), + "status": str(team.get("status") or "unknown"), + "actors": actors, + "tasks": task_rows, + } + return snapshot, trace[-1_400:] + + +@dataclass +class ProgressAccumulator: + offset: int = 0 + inode: int | None = None + counts: Counter[str] = field(default_factory=Counter) + error_count: int = 0 + model_duration_ms: float = 0.0 + model_duration_count: int = 0 + last_kind: str = "" + last_turn: int = 0 + last_tool: str = "" + last_at: float | None = None + terminal: str = "" + usage: dict[str, Any] = field(default_factory=dict) + current_turn: int | None = None + recent: deque[dict[str, Any]] = field(default_factory=lambda: deque(maxlen=1_400)) + text_fragments: deque[str] = field(default_factory=lambda: deque(maxlen=100)) + + def reset(self) -> None: + fresh = ProgressAccumulator() + self.__dict__.update(fresh.__dict__) + + def consume(self, path: Path) -> None: + try: + stat = path.stat() + except OSError: + return + if self.inode not in (None, stat.st_ino) or stat.st_size < self.offset: + self.reset() + self.inode = stat.st_ino + with path.open("rb") as handle: + handle.seek(self.offset) + while True: + line_start = handle.tell() + line = handle.readline() + if not line: + break + if not line.endswith(b"\n"): + handle.seek(line_start) + break + self.offset = handle.tell() + try: + event = json.loads(line.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + continue + if isinstance(event, dict): + self._consume_event(event) + + def _consume_event(self, event: dict[str, Any]) -> None: + kind = str(event.get("kind") or "unknown") + self.counts[kind] += 1 + self.last_kind = kind + turn = event.get("turn") + if isinstance(turn, int): + self.last_turn = max(self.last_turn, turn) + self.current_turn = turn + created_at = _timestamp(event.get("created_at")) + if created_at is not None: + self.last_at = created_at + usage = event.get("usage") + if isinstance(usage, dict) and usage: + self.usage = usage + if kind == "model_response": + duration = event.get("duration_ms") + if isinstance(duration, (int, float)): + self.model_duration_ms += float(duration) + self.model_duration_count += 1 + if kind == "tool_use": + self.last_tool = str(event.get("tool_name") or event.get("name") or "") + if event.get("is_error") or kind in {"tool_error", "run_failed", "run_cancelled"}: + self.error_count += 1 + if kind in {"run_completed", "run_failed", "run_cancelled"}: + self.terminal = kind + if kind == "text_chunk": + content = event.get("content") + if isinstance(content, str) and content: + self.text_fragments.append(content) + return + self.recent.append(self._event_summary(event)) + + def _event_summary(self, event: dict[str, Any]) -> dict[str, Any]: + kind = str(event.get("kind") or "unknown") + tool = str(event.get("tool_name") or event.get("name") or "") + detail = "" + if kind == "tool_use": + detail = _compact(event.get("tool_input") or event.get("input")) + elif kind in {"tool_result", "tool_error"}: + output = event.get("tool_output") + if isinstance(output, dict): + parts = [] + if output.get("exit_code") is not None: + parts.append(f"exit {output['exit_code']}") + parts.append(_compact(output.get("stderr") or output.get("stdout") or output)) + detail = " · ".join(part for part in parts if part) + else: + detail = _compact(output or event.get("error")) + else: + detail = _compact(event.get("error") or event.get("content")) + return { + "kind": kind, + "tool": tool, + "actor": "lead", + "turn": event.get("turn") if isinstance(event.get("turn"), int) else self.current_turn, + "tool_use_id": event.get("tool_use_id"), + "created_at": event.get("created_at"), + "is_error": bool(event.get("is_error") or kind in {"tool_error", "run_failed"}), + "duration_ms": event.get("duration_ms"), + "detail": detail, + } + + def summary(self, path: Path, now: float) -> dict[str, Any]: + self.consume(path) + if self.last_at is None: + try: + self.last_at = path.stat().st_mtime + except OSError: + pass + avg_model_s = ( + self.model_duration_ms / self.model_duration_count / 1000 + if self.model_duration_count + else None + ) + return { + "model_calls": self.counts["model_response"], + "tool_calls": self.counts["tool_use"], + "errors": self.error_count, + "last_kind": self.last_kind, + "last_turn": self.last_turn, + "last_tool": self.last_tool, + "last_at": self.last_at, + "activity_age_s": max(0.0, now - self.last_at) if self.last_at else None, + "terminal": self.terminal, + "usage": self.usage, + "avg_model_s": avg_model_s, + } + + +class DashboardStore: + def __init__(self, run_root: Path) -> None: + self.run_root = run_root.expanduser().resolve() + if not self.run_root.is_dir(): + raise FileNotFoundError(f"run directory not found: {self.run_root}") + self._progress: dict[tuple[str, str], ProgressAccumulator] = {} + self._comparison_cache: dict[str, Any] | None = None + self._comparison_cache_key = "" + self._comparison_cached_at = 0.0 + + def _metadata(self) -> dict[str, Any]: + metadata = _read_json(self.run_root / "run-metadata.json", {}) + if not isinstance(metadata, dict): + metadata = {} + aggregate = _read_json(self.run_root / "results.json", {}) + if isinstance(aggregate, dict): + metadata.setdefault("provider", aggregate.get("provider")) + metadata.setdefault("model", aggregate.get("model")) + metadata.setdefault("execution_backend", aggregate.get("execution_backend")) + metadata.setdefault("score_backend", aggregate.get("score_backend")) + config = aggregate.get("run_config") + if isinstance(config, dict): + for key, value in config.items(): + metadata.setdefault(key, value) + if not metadata.get("tasks") and isinstance(aggregate.get("results"), list): + metadata["tasks"] = sorted( + {str(item.get("task")) for item in aggregate["results"] if item.get("task")} + ) + metadata.setdefault("run_id", self.run_root.name) + return metadata + + def _scheduler(self) -> list[dict[str, Any]]: + return _read_jsonl(self.run_root / "scheduler.jsonl") + + def _queue_cases(self) -> list[dict[str, Any]]: + path = self.run_root / "queue.sqlite3" + if not path.is_file(): + return [] + try: + connection = sqlite3.connect( + f"file:{path}?mode=ro", uri=True, timeout=2 + ) + connection.row_factory = sqlite3.Row + try: + rows = connection.execute( + "SELECT * FROM cases ORDER BY priority DESC, id ASC" + ).fetchall() + finally: + connection.close() + except (sqlite3.Error, OSError): + return [] + return [dict(row) for row in rows] + + def _queue_concurrency(self) -> dict[str, Any] | None: + path = self.run_root / "queue.sqlite3" + if not path.is_file(): + return None + try: + connection = sqlite3.connect( + f"file:{path}?mode=ro", uri=True, timeout=2 + ) + connection.row_factory = sqlite3.Row + try: + row = connection.execute( + "SELECT * FROM worker_config WHERE id=1" + ).fetchone() + finally: + connection.close() + except (sqlite3.Error, OSError): + return None + return dict(row) if row is not None else None + + def set_concurrency( + self, + *, + rollout: int | None = None, + reward: int | None = None, + ) -> dict[str, Any]: + """Reject per-run scaling; the global supervisor owns worker allocation.""" + del rollout, reward + raise RuntimeError( + "per-run concurrency is read-only; change global capacity through " + "global_pool_supervisor.py" + ) + + def _case_specs( + self, + metadata: dict[str, Any], + scheduler: list[dict[str, Any]], + queue_cases: list[dict[str, Any]], + ) -> list[tuple[str, str]]: + configured = metadata.get("tasks") + if isinstance(configured, list): + values = [] + for item in configured: + name = item.get("id") if isinstance(item, dict) else item + if name: + values.append(str(name)) + if values: + modes = metadata.get("modes") + selected_modes = ( + [str(mode) for mode in modes] + if isinstance(modes, list) and modes + else ["adaptive"] + ) + return [(task, mode) for task in values for mode in selected_modes] + queued = [ + (str(case["task"]), str(case.get("mode") or "adaptive")) + for case in queue_cases + if case.get("task") + ] + if queued: + return list(dict.fromkeys(queued)) + discovered = { + (path.parent.parent.name, path.parent.name) + for path in self.run_root.glob("*/*/progress.jsonl") + } + discovered.update( + (str(event["task"]), str(event.get("mode") or "adaptive")) + for event in scheduler + if event.get("task") + ) + return sorted(discovered) + + @staticmethod + def _mode_for_task(root: Path, task: str, metadata: dict[str, Any]) -> str: + modes = metadata.get("modes") + if isinstance(modes, list) and modes: + return str(modes[0]) + task_root = root / task + if task_root.is_dir(): + candidates = sorted(path.name for path in task_root.iterdir() if path.is_dir()) + if candidates: + return candidates[0] + return "adaptive" + + @staticmethod + def _start_epoch( + metadata: dict[str, Any], scheduler: list[dict[str, Any]], scheduler_path: Path + ) -> float | None: + explicit = _timestamp(metadata.get("started_at")) + if explicit is not None: + return explicit + if scheduler: + elapsed = scheduler[-1].get("elapsed_s") + if isinstance(elapsed, (int, float)): + try: + return scheduler_path.stat().st_mtime - float(elapsed) + except OSError: + pass + return None + + @staticmethod + def _comparison_case( + result: dict[str, Any], source_run: str + ) -> dict[str, Any]: + metrics = _normalize_result_metrics(result) + calls = result.get("calls") if isinstance(result.get("calls"), dict) else {} + usage = result.get("usage") if isinstance(result.get("usage"), dict) else {} + hidden = ( + result.get("hidden_tests") + if isinstance(result.get("hidden_tests"), dict) + else {} + ) + tokens = _number(usage.get("total_tokens")) + # Streaming responses without include_usage were historically persisted as + # zero. Treat those as missing measurements instead of free rollouts. + if tokens is not None and tokens <= 0: + tokens = None + return { + "source_run": source_run, + "model": result.get("model"), + "quality_score": ( + metrics["code_quality_score"] + if metrics["metric_eligibility"]["code_quality"] + else None + ), + "runtime_s": _number(result.get("agent_elapsed_s")), + "model_calls": _number(calls.get("model")), + "tool_calls": _number(calls.get("tools")), + "total_tokens": tokens, + "success": bool(result.get("success")), + "infrastructure_error": str(hidden.get("error") or "") or None, + } + + @staticmethod + def _comparison_mode_summary( + rows: list[dict[str, Any]], mode: str + ) -> dict[str, Any]: + cases = [row[mode] for row in rows] + + def values(key: str) -> list[float]: + return [ + value + for case in cases + if (value := _number(case.get(key))) is not None + ] + + quality = values("quality_score") + runtime = values("runtime_s") + model_calls = values("model_calls") + tool_calls = values("tool_calls") + tokens = values("total_tokens") + return { + "count": len(cases), + "quality_count": len(quality), + "average_quality": _average(quality), + "median_quality": median(quality) if quality else None, + "average_runtime_s": _average(runtime), + "median_runtime_s": median(runtime) if runtime else None, + "cumulative_runtime_s": sum(runtime), + "average_model_calls": _average(model_calls), + "average_tool_calls": _average(tool_calls), + "total_tokens": sum(tokens), + "token_coverage": len(tokens), + "strict_successes": sum(bool(case.get("success")) for case in cases), + "models": sorted( + {str(case["model"]) for case in cases if case.get("model")} + ), + "source_runs": dict( + Counter(str(case["source_run"]) for case in cases) + ), + } + + def _comparison(self, metadata: dict[str, Any]) -> dict[str, Any] | None: + config = metadata.get("comparison") + if not isinstance(config, dict): + return None + cache_key = json.dumps(config, sort_keys=True, ensure_ascii=False) + if ( + self._comparison_cache is not None + and self._comparison_cache_key == cache_key + and time.monotonic() - self._comparison_cached_at < 10 + ): + return self._comparison_cache + configured_modes = config.get("modes") + modes = ( + [str(mode) for mode in configured_modes] + if isinstance(configured_modes, list) + else ["adaptive", "forced-team"] + ) + if len(modes) != 2 or modes[0] == modes[1]: + return None + left_mode, right_mode = modes + + indexed: dict[tuple[str, str], dict[str, Any]] = {} + + def load_run(root: Path, source_run: str, only_mode: str | None = None) -> None: + pattern = f"*/{only_mode}/result.json" if only_mode else "*/*/result.json" + for path in root.glob(pattern): + task = path.parent.parent.name + mode = path.parent.name + key = (task, mode) + if key in indexed: + continue + result = _read_json(path, {}) + if isinstance(result, dict) and result: + indexed[key] = self._comparison_case(result, source_run) + + load_run(self.run_root, self.run_root.name) + baselines = config.get("baseline_runs") + baseline_runs: dict[str, str] = {} + if isinstance(baselines, dict): + for mode, raw_run_id in baselines.items(): + run_id = str(raw_run_id) + if Path(run_id).name != run_id: + continue + root = (self.run_root.parent / run_id).resolve() + if root.parent != self.run_root.parent or not root.is_dir(): + continue + baseline_runs[str(mode)] = run_id + load_run(root, run_id, str(mode)) + + paired_tasks = sorted( + {task for task, mode in indexed if mode == left_mode} + & {task for task, mode in indexed if mode == right_mode} + ) + rows: list[dict[str, Any]] = [] + for task in paired_tasks: + left = indexed[(task, left_mode)] + right = indexed[(task, right_mode)] + left_quality = _number(left.get("quality_score")) + right_quality = _number(right.get("quality_score")) + left_runtime = _number(left.get("runtime_s")) + right_runtime = _number(right.get("runtime_s")) + rows.append( + { + "task": task, + left_mode: left, + right_mode: right, + "quality_delta": ( + right_quality - left_quality + if left_quality is not None and right_quality is not None + else None + ), + "runtime_delta_s": ( + right_runtime - left_runtime + if left_runtime is not None and right_runtime is not None + else None + ), + "runtime_ratio": ( + right_runtime / left_runtime + if left_runtime and right_runtime is not None + else None + ), + "cross_run": left["source_run"] != right["source_run"], + "deployment_mismatch": left.get("model") != right.get("model"), + } + ) + + if not rows: + return None + quality_deltas = [ + value + for row in rows + if (value := _number(row.get("quality_delta"))) is not None + ] + runtime_deltas = [ + value + for row in rows + if (value := _number(row.get("runtime_delta_s"))) is not None + ] + comparison = { + "modes": modes, + "paired_count": len(rows), + "cross_run_count": sum(bool(row["cross_run"]) for row in rows), + "deployment_mismatch_count": sum( + bool(row["deployment_mismatch"]) for row in rows + ), + "baseline_runs": baseline_runs, + "mode_summaries": { + left_mode: self._comparison_mode_summary(rows, left_mode), + right_mode: self._comparison_mode_summary(rows, right_mode), + }, + "paired": { + "average_quality_delta": _average(quality_deltas), + "average_runtime_delta_s": _average(runtime_deltas), + "right_quality_wins": sum(value > 0 for value in quality_deltas), + "ties": sum(value == 0 for value in quality_deltas), + "left_quality_wins": sum(value < 0 for value in quality_deltas), + "right_faster": sum(value < 0 for value in runtime_deltas), + "left_faster": sum(value > 0 for value in runtime_deltas), + }, + "rows": rows, + "notes": [ + "Cross-run rows use agent_elapsed_s and can reflect different concurrency or service load.", + "Token totals exclude zero-valued historical streaming usage; coverage is shown explicitly.", + ], + } + self._comparison_cache = comparison + self._comparison_cache_key = cache_key + self._comparison_cached_at = time.monotonic() + return comparison + + def state(self) -> dict[str, Any]: + now = time.time() + metadata = self._metadata() + concurrency = self._queue_concurrency() + if concurrency is not None: + metadata.update( + { + "rollout_concurrency": concurrency["rollout_concurrency"], + "reward_concurrency": concurrency["reward_concurrency"], + "max_rollout_concurrency": concurrency[ + "max_rollout_concurrency" + ], + "max_reward_concurrency": concurrency[ + "max_reward_concurrency" + ], + } + ) + scheduler = self._scheduler() + queue_cases = self._queue_cases() + scheduler_path = self.run_root / "scheduler.jsonl" + case_specs = self._case_specs(metadata, scheduler, queue_cases) + total = len(case_specs) + start_epoch = self._start_epoch(metadata, scheduler, scheduler_path) + now_elapsed = max(0.0, now - start_epoch) if start_epoch else 0.0 + + events_by_case: dict[tuple[str, str], list[dict[str, Any]]] = {} + for event in scheduler: + task = event.get("task") + if task: + key = (str(task), str(event.get("mode") or "adaptive")) + events_by_case.setdefault(key, []).append(event) + queue_by_task = { + (str(case.get("task")), str(case.get("mode"))): case + for case in queue_cases + } + + tasks: list[dict[str, Any]] = [] + for index, (task, mode) in enumerate(case_specs): + queued_case = queue_by_task.get((task, mode)) + queue_status = str(queued_case.get("status")) if queued_case else "" + case_root = self.run_root / task / mode + progress_path = case_root / "progress.jsonl" + accumulator = self._progress.setdefault((task, mode), ProgressAccumulator()) + progress = accumulator.summary(progress_path, now) + task_events = events_by_case.get((task, mode), []) + event_map = {event.get("event"): event for event in task_events} + rollout_started = event_map.get("rollout.started") + rollout_completed = event_map.get("rollout.completed") + reward_started = event_map.get("reward.started") + reward_completed = event_map.get("reward.completed") + result = _read_json(case_root / "result.json", {}) + if not isinstance(result, dict): + result = {} + metrics = _normalize_result_metrics(result) if result else {} + hidden = result.get("hidden_tests") if isinstance(result.get("hidden_tests"), dict) else {} + pytest = hidden.get("pytest") if isinstance(hidden.get("pytest"), dict) else {} + queue_error = str(queued_case.get("error") or "") if queued_case else "" + infrastructure_error = "" + if result and metrics.get("is_infrastructure"): + infrastructure_error = str( + hidden.get("error") + or queue_error + or result.get("failure_class") + or metrics.get("reward_outcome") + or "infrastructure failure" + ) + elif not result and queue_error: + infrastructure_error = queue_error + + if queue_status == "queued": + status = "queued" + elif queue_status == "rollout": + status = "running" + elif queue_status == "reward_pending": + status = "reward_waiting" + elif queue_status == "rewarding": + status = "rewarding" + elif queue_status == "failed": + status = "infra_error" if infrastructure_error or not result else "scored" + elif queue_status == "done" and result: + status = "infra_error" if infrastructure_error else ( + "success" if result.get("success") else "scored" + ) + elif queue_status == "done": + status = "infra_error" + infrastructure_error = "queue completed without result.json" + elif result: + status = "infra_error" if infrastructure_error else ( + "success" if result.get("success") else "scored" + ) + elif reward_started and not reward_completed: + status = "rewarding" + elif rollout_completed: + status = "reward_waiting" + elif rollout_started: + status = "running" + else: + status = "queued" + + queued_started = bool(queue_status and queue_status != "queued") + queued_rollout_done = queue_status in { + "reward_pending", "rewarding", "done", "failed" + } + queued_reward_started = queue_status in {"rewarding", "done", "failed"} + queued_reward_done = queue_status in {"done", "failed"} + if queued_case: + rollout_started_flag = queued_started + rollout_completed_flag = queued_rollout_done + reward_started_flag = queued_reward_started + reward_completed_flag = queued_reward_done + queued_started_at = _timestamp(queued_case.get("started_at")) + queued_completed_at = _timestamp(queued_case.get("rollout_finished_at")) + started_elapsed = ( + max(0.0, queued_started_at - start_epoch) + if queued_started_at is not None and start_epoch is not None + else None + ) + completed_elapsed = ( + max(0.0, queued_completed_at - start_epoch) + if queued_completed_at is not None and start_epoch is not None + else None + ) + else: + rollout_started_flag = bool(rollout_started) + rollout_completed_flag = bool(rollout_completed) + reward_started_flag = bool(reward_started) + reward_completed_flag = bool(result or reward_completed) + started_elapsed = ( + float(rollout_started.get("elapsed_s", 0)) + if rollout_started + else None + ) + completed_elapsed = ( + float(rollout_completed.get("elapsed_s", 0)) + if rollout_completed + else None + ) + runtime_s = None + if started_elapsed is not None: + runtime_s = max( + 0.0, + (completed_elapsed if completed_elapsed is not None else now_elapsed) + - started_elapsed, + ) + quality = result.get("quality_score") if result else None + turns = ( + (result.get("usage") or {}).get("lead_turns") + if isinstance(result.get("usage"), dict) + else None + ) + if not isinstance(turns, int): + turns = progress["model_calls"] + tasks.append( + { + "index": index + 1, + "task": task, + "mode": mode, + "case_key": f"{task}::{mode}", + "status": status, + "rollout_started": rollout_started_flag, + "rollout_completed": rollout_completed_flag, + "reward_started": reward_started_flag, + "reward_completed": reward_completed_flag, + "runtime_s": runtime_s, + "turns": turns, + "max_turns": metadata.get("max_turns", 300), + "model_calls": progress["model_calls"], + "tool_calls": progress["tool_calls"], + "tool_errors": progress["errors"], + "last_tool": progress["last_tool"], + "last_kind": progress["last_kind"], + "activity_age_s": progress["activity_age_s"], + "avg_model_s": progress["avg_model_s"], + "quality_score": quality, + "code_quality_score": metrics.get("code_quality_score"), + "protocol_status": metrics.get("protocol_status"), + "protocol_credit": metrics.get("protocol_credit"), + "delivery_valid": metrics.get("delivery_valid"), + "effective_quality_score": metrics.get("effective_quality_score"), + "reward_outcome": metrics.get("reward_outcome"), + "reward_score_valid": metrics.get("reward_score_valid"), + "metric_eligibility": metrics.get("metric_eligibility") or {}, + "failure_domain": metrics.get("failure_domain"), + "is_infrastructure": metrics.get("is_infrastructure", False), + "retryable": metrics.get("retryable", False), + "timeout_scope": metrics.get("timeout_scope"), + "success": bool(result.get("success")) if result else None, + "passed": pytest.get("passed"), + "failed": pytest.get("failed"), + "errors": pytest.get("errors"), + "expected": pytest.get("expected"), + "infrastructure_error": infrastructure_error or None, + "failure_class": result.get("failure_class") if result else None, + "agent_ok": result.get("agent_ok") if result else None, + "rescored": bool(result.get("rescored_at")), + "queue_id": queued_case.get("id") if queued_case else None, + "queue_status": queue_status or None, + "priority": queued_case.get("priority") if queued_case else None, + "attempt": queued_case.get("attempt") if queued_case else None, + "enqueued_at": queued_case.get("enqueued_at") if queued_case else None, + } + ) + + started_count = sum(task["rollout_started"] for task in tasks) + completed_count = sum(task["rollout_completed"] for task in tasks) + rewards_done = sum(task["reward_completed"] for task in tasks) + active_count = sum(task["status"] == "running" for task in tasks) + reward_active = sum(task["status"] == "rewarding" for task in tasks) + infrastructure_errors = sum(task["status"] == "infra_error" for task in tasks) + code_quality_scores = [ + float(task["code_quality_score"]) + for task in tasks + if task["code_quality_score"] is not None + and task["metric_eligibility"].get("code_quality") + ] + protocol_cases = [ + task for task in tasks + if task["metric_eligibility"].get("protocol_yield") + ] + protocol_passes = sum( + task["protocol_status"] == "passed" for task in protocol_cases + ) + effective_quality_scores = [ + float(task["effective_quality_score"]) + for task in tasks + if task["effective_quality_score"] is not None + and task["metric_eligibility"].get("effective_quality") + ] + strict_successes = sum(task["status"] == "success" for task in tasks) + passed_total = sum(int(task["passed"] or 0) for task in tasks if not task["infrastructure_error"]) + expected_total = sum( + int(task["expected"] or 0) for task in tasks if not task["infrastructure_error"] + ) + completed_elapsed_values = [ + float(event["elapsed_s"]) + for event in scheduler + if event.get("event") == "rollout.completed" + and isinstance(event.get("elapsed_s"), (int, float)) + ] + rollout_rate_h = completed_count / now_elapsed * 3600 if now_elapsed else 0.0 + eta_s = ( + (total - completed_count) / rollout_rate_h * 3600 + if rollout_rate_h > 0 and total > completed_count + else 0.0 + ) + latest_activity_age = min( + ( + float(task["activity_age_s"]) + for task in tasks + if task["activity_age_s"] is not None and task["status"] == "running" + ), + default=None, + ) + aggregate_exists = (self.run_root / "results.json").is_file() + continuous = metadata.get("queue_mode") == "continuous" + if aggregate_exists and not continuous: + run_status = "completed" + elif active_count and latest_activity_age is not None and latest_activity_age < 180: + run_status = "live" + elif active_count: + run_status = "stale" + elif continuous: + run_status = "waiting" + else: + run_status = "idle" + + timeline = [ + { + "event": event.get("event"), + "task": event.get("task"), + "mode": event.get("mode"), + "elapsed_s": event.get("elapsed_s"), + "quality_score": event.get("quality_score"), + "success": event.get("success"), + } + for event in scheduler[-160:] + ] + comparison = self._comparison(metadata) + return { + "generated_at": datetime.now(timezone.utc).isoformat(), + "run": { + "id": metadata.get("run_id", self.run_root.name), + "path": str(self.run_root), + "status": run_status, + "started_at": ( + datetime.fromtimestamp(start_epoch, timezone.utc).isoformat() + if start_epoch + else None + ), + "elapsed_s": now_elapsed, + "provider": metadata.get("provider"), + "model": metadata.get("model"), + "execution_backend": metadata.get("execution_backend"), + "score_backend": metadata.get("score_backend"), + "rollout_concurrency": metadata.get("rollout_concurrency", 0), + "reward_concurrency": metadata.get("reward_concurrency", 0), + "max_rollout_concurrency": metadata.get( + "max_rollout_concurrency", metadata.get("rollout_concurrency", 0) + ), + "max_reward_concurrency": metadata.get( + "max_reward_concurrency", metadata.get("reward_concurrency", 0) + ), + "max_turns": metadata.get("max_turns", 300), + "queue_mode": metadata.get("queue_mode"), + }, + "summary": { + "total": total, + "queued": max(0, total - started_count), + "started": started_count, + "active": active_count, + "rollouts_completed": completed_count, + "reward_active": reward_active, + "rewards_completed": rewards_done, + "strict_successes": strict_successes, + "infrastructure_errors": infrastructure_errors, + # average_quality/valid_rewards remain aliases for old clients. + "average_quality": _average(code_quality_scores), + "valid_rewards": len(code_quality_scores), + "code_quality": _average(code_quality_scores), + "coverage": ( + len(code_quality_scores) / rewards_done if rewards_done else 0.0 + ), + "coverage_count": len(code_quality_scores), + "coverage_total": rewards_done, + "protocol_yield": ( + protocol_passes / len(protocol_cases) if protocol_cases else None + ), + "protocol_passed": protocol_passes, + "protocol_eligible": len(protocol_cases), + "effective_quality": _average(effective_quality_scores), + "effective_eligible": len(effective_quality_scores), + "passed_total": passed_total, + "expected_total": expected_total, + "model_calls": sum(int(task["model_calls"]) for task in tasks), + "tool_calls": sum(int(task["tool_calls"]) for task in tasks), + "tool_errors": sum(int(task["tool_errors"]) for task in tasks), + "rollout_rate_h": rollout_rate_h, + "eta_s": eta_s, + "rollout_progress": completed_count / total if total else 0, + "reward_progress": rewards_done / total if total else 0, + "overall_progress": ( + (completed_count + rewards_done) / (2 * total) if total else 0 + ), + "last_completion_s": max(completed_elapsed_values, default=None), + "queue_depth": sum(task["status"] == "queued" for task in tasks), + "queue_low": ( + sum(task["status"] == "queued" for task in tasks) + < int(metadata.get("rollout_concurrency") or 1) + if continuous + else False + ), + }, + "tasks": tasks, + "timeline": timeline, + "comparison": comparison, + } + + def task_detail( + self, task_name: str, mode_name: str | None = None + ) -> dict[str, Any]: + state = self.state() + task = next( + ( + item + for item in state["tasks"] + if item["task"] == task_name + and (mode_name is None or item["mode"] == mode_name) + ), + None, + ) + if task is None: + raise KeyError(task_name) + mode = str(task["mode"]) + case_root = self.run_root / task_name / mode + accumulator = self._progress.setdefault((task_name, mode), ProgressAccumulator()) + accumulator.consume(case_root / "progress.jsonl") + team, team_trace = _team_detail(case_root) + agent = _read_json(case_root / "agent-result.json", {}) + if not isinstance(agent, dict): + agent = {} + return { + "task": task, + "recent_events": list(accumulator.recent), + "trace_events": team_trace or list(accumulator.recent), + "team": team, + "recent_text": "".join(accumulator.text_fragments)[-2_400:], + "agent_response": str(agent.get("response_text") or agent.get("error") or "")[-4_000:], + "hidden_log": _tail(case_root / "hidden-tests.log", lines=100), + "docker_build_log": _tail(case_root / "docker-build.log", lines=50), + "stdout_log": _tail(case_root / "stdout.log", lines=60), + "stderr_log": _tail(case_root / "stderr.log", lines=60), + } + + +class DashboardRegistry: + """Discover sibling runs and keep one incremental store per selected run.""" + + def __init__(self, default_run: Path) -> None: + resolved = default_run.expanduser().resolve() + if not resolved.is_dir(): + raise FileNotFoundError(f"run directory not found: {resolved}") + self.runs_root = resolved.parent + self.default_run_id = resolved.name + self._stores: dict[str, DashboardStore] = { + self.default_run_id: DashboardStore(resolved) + } + + @staticmethod + def _is_run(path: Path) -> bool: + return path.is_dir() and any( + (path / marker).exists() + for marker in ( + "run-metadata.json", + "scheduler.jsonl", + "queue.sqlite3", + "results.json", + ) + ) + + def run_ids(self) -> list[str]: + values = [path.name for path in self.runs_root.iterdir() if self._is_run(path)] + return sorted(values, reverse=True) + + def get(self, run_id: str | None = None) -> DashboardStore: + selected = run_id or self.default_run_id + if not selected or Path(selected).name != selected: + raise KeyError(selected) + path = (self.runs_root / selected).resolve() + if path.parent != self.runs_root or not self._is_run(path): + raise KeyError(selected) + store = self._stores.get(selected) + if store is None: + store = DashboardStore(path) + self._stores[selected] = store + return store + + def listing(self) -> dict[str, Any]: + runs = [] + for run_id in self.run_ids(): + path = self.runs_root / run_id + metadata = _read_json(path / "run-metadata.json", {}) + if not isinstance(metadata, dict): + metadata = {} + runs.append( + { + "id": run_id, + "provider": metadata.get("provider"), + "model": metadata.get("model"), + "queue_mode": metadata.get("queue_mode"), + "invalidated": bool(metadata.get("invalidated_at")), + } + ) + return {"default": self.default_run_id, "runs": runs} + + @staticmethod + def _campaign_id(run_id: str) -> str: + """Collapse Adaptive/Forced repetitions into one dashboard campaign.""" + return re.sub( + r"-(?:adaptive-team-v\d+|forced-team(?:-fixed)?)-pool\d+-r\d+$", + "", + run_id, + ) + + def _quick_queue_state(self, run_id: str) -> dict[str, Any] | None: + path = self.runs_root / run_id + database = path / "queue.sqlite3" + if not database.is_file(): + return None + metadata = _read_json(path / "run-metadata.json", {}) + if not isinstance(metadata, dict) or metadata.get("queue_mode") != "continuous": + return None + try: + connection = sqlite3.connect( + f"file:{database}?mode=ro", uri=True, timeout=2 + ) + connection.row_factory = sqlite3.Row + try: + count_rows = connection.execute( + "SELECT status, COUNT(*) AS count FROM cases GROUP BY status" + ).fetchall() + config = connection.execute( + "SELECT * FROM worker_config WHERE id=1" + ).fetchone() + score_row = connection.execute( + "SELECT AVG(quality_score) AS average_quality, " + "COUNT(quality_score) AS valid_rewards FROM cases " + "WHERE status='done'" + ).fetchone() + finally: + connection.close() + except (sqlite3.Error, OSError): + return None + counts = { + status: 0 + for status in ( + "queued", + "rollout", + "reward_pending", + "rewarding", + "done", + "failed", + ) + } + for row in count_rows: + counts[str(row["status"])] = int(row["count"]) + total = sum(counts.values()) + rollout_done = ( + counts["reward_pending"] + + counts["rewarding"] + + counts["done"] + + counts["failed"] + ) + rewards_done = counts["done"] + counts["failed"] + config_value = dict(config) if config is not None else {} + average_quality = score_row["average_quality"] if score_row else None + return { + "id": run_id, + "campaign": self._campaign_id(run_id), + "provider": metadata.get("provider"), + "model": metadata.get("model"), + "rollout_slots": int(config_value.get("rollout_concurrency") or 0), + "reward_slots": int(config_value.get("reward_concurrency") or 0), + "counts": counts, + "total": total, + "started": total - counts["queued"], + "rollouts_completed": rollout_done, + "rewards_completed": rewards_done, + "average_quality": average_quality, + "valid_rewards": int(score_row["valid_rewards"] or 0) if score_row else 0, + "overall_progress": ( + (rollout_done + rewards_done) / (2 * total) if total else 0 + ), + } + + def global_state(self, selected_run_id: str | None = None) -> dict[str, Any]: + """Return a low-cost global pool snapshot for sibling queue runs.""" + selected = selected_run_id or self.default_run_id + campaign = self._campaign_id(selected) + runs = [ + state + for run_id in self.run_ids() + if (state := self._quick_queue_state(run_id)) is not None + and state["campaign"] == campaign + ] + runs.sort(key=lambda item: item["id"]) + + persisted = _read_json(self.runs_root / "global-pool-state.json", {}) + if not isinstance(persisted, dict): + persisted = {} + persisted_ids = { + str(item.get("run")) + for item in persisted.get("runs", []) + if isinstance(item, dict) + } + cohort_ids = {str(item["id"]) for item in runs} + uses_persisted = bool(persisted_ids) and persisted_ids.issubset(cohort_ids) + allocated_rollout = sum(int(item["rollout_slots"]) for item in runs) + allocated_reward = sum(int(item["reward_slots"]) for item in runs) + total = sum(int(item["total"]) for item in runs) + rollout_done = sum(int(item["rollouts_completed"]) for item in runs) + rewards_done = sum(int(item["rewards_completed"]) for item in runs) + return { + "generated_at": datetime.now(timezone.utc).isoformat(), + "campaign": campaign, + "pool": { + "status": persisted.get("status") if uses_persisted else "observed", + "updated_at": persisted.get("updated_at") if uses_persisted else None, + "rollout_capacity": ( + int(persisted.get("rollout_capacity") or 0) + if uses_persisted + else allocated_rollout + ), + "reward_capacity": ( + int(persisted.get("reward_capacity") or 0) + if uses_persisted + else allocated_reward + ), + "allocated_rollout": allocated_rollout, + "allocated_reward": allocated_reward, + }, + "summary": { + "runs": len(runs), + "total": total, + "queued": sum(int(item["counts"]["queued"]) for item in runs), + "rollout_active": sum( + int(item["counts"]["rollout"]) for item in runs + ), + "reward_active": sum( + int(item["counts"]["rewarding"]) for item in runs + ), + "reward_pending": sum( + int(item["counts"]["reward_pending"]) for item in runs + ), + "rollouts_completed": rollout_done, + "rewards_completed": rewards_done, + "failed": sum(int(item["counts"]["failed"]) for item in runs), + "overall_progress": ( + (rollout_done + rewards_done) / (2 * total) if total else 0 + ), + }, + "runs": runs, + } + + +class DashboardHandler(BaseHTTPRequestHandler): + registry: DashboardRegistry + html_path: Path + + def log_message(self, format: str, *args: Any) -> None: + if getattr(self.server, "verbose", False): + super().log_message(format, *args) + + def _send_json(self, value: Any, status: HTTPStatus = HTTPStatus.OK) -> None: + payload = json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(payload))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(payload) + + def do_GET(self) -> None: # noqa: N802 + parsed = urlparse(self.path) + if parsed.path == "/": + try: + payload = self.html_path.read_bytes() + except OSError as exc: + self._send_json({"error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR) + return + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(payload))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(payload) + return + if parsed.path == "/api/state": + run_id = parse_qs(parsed.query).get("run", [""])[0] or None + try: + store = self.registry.get(run_id) + except KeyError: + self._send_json({"error": "unknown run"}, HTTPStatus.NOT_FOUND) + return + self._send_json(store.state()) + return + if parsed.path == "/api/runs": + self._send_json(self.registry.listing()) + return + if parsed.path == "/api/global": + run_id = parse_qs(parsed.query).get("run", [""])[0] or None + self._send_json(self.registry.global_state(run_id)) + return + if parsed.path == "/api/task": + query = parse_qs(parsed.query) + task = query.get("task", [""])[0] + mode = query.get("mode", [""])[0] or None + run_id = query.get("run", [""])[0] or None + try: + detail = self.registry.get(run_id).task_detail(task, mode) + except KeyError as exc: + self._send_json({"error": f"unknown run or task: {exc}"}, HTTPStatus.NOT_FOUND) + return + self._send_json(detail) + return + if parsed.path == "/api/health": + self._send_json( + {"ok": True, "run": self.registry.default_run_id} + ) + return + self._send_json({"error": "not found"}, HTTPStatus.NOT_FOUND) + + def do_POST(self) -> None: # noqa: N802 + parsed = urlparse(self.path) + if parsed.path != "/api/concurrency": + self._send_json({"error": "not found"}, HTTPStatus.NOT_FOUND) + return + self._send_json( + { + "error": ( + "per-run concurrency is read-only; change global capacity " + "through global_pool_supervisor.py" + ), + "code": "global_pool_managed", + "global_state_endpoint": "/api/global", + }, + HTTPStatus.CONFLICT, + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--run", type=Path, required=True, help="Benchmark run directory") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8765) + parser.add_argument("--html", type=Path, default=DEFAULT_HTML) + parser.add_argument("--open", action="store_true", help="Open the dashboard in a browser") + parser.add_argument("--verbose", action="store_true") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + registry = DashboardRegistry(args.run) + handler = type( + "BoundDashboardHandler", + (DashboardHandler,), + {"registry": registry, "html_path": args.html.resolve()}, + ) + server = ThreadingHTTPServer((args.host, args.port), handler) + server.verbose = args.verbose # type: ignore[attr-defined] + host, port = server.server_address[:2] + url = f"http://{host}:{port}/" + print(f"NL2Repo dashboard: {url}", flush=True) + print(f"Runs root: {registry.runs_root}", flush=True) + print(f"Default run: {registry.default_run_id}", flush=True) + if args.open: + webbrowser.open(url) + try: + server.serve_forever(poll_interval=0.5) + except KeyboardInterrupt: + pass + finally: + server.server_close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/teammate-evals/nl2repo-pilot/evaluation_queue.py b/teammate-evals/nl2repo-pilot/evaluation_queue.py new file mode 100644 index 0000000..7fcc629 --- /dev/null +++ b/teammate-evals/nl2repo-pilot/evaluation_queue.py @@ -0,0 +1,1607 @@ +#!/usr/bin/env python3 +"""Run NL2Repo evaluation as a persistent, dynamically refillable queue.""" + +from __future__ import annotations + +import argparse +import errno +import fcntl +import importlib.util +import json +import os +import signal +import shutil +import sqlite3 +import sys +import threading +import time +from concurrent.futures import Future, ThreadPoolExecutor +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable, Mapping + + +HERE = Path(__file__).resolve().parent +BENCHMARK_PATH = HERE / "benchmark.py" +SPEC = importlib.util.spec_from_file_location("nl2repo_queue_benchmark", BENCHMARK_PATH) +assert SPEC is not None and SPEC.loader is not None +benchmark = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = benchmark +SPEC.loader.exec_module(benchmark) + +QUEUE_DB = "queue.sqlite3" +GLOBAL_POOL_WORKER_ENV = "CLAWD_NL2REPO_GLOBAL_POOL_WORKER" +GLOBAL_POOL_WORKER_MARKER = "global_pool_supervisor.v1" +GLOBAL_POOL_LOCK_PATH = HERE / "runs" / "global-pool.lock" +QUEUE_STATUSES = ( + "queued", + "rollout", + "reward_pending", + "rewarding", + "done", + "failed", +) + + +def enforce_serve_launch_policy( + args: argparse.Namespace, + *, + environ: Mapping[str, str] | None = None, + lock_path: Path | None = None, + parent_pid: int | None = None, +) -> None: + """Prevent direct worker/slot controls from bypassing the global pool. + + A marker environment variable alone is intentionally insufficient: a + managed worker must be a direct child of the process recorded in the live + pilot-wide flock, and its resolved run directory must be registered there. + The check happens before ``QueueStore`` is opened so an unauthorized second + worker cannot recover/move another worker's in-flight cases. + """ + if args.command == "scale": + raise SystemExit( + "direct 'evaluation_queue.py ... scale' is disabled: the global pool " + "supervisor exclusively owns rollout/reward slot allocation. Restart " + "global_pool_supervisor.py with the desired capacities." + ) + if args.command != "serve": + return + environment = os.environ if environ is None else environ + if _is_authorized_global_pool_worker( + args.run, + environ=environment, + lock_path=GLOBAL_POOL_LOCK_PATH if lock_path is None else lock_path, + parent_pid=os.getppid() if parent_pid is None else parent_pid, + ): + return + raise SystemExit( + "direct 'evaluation_queue.py ... serve' is disabled: start queue workers " + "with global_pool_supervisor.py so rollout/reward capacity is shared. A " + "marker environment variable without the matching live supervisor lock, " + "parent PID, and registered run is not accepted." + ) + + +def _global_pool_lock_is_held(path: Path) -> bool: + """Return whether another process currently owns the pilot-wide flock.""" + try: + handle = path.open("r", encoding="utf-8") + except OSError: + return False + try: + try: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError as exc: + if exc.errno in {errno.EACCES, errno.EAGAIN}: + return True + return False + else: + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + return False + finally: + handle.close() + + +def _is_authorized_global_pool_worker( + run_root: Path, + *, + environ: Mapping[str, str], + lock_path: Path, + parent_pid: int, +) -> bool: + """Validate the supervisor-to-worker launch relationship.""" + if environ.get(GLOBAL_POOL_WORKER_ENV) != GLOBAL_POOL_WORKER_MARKER: + return False + try: + metadata = json.loads(lock_path.read_text(encoding="utf-8")) + owner_pid = int(metadata["pid"]) + registered_values = {str(value) for value in metadata["runs"]} + except (OSError, TypeError, ValueError, KeyError, json.JSONDecodeError): + return False + if owner_pid != parent_pid: + return False + resolved_run = run_root.expanduser().resolve() + # Name-only metadata is accepted solely for workers whose parent owns the + # live lock, allowing a supervisor started before the full-path metadata + # migration to restart its children without abandoning in-flight work. + if ( + str(resolved_run) not in registered_values + and resolved_run.name not in registered_values + ): + return False + return _global_pool_lock_is_held(lock_path) + + +def start_supervisor_lease_watchdog( + run_root: Path, + stop_event: threading.Event, + *, + interval_s: float = 1.0, + exit_fn: Callable[[int], Any] | None = None, +) -> threading.Thread: + """Hard-stop an orphan queue worker after its supervisor loses the flock. + + Executor threads cannot be safely cancelled while they are inside model or + sandbox clients. Exiting the child process is therefore intentional: a new + supervisor can recover the SQLite in-flight states without an orphan worker + continuing to consume global capacity or later committing duplicate output. + """ + if interval_s <= 0: + raise ValueError("supervisor watchdog interval must be positive") + terminate = _terminate_orphan_worker_tree if exit_fn is None else exit_fn + + def monitor() -> None: + consecutive_failures = 0 + while not stop_event.wait(interval_s): + valid = _is_authorized_global_pool_worker( + run_root, + environ=os.environ, + lock_path=GLOBAL_POOL_LOCK_PATH, + parent_pid=os.getppid(), + ) + if valid: + consecutive_failures = 0 + continue + # Lock metadata is updated in place to preserve the flock inode. A + # reader may catch its tiny truncate/write window, so require three + # consecutive misses before treating the lease as lost. + consecutive_failures += 1 + if consecutive_failures < 3: + continue + print( + "global pool supervisor lease lost; terminating orphan queue worker", + file=sys.stderr, + flush=True, + ) + terminate(75) + return + + thread = threading.Thread( + target=monitor, + name="global-pool-lease-watchdog", + daemon=True, + ) + thread.start() + return thread + + +def _terminate_orphan_worker_tree(exit_code: int) -> None: + """Gracefully stop a managed worker group, then enforce a hard deadline.""" + process_group = os.getpgrp() + if process_group != os.getpid(): + # New supervisors always launch each worker as a session leader. Avoid + # signaling an unrelated shell/process group if an old manual process + # somehow reaches this path. + os._exit(exit_code) + + def force_kill() -> None: + time.sleep(30) + try: + os.killpg(process_group, signal.SIGKILL) + except ProcessLookupError: + pass + + threading.Thread( + target=force_kill, + name="orphan-worker-force-kill", + daemon=True, + ).start() + try: + os.killpg(process_group, signal.SIGTERM) + except ProcessLookupError: + os._exit(exit_code) + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def has_reusable_rollout(run_root: Path, task: str, mode: str) -> bool: + """Whether a failed case can be rescored without regenerating its workspace.""" + case_root = run_root / task / mode + artifact_path = case_root / "rollout-artifact.json" + agent_path = case_root / "agent-result.json" + if not artifact_path.is_file() or not agent_path.is_file(): + return False + if not (case_root / "workspace" / "start.md").is_file(): + return False + try: + stored = benchmark._read_json(artifact_path) + agent = benchmark._read_json(agent_path) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + return False + if not isinstance(stored, dict) or not isinstance(agent, dict): + return False + outcome = str(agent.get("rollout_outcome") or "") + return not ( + outcome in {"infra_error", "harness_error"} + or agent.get("rollout_infrastructure") + or agent.get("workspace_download_error") + ) + + +class QueueStore: + """Small SQLite state machine shared by add/status/serve processes.""" + + def __init__(self, run_root: Path) -> None: + self.run_root = run_root.expanduser().resolve() + self.run_root.mkdir(parents=True, exist_ok=True) + self.path = self.run_root / QUEUE_DB + self._initialize() + + def _connect(self) -> sqlite3.Connection: + connection = sqlite3.connect(self.path, timeout=30) + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA journal_mode=WAL") + connection.execute("PRAGMA busy_timeout=30000") + return connection + + def _initialize(self) -> None: + with self._connect() as connection: + connection.execute( + """ + CREATE TABLE IF NOT EXISTS cases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task TEXT NOT NULL, + mode TEXT NOT NULL, + priority INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'queued', + attempt INTEGER NOT NULL DEFAULT 0, + enqueued_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + started_at TEXT, + rollout_finished_at TEXT, + reward_started_at TEXT, + finished_at TEXT, + quality_score REAL, + success INTEGER, + error TEXT, + UNIQUE(task, mode), + CHECK( + status IN ( + 'queued','rollout','reward_pending','rewarding','done','failed' + ) + ) + ) + """ + ) + connection.execute( + "CREATE INDEX IF NOT EXISTS cases_status_priority " + "ON cases(status, priority DESC, id ASC)" + ) + connection.execute( + """ + CREATE TABLE IF NOT EXISTS worker_config ( + id INTEGER PRIMARY KEY CHECK(id = 1), + rollout_concurrency INTEGER NOT NULL, + reward_concurrency INTEGER NOT NULL, + max_rollout_concurrency INTEGER NOT NULL, + max_reward_concurrency INTEGER NOT NULL, + updated_at TEXT NOT NULL, + CHECK(rollout_concurrency >= 0), + CHECK(reward_concurrency >= 0), + CHECK(max_rollout_concurrency >= 1), + CHECK(max_reward_concurrency >= 1), + CHECK(rollout_concurrency <= max_rollout_concurrency), + CHECK(reward_concurrency <= max_reward_concurrency) + ) + """ + ) + + def initialize_concurrency( + self, + rollout: int, + reward: int, + *, + max_rollout: int, + max_reward: int, + ) -> dict[str, Any]: + """Initialize dynamic limits while preserving an existing desired size.""" + if rollout < 0 or reward < 0: + raise ValueError("initial concurrency must be non-negative") + if max_rollout < 1 or max_reward < 1: + raise ValueError("maximum concurrency must be positive") + if rollout > max_rollout or reward > max_reward: + raise ValueError("initial concurrency cannot exceed worker capacity") + now = utc_now() + with self._connect() as connection: + connection.execute( + """ + INSERT OR IGNORE INTO worker_config + (id, rollout_concurrency, reward_concurrency, + max_rollout_concurrency, max_reward_concurrency, updated_at) + VALUES (1, ?, ?, ?, ?, ?) + """, + (rollout, reward, max_rollout, max_reward, now), + ) + connection.execute( + """ + UPDATE worker_config + SET rollout_concurrency=MIN(rollout_concurrency, ?), + reward_concurrency=MIN(reward_concurrency, ?), + max_rollout_concurrency=?, max_reward_concurrency=?, updated_at=? + WHERE id=1 + """, + (max_rollout, max_reward, max_rollout, max_reward, now), + ) + configured = self.concurrency() + assert configured is not None + return configured + + def concurrency(self) -> dict[str, Any] | None: + with self._connect() as connection: + row = connection.execute( + "SELECT * FROM worker_config WHERE id=1" + ).fetchone() + return dict(row) if row is not None else None + + def set_concurrency( + self, + *, + rollout: int | None = None, + reward: int | None = None, + ) -> dict[str, Any]: + """Persist desired pool sizes; zero pauses new work for that pool.""" + if rollout is None and reward is None: + raise ValueError("provide rollout or reward concurrency") + if rollout is not None and rollout < 0: + raise ValueError("rollout concurrency must be non-negative") + if reward is not None and reward < 0: + raise ValueError("reward concurrency must be non-negative") + connection = self._connect() + try: + connection.execute("BEGIN IMMEDIATE") + row = connection.execute( + "SELECT * FROM worker_config WHERE id=1" + ).fetchone() + if row is None: + raise RuntimeError("dynamic worker configuration is not initialized") + desired_rollout = int(row["rollout_concurrency"]) if rollout is None else rollout + desired_reward = int(row["reward_concurrency"]) if reward is None else reward + if desired_rollout > int(row["max_rollout_concurrency"]): + raise ValueError( + f"rollout concurrency exceeds worker capacity " + f"{row['max_rollout_concurrency']}" + ) + if desired_reward > int(row["max_reward_concurrency"]): + raise ValueError( + f"reward concurrency exceeds worker capacity " + f"{row['max_reward_concurrency']}" + ) + connection.execute( + """ + UPDATE worker_config + SET rollout_concurrency=?, reward_concurrency=?, updated_at=? + WHERE id=1 + """, + (desired_rollout, desired_reward, utc_now()), + ) + connection.commit() + except Exception: + connection.rollback() + raise + finally: + connection.close() + configured = self.concurrency() + assert configured is not None + return configured + + def enqueue( + self, task_names: list[str], mode: str, *, priority: int = 0 + ) -> tuple[list[str], list[str]]: + added: list[str] = [] + skipped: list[str] = [] + now = utc_now() + with self._connect() as connection: + for task in task_names: + cursor = connection.execute( + """ + INSERT OR IGNORE INTO cases + (task, mode, priority, status, enqueued_at, updated_at) + VALUES (?, ?, ?, 'queued', ?, ?) + """, + (task, mode, priority, now, now), + ) + (added if cursor.rowcount else skipped).append(task) + return added, skipped + + def retry(self, task_names: list[str], mode: str) -> tuple[list[str], list[str]]: + """Retry terminal cases without exposing an unarchived rollout as queued. + + SQLite's write transaction is deliberately held across the filesystem + rename. A live worker attempting to claim the case therefore cannot + observe ``queued`` until the previous attempt is safely archived. + """ + retried: list[str] = [] + missing: list[str] = [] + for task in task_names: + connection = self._connect() + case_root = self.run_root / task / mode + archive: Path | None = None + try: + connection.execute("BEGIN IMMEDIATE") + row = connection.execute( + "SELECT status, attempt FROM cases WHERE task=? AND mode=? " + "AND status IN ('done','failed')", + (task, mode), + ).fetchone() + if row is None: + connection.rollback() + missing.append(task) + continue + resume_reward = bool( + row["status"] == "failed" + and has_reusable_rollout(self.run_root, task, mode) + ) + if resume_reward: + cursor = connection.execute( + """ + UPDATE cases + SET status='reward_pending', updated_at=?, reward_started_at=NULL, + finished_at=NULL, quality_score=NULL, success=NULL, error=NULL + WHERE task=? AND mode=? AND status='failed' + """, + (utc_now(), task, mode), + ) + else: + if case_root.exists(): + archive = rollout_attempt_archive_path( + self.run_root, + {"task": task, "mode": mode, "attempt": row["attempt"]}, + label="manual-retry", + ) + archive.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(case_root), str(archive)) + cursor = connection.execute( + """ + UPDATE cases + SET status='queued', updated_at=?, started_at=NULL, + rollout_finished_at=NULL, reward_started_at=NULL, + finished_at=NULL, quality_score=NULL, success=NULL, error=NULL + WHERE task=? AND mode=? AND status IN ('done','failed') + """, + (utc_now(), task, mode), + ) + if cursor.rowcount != 1: + raise RuntimeError( + f"retry state changed unexpectedly for {task}/{mode}" + ) + connection.commit() + retried.append(task) + except Exception: + connection.rollback() + # A same-filesystem rename is atomic. If anything after it + # fails, restore the terminal attempt before surfacing the + # error so a later retry remains safe. + if ( + archive is not None + and archive.exists() + and not case_root.exists() + ): + case_root.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(archive), str(case_root)) + raise + finally: + connection.close() + return retried, missing + + def recover_interrupted(self) -> dict[str, int]: + """Return interrupted rollouts to queue and interrupted rewards to reward queue.""" + now = utc_now() + with self._connect() as connection: + interrupted_rollouts = connection.execute( + "SELECT task, mode, attempt FROM cases WHERE status='rollout'" + ).fetchall() + rollout = connection.execute( + """ + UPDATE cases + SET status='queued', updated_at=?, error='runner restarted during rollout' + WHERE status='rollout' + """, + (now,), + ).rowcount + rewarding = connection.execute( + """ + UPDATE cases + SET status='reward_pending', updated_at=?, + error='runner restarted during reward' + WHERE status='rewarding' + """, + (now,), + ).rowcount + archive_stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + for row in interrupted_rollouts: + case_root = self.run_root / str(row["task"]) / str(row["mode"]) + if not case_root.exists(): + continue + archive = ( + self.run_root + / "_attempts" + / str(row["task"]) + / str(row["mode"]) + / f"attempt-{int(row['attempt'])}-interrupted-{archive_stamp}" + ) + archive.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(case_root), str(archive)) + return {"rollout": rollout, "reward": rewarding} + + def salvage_completed_rollouts( + self, *, exclude_case_ids: set[int] | None = None + ) -> list[dict[str, Any]]: + """Promote completed rollouts left in-flight by a draining predecessor.""" + excluded = exclude_case_ids or set() + with self._connect() as connection: + rows = connection.execute( + "SELECT * FROM cases WHERE status='rollout' ORDER BY id" + ).fetchall() + salvaged: list[dict[str, Any]] = [] + for row in rows: + case = dict(row) + case_id = int(case["id"]) + if case_id in excluded: + continue + case_root = self.run_root / str(case["task"]) / str(case["mode"]) + workspace = case_root / "workspace" + agent_path = case_root / "agent-result.json" + start_path = workspace / "start.md" + if not agent_path.is_file() or not start_path.is_file(): + continue + try: + agent = benchmark._read_json(agent_path) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + continue + if not isinstance(agent, dict): + continue + started_at = datetime.fromisoformat(str(case["started_at"])) + elapsed = max( + 0.0, (datetime.now(timezone.utc) - started_at).total_seconds() + ) + benchmark._write_json( + artifact_path(self.run_root, str(case["task"]), str(case["mode"])), + { + "start_hash": benchmark._hash_file(start_path), + "agent_elapsed_s": elapsed, + "agent_timed_out": False, + "agent_returncode": 0 if agent.get("ok") else 1, + "salvaged": True, + }, + ) + now = utc_now() + with self._connect() as connection: + updated = connection.execute( + """ + UPDATE cases + SET status='reward_pending', updated_at=?, rollout_finished_at=?, + error='rollout salvaged during live worker handoff' + WHERE id=? AND status='rollout' + """, + (now, now, case_id), + ).rowcount + if updated: + salvaged.append(case) + return salvaged + + def claim(self, from_status: str, to_status: str, limit: int) -> list[dict[str, Any]]: + if from_status not in QUEUE_STATUSES or to_status not in QUEUE_STATUSES: + raise ValueError("invalid queue status") + if limit < 1: + return [] + now = utc_now() + connection = self._connect() + try: + connection.execute("BEGIN IMMEDIATE") + rows = connection.execute( + "SELECT * FROM cases WHERE status=? " + "ORDER BY priority DESC, id ASC LIMIT ?", + (from_status, limit), + ).fetchall() + ids = [int(row["id"]) for row in rows] + if ids: + placeholders = ",".join("?" for _ in ids) + assignments = "status=?, updated_at=?" + values: list[Any] = [to_status, now] + if to_status == "rollout": + assignments += ", started_at=?, attempt=attempt+1, error=NULL" + values.append(now) + elif to_status == "rewarding": + assignments += ", reward_started_at=?, error=NULL" + values.append(now) + connection.execute( + f"UPDATE cases SET {assignments} WHERE id IN ({placeholders})", + (*values, *ids), + ) + refreshed = connection.execute( + f"SELECT * FROM cases WHERE id IN ({placeholders})", + ids, + ).fetchall() + refreshed_by_id = {int(row["id"]): row for row in refreshed} + rows = [refreshed_by_id[case_id] for case_id in ids] + connection.commit() + return [dict(row) for row in rows] + except Exception: + connection.rollback() + raise + finally: + connection.close() + + def mark_rollout_complete(self, case_id: int) -> None: + now = utc_now() + with self._connect() as connection: + connection.execute( + """ + UPDATE cases SET status='reward_pending', updated_at=?, + rollout_finished_at=?, error=NULL WHERE id=? + """, + (now, now, case_id), + ) + + def mark_rollout_retry(self, case_id: int, error: BaseException | str) -> bool: + """Return a failed infrastructure rollout to the rollout queue. + + The attempt counter is intentionally retained. It is incremented only + when the case is claimed again, which makes it a durable retry budget + across worker restarts. + """ + now = utc_now() + message = str(error) + with self._connect() as connection: + updated = connection.execute( + """ + UPDATE cases SET status='queued', updated_at=?, started_at=NULL, + rollout_finished_at=NULL, reward_started_at=NULL, + finished_at=NULL, quality_score=NULL, success=NULL, error=? + WHERE id=? AND status IN ('rollout','rewarding') + """, + (now, message[-4000:], case_id), + ).rowcount + return bool(updated) + + def mark_done(self, case_id: int, result: dict[str, Any]) -> None: + if reward_result_error(result) is not None: + raise ValueError("cannot mark a case done without a valid reward score") + score = reward_numeric_score(result) + assert score is not None + now = utc_now() + with self._connect() as connection: + connection.execute( + """ + UPDATE cases SET status='done', updated_at=?, finished_at=?, + quality_score=?, success=?, error=NULL WHERE id=? + """, + ( + now, + now, + score, + int(bool(result.get("success"))), + case_id, + ), + ) + + def mark_failed(self, case_id: int, error: BaseException | str) -> None: + now = utc_now() + message = str(error) + with self._connect() as connection: + connection.execute( + """ + UPDATE cases SET status='failed', updated_at=?, finished_at=?, + quality_score=NULL, success=0, error=? WHERE id=? + """, + (now, now, message[-4000:], case_id), + ) + + def cases(self) -> list[dict[str, Any]]: + with self._connect() as connection: + rows = connection.execute( + "SELECT * FROM cases ORDER BY priority DESC, id ASC" + ).fetchall() + return [dict(row) for row in rows] + + def counts(self) -> dict[str, int]: + values = {status: 0 for status in QUEUE_STATUSES} + with self._connect() as connection: + rows = connection.execute( + "SELECT status, COUNT(*) AS count FROM cases GROUP BY status" + ).fetchall() + for row in rows: + values[str(row["status"])] = int(row["count"]) + values["total"] = sum(values.values()) + return values + + +def artifact_path(run_root: Path, task_name: str, mode: str) -> Path: + return run_root / task_name / mode / "rollout-artifact.json" + + +def persist_artifact(artifact: Any) -> None: + agent_path = artifact.case_root / "agent-result.json" + if not agent_path.is_file(): + raise FileNotFoundError( + f"refusing to persist incomplete rollout without {agent_path.name}: " + f"{artifact.case_root}" + ) + benchmark._write_json( + artifact.case_root / "rollout-artifact.json", + { + "start_hash": artifact.start_hash, + "agent_elapsed_s": artifact.agent_elapsed_s, + "agent_timed_out": artifact.agent_timed_out, + "agent_returncode": artifact.agent_returncode, + }, + ) + + +def restore_artifact(run_root: Path, task: dict[str, Any], mode: str) -> Any: + case_root = run_root / task["id"] / mode + stored = benchmark._read_json(case_root / "rollout-artifact.json") + agent_path = case_root / "agent-result.json" + if not isinstance(stored, dict) or not agent_path.is_file(): + raise FileNotFoundError(f"persisted rollout artifact is incomplete: {case_root}") + return benchmark.RolloutArtifact( + task=task, + mode=mode, + case_root=case_root, + workspace=case_root / "workspace", + start_hash=str(stored["start_hash"]), + agent=benchmark._read_json(agent_path), + agent_elapsed_s=float(stored.get("agent_elapsed_s", 0)), + agent_timed_out=bool(stored.get("agent_timed_out")), + agent_returncode=int(stored.get("agent_returncode", 1)), + ) + + +def rollout_result_error(artifact: Any) -> tuple[str, bool] | None: + """Return ``(reason, retryable)`` for non-scorable rollout outcomes.""" + agent = getattr(artifact, "agent", None) + if not isinstance(agent, dict): + return None + outcome = str(agent.get("rollout_outcome") or "") + infrastructure = bool(agent.get("rollout_infrastructure")) + if outcome not in {"infra_error", "harness_error"} and not infrastructure: + return None + reason = str( + agent.get("workspace_download_error") + or agent.get("error") + or agent.get("failure_reason") + or outcome + or "rollout infrastructure failure" + ) + # Harness failures are terminal even if an older result accidentally set + # rollout_infrastructure=true. Only an explicitly retryable infrastructure + # outcome is allowed to consume another rollout attempt. + retryable = bool( + outcome == "infra_error" and agent.get("rollout_retryable") + ) + return reason, retryable + + +def retryable_rollout_exception(error: BaseException) -> bool: + """Conservatively identify transient infrastructure exceptions. + + Explicit structured metadata wins. Otherwise only network/timeout error + types, transient network errno values, and well-known infrastructure + messages are accepted. Broad ``OSError`` and programming exceptions are + intentionally not retried. + """ + pending: list[BaseException] = [error] + seen: set[int] = set() + transient_errnos = { + errno.ECONNABORTED, + errno.ECONNREFUSED, + errno.ECONNRESET, + errno.EHOSTUNREACH, + errno.ENETDOWN, + errno.ENETUNREACH, + errno.EPIPE, + errno.ETIMEDOUT, + } + markers = ( + "connection reset", + "connection refused", + "connection error", + "service unavailable", + "bad gateway", + "gateway timeout", + "rate limit", + "too many requests", + "sandbox unavailable", + "deployment unavailable", + "ags backend", + "image is still preparing", + "pending request was cancelled", + ) + + while pending: + current = pending.pop() + if id(current) in seen: + continue + seen.add(id(current)) + if isinstance(current, (ConnectionError, TimeoutError)): + return True + if isinstance(current, OSError) and current.errno in transient_errnos: + return True + + metadata: list[Any] = [current] + metadata.extend( + getattr(current, name, None) + for name in ("payload", "details", "metadata", "result") + ) + for value in metadata: + if isinstance(value, dict): + retryable = value.get("retryable") is True + infrastructure = bool( + value.get("is_infrastructure") + or value.get("infrastructure") + or value.get("failure_domain") == "infrastructure" + or value.get("rollout_outcome") == "infra_error" + ) + else: + retryable = getattr(value, "retryable", None) is True + infrastructure = bool( + getattr(value, "is_infrastructure", False) + or getattr(value, "infrastructure", False) + or getattr(value, "failure_domain", None) == "infrastructure" + or getattr(value, "rollout_outcome", None) == "infra_error" + ) + class_name = type(value).__name__.casefold() + infrastructure = infrastructure or ( + retryable and "infra" in class_name + ) + if retryable and infrastructure: + return True + + if any(marker in str(current).casefold() for marker in markers): + return True + for nested in ( + current.__cause__, + current.__context__, + getattr(current, "cause", None), + getattr(current, "original_error", None), + ): + if isinstance(nested, BaseException): + pending.append(nested) + pending.extend(arg for arg in current.args if isinstance(arg, BaseException)) + return False + + +def reward_numeric_score(result: dict[str, Any]) -> int | float | None: + for key in ("code_quality_score", "quality_score"): + value = result.get(key) + if isinstance(value, (int, float)) and not isinstance(value, bool): + return value + return None + + +def reward_result_error(result: Any) -> str | None: + """Explain why a reward result cannot be a successful queue terminal state.""" + if not isinstance(result, dict): + return "reward returned a non-object result" + score = reward_numeric_score(result) + numeric_score = score is not None + if "reward_outcome" not in result and "reward_score_valid" not in result: + return None if numeric_score else "reward did not return a numeric quality score" + outcome = str(result.get("reward_outcome") or "pending") + score_valid = result.get("reward_score_valid") + if score_valid is None: + score_valid = outcome == "scored" and numeric_score + if outcome == "scored" and bool(score_valid) and numeric_score: + return None + hidden = result.get("hidden_tests") + detail = "" + if isinstance(hidden, dict): + detail = str(hidden.get("error") or hidden.get("skip_reason") or "") + reason = f"reward_outcome={outcome}; reward_score_valid={bool(score_valid)}" + return f"{reason}: {detail}" if detail else reason + + +def rollout_attempt_archive_path( + run_root: Path, + case: dict[str, Any], + *, + label: str = "infra-retry", +) -> Path: + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") + return ( + run_root + / "_attempts" + / str(case["task"]) + / str(case["mode"]) + / f"attempt-{int(case['attempt'])}-{label}-{stamp}" + ) + + +def archive_rollout_attempt( + run_root: Path, + case: dict[str, Any], + *, + label: str = "infra-retry", +) -> Path | None: + """Archive one rollout attempt before its workspace is generated again.""" + case_root = run_root / str(case["task"]) / str(case["mode"]) + if not case_root.exists(): + return None + archive = rollout_attempt_archive_path(run_root, case, label=label) + archive.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(case_root), str(archive)) + return archive + + +def score_with_infrastructure_retries( + score_fn: Callable[[], dict[str, Any]], + *, + attempts: int, + delay_s: float, + sleep_fn: Callable[[float], None] = time.sleep, + on_retry: Callable[[int, str], None] | None = None, +) -> dict[str, Any]: + """Retry scorer exceptions and explicit hidden-test infrastructure errors.""" + if attempts < 1: + raise ValueError("reward attempts must be positive") + for attempt in range(1, attempts + 1): + try: + result = score_fn() + hidden = result.get("hidden_tests") + error = str(hidden.get("error") or "") if isinstance(hidden, dict) else "" + if not error or attempt == attempts: + return result + except Exception as exc: + if attempt == attempts: + raise + error = f"{type(exc).__name__}: {exc}" + if on_retry is not None: + on_retry(attempt, error) + sleep_fn(delay_s) + raise AssertionError("unreachable") + + +def allocate_global_slots( + snapshots: list[dict[str, int]], + capacity: int, + *, + active_key: str, + pending_key: str, +) -> list[int]: + """Fairly allocate one pool while preserving already-active work. + + Existing active work consumes capacity first because shrinking a desired + concurrency does not preempt an in-flight rollout/reward. Remaining slots + water-fill the least-allocated run that still has pending demand, so one + large first queue cannot starve every later run. + """ + if capacity < 0: + raise ValueError("global capacity must be non-negative") + active = [max(0, int(counts.get(active_key, 0))) for counts in snapshots] + demand = [ + active[index] + max(0, int(counts.get(pending_key, 0))) + for index, counts in enumerate(snapshots) + ] + + if sum(active) <= capacity: + allocations = active.copy() + remaining = capacity - sum(allocations) + limits = demand + else: + # The supervisor may inherit more active work than its new capacity. + # It cannot preempt that work, but lower fair desired values prevent any + # run from claiming replacements until the aggregate drains. + allocations = [0] * len(snapshots) + remaining = capacity + limits = active + + while remaining: + eligible = [ + index + for index, limit in enumerate(limits) + if allocations[index] < limit + ] + if not eligible: + break + target = min(eligible, key=lambda index: (allocations[index], index)) + allocations[target] += 1 + remaining -= 1 + return allocations + + +def run_queue_loop( + store: QueueStore, + task_loader: Callable[[str], dict[str, Any]], + rollout_fn: Callable[[dict[str, Any], str], Any], + reward_fn: Callable[[Any], dict[str, Any]], + *, + rollout_concurrency: int, + reward_concurrency: int, + max_rollout_concurrency: int | None = None, + max_reward_concurrency: int | None = None, + concurrency_loader: Callable[[], dict[str, Any] | None] | None = None, + recover_interrupted: bool = True, + adopt_external_inflight: bool = False, + stop_event: threading.Event, + poll_interval_s: float = 1.0, + stop_when_empty: bool = False, + rollout_attempts: int = 3, + failure_fn: Callable[[dict[str, Any], str, str, Exception], dict[str, Any]] | None = None, + on_event: Callable[[dict[str, Any]], None] | None = None, + on_resize: Callable[[dict[str, int]], None] | None = None, +) -> None: + """Keep both pools full while cases can be appended from another process.""" + if rollout_concurrency < 0 or reward_concurrency < 0: + raise ValueError("rollout and reward concurrency must be non-negative") + if rollout_attempts < 1: + raise ValueError("rollout attempts must be positive") + max_rollout = max_rollout_concurrency or rollout_concurrency + max_reward = max_reward_concurrency or reward_concurrency + if max_rollout < 1 or max_reward < 1: + raise ValueError("maximum concurrency must be positive") + if max_rollout < rollout_concurrency or max_reward < reward_concurrency: + raise ValueError("maximum concurrency cannot be below initial concurrency") + desired_rollout = rollout_concurrency + desired_reward = reward_concurrency + started = time.monotonic() + event_lock = threading.Lock() + + def emit(name: str, task: dict[str, Any], mode: str, **extra: Any) -> None: + if on_event is None: + return + value = { + "event": name, + "task": task["id"], + "mode": mode, + "elapsed_s": round(time.monotonic() - started, 3), + "recorded_at": utc_now(), + **extra, + } + with event_lock: + on_event(value) + + rollout_futures: dict[Future[Any], tuple[dict[str, Any], dict[str, Any]]] = {} + reward_futures: dict[Future[Any], tuple[dict[str, Any], dict[str, Any]]] = {} + if recover_interrupted: + store.recover_interrupted() + + def do_rollout(case: dict[str, Any], task: dict[str, Any]) -> Any: + emit("rollout.started", task, str(case["mode"]), queue_id=case["id"]) + return rollout_fn(task, str(case["mode"])) + + def do_reward(case: dict[str, Any], task: dict[str, Any]) -> dict[str, Any]: + emit("reward.started", task, str(case["mode"]), queue_id=case["id"]) + artifact = restore_artifact(store.run_root, task, str(case["mode"])) + return reward_fn(artifact) + + with ( + ThreadPoolExecutor( + max_workers=max_rollout, thread_name_prefix="queue-rollout" + ) as rollout_pool, + ThreadPoolExecutor( + max_workers=max_reward, thread_name_prefix="queue-reward" + ) as reward_pool, + ): + while not stop_event.is_set(): + if concurrency_loader is not None: + configured = concurrency_loader() + if configured is not None: + next_rollout = int(configured["rollout_concurrency"]) + next_reward = int(configured["reward_concurrency"]) + if not 0 <= next_rollout <= max_rollout: + raise ValueError("dynamic rollout concurrency is outside worker capacity") + if not 0 <= next_reward <= max_reward: + raise ValueError("dynamic reward concurrency is outside worker capacity") + if (next_rollout, next_reward) != (desired_rollout, desired_reward): + desired_rollout, desired_reward = next_rollout, next_reward + if on_resize is not None: + on_resize( + { + "rollout_concurrency": desired_rollout, + "reward_concurrency": desired_reward, + "rollout_active": len(rollout_futures), + "reward_active": len(reward_futures), + } + ) + if adopt_external_inflight: + owned_ids = { + int(case["id"]) for case, _task in rollout_futures.values() + } + for case in store.salvage_completed_rollouts( + exclude_case_ids=owned_ids + ): + task = task_loader(str(case["task"])) + emit( + "rollout.salvaged", + task, + str(case["mode"]), + queue_id=case["id"], + ) + for future in [item for item in rollout_futures if item.done()]: + case, task = rollout_futures.pop(future) + try: + artifact = future.result() + persist_artifact(artifact) + rollout_error = rollout_result_error(artifact) + if rollout_error is None: + store.mark_rollout_complete(int(case["id"])) + emit( + "rollout.completed", task, str(case["mode"]), + queue_id=case["id"], + ) + else: + reason, retryable = rollout_error + attempt = int(case["attempt"]) + if retryable and attempt < rollout_attempts: + archive_rollout_attempt(store.run_root, case) + store.mark_rollout_retry(int(case["id"]), reason) + emit( + "rollout.requeued", task, str(case["mode"]), + queue_id=case["id"], attempt=attempt, + max_attempts=rollout_attempts, error=reason, + ) + else: + store.mark_failed(int(case["id"]), reason) + emit( + "rollout.failed", task, str(case["mode"]), + queue_id=case["id"], + error_type=( + "InfrastructureError" if retryable + else "HarnessError" + ), + attempt=attempt, max_attempts=rollout_attempts, + ) + except Exception as exc: + if failure_fn is not None: + failure_fn(task, str(case["mode"]), "rollout", exc) + attempt = int(case["attempt"]) + if ( + retryable_rollout_exception(exc) + and attempt < rollout_attempts + ): + archive_rollout_attempt( + store.run_root, case, label="exception-retry" + ) + store.mark_rollout_retry(int(case["id"]), exc) + emit( + "rollout.requeued", task, str(case["mode"]), + queue_id=case["id"], attempt=attempt, + max_attempts=rollout_attempts, error=str(exc), + error_type=type(exc).__name__, + ) + else: + store.mark_failed(int(case["id"]), exc) + emit( + "rollout.failed", task, str(case["mode"]), + queue_id=case["id"], error_type=type(exc).__name__, + attempt=attempt, max_attempts=rollout_attempts, + ) + + for future in [item for item in reward_futures if item.done()]: + case, task = reward_futures.pop(future) + try: + result = future.result() + invalid_reward = reward_result_error(result) + if invalid_reward is None: + store.mark_done(int(case["id"]), result) + emit( + "reward.completed", task, str(case["mode"]), + queue_id=case["id"], quality_score=result.get("quality_score"), + success=bool(result.get("success")), + ) + else: + store.mark_failed(int(case["id"]), invalid_reward) + emit( + "reward.failed", task, str(case["mode"]), + queue_id=case["id"], error_type="InvalidRewardResult", + reward_outcome=result.get("reward_outcome"), + retryable=bool(result.get("retryable")), + ) + except Exception as exc: + if failure_fn is not None: + failure_fn(task, str(case["mode"]), "reward", exc) + store.mark_failed(int(case["id"]), exc) + emit( + "reward.failed", task, str(case["mode"]), + queue_id=case["id"], error_type=type(exc).__name__, + ) + + active_counts = store.counts() if adopt_external_inflight else None + reward_slots = desired_reward - ( + active_counts["rewarding"] + if active_counts is not None + else len(reward_futures) + ) + for case in store.claim("reward_pending", "rewarding", reward_slots): + try: + task = task_loader(str(case["task"])) + except Exception as exc: + store.mark_failed(int(case["id"]), exc) + continue + future = reward_pool.submit(do_reward, case, task) + reward_futures[future] = (case, task) + + active_counts = store.counts() if adopt_external_inflight else None + rollout_slots = desired_rollout - ( + active_counts["rollout"] + if active_counts is not None + else len(rollout_futures) + ) + for case in store.claim("queued", "rollout", rollout_slots): + try: + task = task_loader(str(case["task"])) + except Exception as exc: + store.mark_failed(int(case["id"]), exc) + continue + future = rollout_pool.submit(do_rollout, case, task) + rollout_futures[future] = (case, task) + + counts = store.counts() + if ( + stop_when_empty + and not rollout_futures + and not reward_futures + and not counts["queued"] + and not counts["reward_pending"] + and not counts["rollout"] + and not counts["rewarding"] + ): + break + stop_event.wait(poll_interval_s) + + +def ensure_metadata(run_root: Path, values: dict[str, Any]) -> dict[str, Any]: + path = run_root / "run-metadata.json" + current: dict[str, Any] = {} + if path.is_file(): + loaded = benchmark._read_json(path) + if isinstance(loaded, dict): + current = loaded + if not current.get("started_at"): + current["started_at"] = utc_now() + current.update(values) + current.update( + { + "schema_version": 2, + "queue_mode": "continuous", + "run_id": current.get("run_id") or run_root.name, + "queue_db": QUEUE_DB, + "updated_at": utc_now(), + } + ) + benchmark._write_json(path, current) + return current + + +def resolve_task_names(args: argparse.Namespace, upstream_root: Path) -> list[str]: + if args.task: + names = list(dict.fromkeys(args.task)) + elif args.task_set == "qwen32": + names = benchmark.select_task_subset(benchmark.list_tasks(upstream_root)) + elif args.task_set == "remaining-qwen32": + all_tasks = benchmark.list_tasks(upstream_root) + selected = set(benchmark.select_task_subset(all_tasks)) + names = [task["id"] for task in all_tasks if task["id"] not in selected] + elif args.task_set == "all": + names = [task["id"] for task in benchmark.list_tasks(upstream_root)] + else: + raise ValueError("provide --task or --task-set") + for name in names: + benchmark.load_task(upstream_root, name) + return names + + +def add_common_task_args(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--task", action="append", help="Task ID; repeat to enqueue several") + parser.add_argument( + "--task-set", choices=("qwen32", "remaining-qwen32", "all") + ) + parser.add_argument( + "--mode", + choices=("solo", "adaptive", "adaptive-team-v2", "forced-team"), + default="adaptive", + ) + parser.add_argument("--upstream-root", type=Path) + parser.add_argument("--cache-root", type=Path) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--run", type=Path, required=True, help="Persistent queue run directory") + subparsers = parser.add_subparsers(dest="command", required=True) + + add_parser = subparsers.add_parser("add", help="Append validated cases to the live queue") + add_common_task_args(add_parser) + add_parser.add_argument("--priority", type=int, default=0) + + retry_parser = subparsers.add_parser("retry", help="Requeue completed or failed cases") + add_common_task_args(retry_parser) + + subparsers.add_parser("status", help="Print queue counts and cases") + + scale_parser = subparsers.add_parser( + "scale", help="Change live rollout/reward slots without restarting workers" + ) + scale_parser.add_argument("--rollout-concurrency", type=int) + scale_parser.add_argument("--reward-concurrency", type=int) + + serve = subparsers.add_parser("serve", help="Run persistent rollout and reward workers") + serve.add_argument("--provider", default="qwen") + serve.add_argument("--model", default="ms-mnhdj86z") + serve.add_argument("--max-turns", type=int, default=300) + serve.add_argument("--teammate-max-turns", type=int, default=160) + serve.add_argument("--teammate-min-timeout", type=float, default=900.0) + serve.add_argument("--max-output-tokens", type=int, default=16384) + serve.add_argument("--agent-timeout", type=float, default=7200) + serve.add_argument("--score-timeout", type=float, default=1200) + serve.add_argument("--rollout-concurrency", type=int, default=8) + serve.add_argument("--reward-concurrency", type=int, default=4) + serve.add_argument("--max-rollout-concurrency", type=int, default=64) + serve.add_argument("--max-reward-concurrency", type=int, default=16) + serve.add_argument("--execution-backend", choices=("local", "ags"), default="ags") + serve.add_argument("--score-backend", choices=("docker", "ags"), default="docker") + serve.add_argument("--upstream-root", type=Path) + serve.add_argument("--cache-root", type=Path) + serve.add_argument("--ags-env-file", type=Path) + serve.add_argument("--ags-timeout", default="3h") + serve.add_argument("--ags-cpu", default="2") + serve.add_argument("--ags-memory", default="4Gi") + serve.add_argument("--ags-score-tool-id") + serve.add_argument("--ags-image-template", default=benchmark.AGS_IMAGE_TEMPLATE) + serve.add_argument("--keep-image", action="store_true") + serve.add_argument("--stream", action=argparse.BooleanOptionalAction, default=True) + serve.add_argument("--poll-interval", type=float, default=1.0) + serve.add_argument("--rollout-attempts", type=int, default=3) + serve.add_argument("--reward-attempts", type=int, default=3) + serve.add_argument("--reward-retry-delay", type=float, default=5.0) + serve.add_argument( + "--start-after", + type=Path, + help="Wait for another run directory's results.json before claiming work", + ) + serve.add_argument("--stop-when-empty", action="store_true", help=argparse.SUPPRESS) + serve.add_argument("--adopt-inflight", action="store_true", help=argparse.SUPPRESS) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + enforce_serve_launch_policy(args) + run_root = args.run.resolve() + store = QueueStore(run_root) + + if args.command == "status": + print( + json.dumps( + { + "counts": store.counts(), + "concurrency": store.concurrency(), + "cases": store.cases(), + }, + indent=2, + ) + ) + return 0 + + if args.command == "scale": + try: + configured = store.set_concurrency( + rollout=args.rollout_concurrency, + reward=args.reward_concurrency, + ) + except (RuntimeError, ValueError) as exc: + raise SystemExit(str(exc)) from exc + ensure_metadata( + run_root, + { + "rollout_concurrency": configured["rollout_concurrency"], + "reward_concurrency": configured["reward_concurrency"], + }, + ) + print(json.dumps(configured, indent=2)) + return 0 + + if args.command in {"add", "retry"}: + upstream_root = benchmark.resolve_upstream(args.upstream_root, cache_root=args.cache_root) + try: + task_names = resolve_task_names(args, upstream_root) + except ValueError as exc: + raise SystemExit(str(exc)) from exc + ensure_metadata(run_root, {}) + if args.command == "add": + changed, unchanged = store.enqueue(task_names, args.mode, priority=args.priority) + print(f"queued {len(changed)} case(s): {', '.join(changed) or '-'}") + if unchanged: + print(f"already present {len(unchanged)}: {', '.join(unchanged)}") + else: + changed, unchanged = store.retry(task_names, args.mode) + print(f"requeued {len(changed)} case(s): {', '.join(changed) or '-'}") + if unchanged: + print(f"not retryable {len(unchanged)}: {', '.join(unchanged)}") + return 0 + + if args.rollout_concurrency < 0 or args.reward_concurrency < 0: + raise SystemExit("concurrency must be non-negative") + if ( + args.max_rollout_concurrency < args.rollout_concurrency + or args.max_reward_concurrency < args.reward_concurrency + ): + raise SystemExit("maximum concurrency must cover initial concurrency") + if args.rollout_attempts < 1: + raise SystemExit("rollout attempts must be positive") + if args.reward_attempts < 1 or args.reward_retry_delay < 0: + raise SystemExit("reward retry settings must be non-negative with positive attempts") + upstream_root = benchmark.resolve_upstream(args.upstream_root, cache_root=args.cache_root) + ags_env_file = args.ags_env_file.resolve() if args.ags_env_file else None + configured = store.initialize_concurrency( + args.rollout_concurrency, + args.reward_concurrency, + max_rollout=args.max_rollout_concurrency, + max_reward=args.max_reward_concurrency, + ) + ensure_metadata( + run_root, + { + "provider": args.provider, + "model": args.model, + "execution_backend": args.execution_backend, + "score_backend": args.score_backend, + "max_turns": args.max_turns, + "teammate_max_turns": args.teammate_max_turns, + "teammate_min_timeout_s": args.teammate_min_timeout, + "rollout_concurrency": configured["rollout_concurrency"], + "reward_concurrency": configured["reward_concurrency"], + "max_rollout_concurrency": configured["max_rollout_concurrency"], + "max_reward_concurrency": configured["max_reward_concurrency"], + "rollout_attempts": args.rollout_attempts, + "reward_attempts": args.reward_attempts, + "reward_uses_rollout_slots": False, + }, + ) + scheduler_path = run_root / "scheduler.jsonl" + + def load_task(name: str) -> dict[str, Any]: + return benchmark.load_task(upstream_root, name) + + def rollout(task: dict[str, Any], mode: str) -> Any: + return benchmark.run_rollout( + task, mode, run_root, + provider=args.provider, model=args.model, max_turns=args.max_turns, + teammate_max_turns=args.teammate_max_turns, + teammate_min_timeout_s=args.teammate_min_timeout, + max_output_tokens=args.max_output_tokens, agent_timeout_s=args.agent_timeout, + stream=args.stream, execution_backend=args.execution_backend, + ags_image=benchmark.format_ags_image(args.ags_image_template, task["id"]), + ags_env_file=ags_env_file, ags_timeout=args.ags_timeout, + ags_cpu=args.ags_cpu, ags_memory=args.ags_memory, + ) + + def reward(artifact: Any) -> dict[str, Any]: + return score_with_infrastructure_retries( + lambda: benchmark.score_rollout( + artifact, + provider=args.provider, + model=args.model, + score_timeout_s=args.score_timeout, + keep_image=args.keep_image, + execution_backend=args.execution_backend, + score_backend=args.score_backend, + ags_image=benchmark.format_ags_image( + args.ags_image_template, artifact.task["id"] + ), + ags_env_file=ags_env_file, + ags_timeout=args.ags_timeout, + ags_cpu=args.ags_cpu, + ags_memory=args.ags_memory, + ags_score_tool_id=args.ags_score_tool_id, + ), + attempts=args.reward_attempts, + delay_s=args.reward_retry_delay, + on_retry=lambda attempt, error: print( + f"[{artifact.task['id']}] reward infrastructure retry " + f"{attempt}/{args.reward_attempts}: {error}", + flush=True, + ), + ) + + def failed(task: dict[str, Any], mode: str, phase: str, error: Exception) -> dict[str, Any]: + return benchmark._failed_case_result( + task, mode, phase, error, output_root=run_root, + provider=args.provider, model=args.model, + execution_backend=args.execution_backend, score_backend=args.score_backend, + ) + + def event(value: dict[str, Any]) -> None: + benchmark._append_jsonl(scheduler_path, value) + print( + f"[{value['task']}] {value['event']} · queue={store.counts()}", + flush=True, + ) + + def resized(value: dict[str, int]) -> None: + ensure_metadata( + run_root, + { + "rollout_concurrency": value["rollout_concurrency"], + "reward_concurrency": value["reward_concurrency"], + }, + ) + benchmark._append_jsonl( + scheduler_path, + { + "event": "pool.resized", + "elapsed_s": None, + "recorded_at": utc_now(), + **value, + }, + ) + print( + "pool resized: " + f"rollout={value['rollout_concurrency']} " + f"reward={value['reward_concurrency']} " + f"active={value['rollout_active']}+{value['reward_active']}", + flush=True, + ) + + stop_event = threading.Event() + + def stop(signum: int, frame: Any) -> None: + print(f"received signal {signum}; waiting for active workers", flush=True) + stop_event.set() + + signal.signal(signal.SIGINT, stop) + signal.signal(signal.SIGTERM, stop) + start_supervisor_lease_watchdog(run_root, stop_event) + if args.start_after: + marker = args.start_after.expanduser().resolve() + if marker.suffix != ".json": + marker = marker / "results.json" + print(f"waiting for predecessor: {marker}", flush=True) + while not marker.is_file() and not stop_event.wait(max(0.2, args.poll_interval)): + pass + if stop_event.is_set(): + return 0 + print("predecessor completed; queue workers released", flush=True) + print( + f"Continuous queue ready: {run_root}\n" + f"rollout_pool={configured['rollout_concurrency']} " + f"reward_pool={configured['reward_concurrency']} " + f"capacity={configured['max_rollout_concurrency']}+" + f"{configured['max_reward_concurrency']} " + f"queued={store.counts()['queued']}", + flush=True, + ) + run_queue_loop( + store, load_task, rollout, reward, + rollout_concurrency=int(configured["rollout_concurrency"]), + reward_concurrency=int(configured["reward_concurrency"]), + max_rollout_concurrency=int(configured["max_rollout_concurrency"]), + max_reward_concurrency=int(configured["max_reward_concurrency"]), + concurrency_loader=store.concurrency, + recover_interrupted=not args.adopt_inflight, + adopt_external_inflight=args.adopt_inflight, + stop_event=stop_event, + poll_interval_s=args.poll_interval, + stop_when_empty=args.stop_when_empty, + rollout_attempts=args.rollout_attempts, + failure_fn=failed, + on_event=event, + on_resize=resized, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/teammate-evals/nl2repo-pilot/global_pool_supervisor.py b/teammate-evals/nl2repo-pilot/global_pool_supervisor.py new file mode 100644 index 0000000..ad997e3 --- /dev/null +++ b/teammate-evals/nl2repo-pilot/global_pool_supervisor.py @@ -0,0 +1,510 @@ +#!/usr/bin/env python3 +"""Run several NL2Repo queues through shared global rollout and reward pools.""" + +from __future__ import annotations + +import argparse +import errno +import fcntl +import json +import os +import signal +import subprocess +import sys +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import evaluation_queue as queue + + +GLOBAL_POOL_ROOT = Path(__file__).resolve().parent / "runs" + + +class GlobalPoolLock: + """Process-wide lease preventing multiple supervisors from allocating slots. + + The lock file is intentionally persistent: deleting a flock file while a + process owns it can split future contenders across different inodes. Its + contents are only diagnostic; the kernel lock is authoritative. + """ + + def __init__(self, path: Path, run_roots: list[Path]) -> None: + self.path = path + self.run_roots = run_roots + self._handle: Any = None + self._metadata: dict[str, Any] = {} + + def _write_metadata(self) -> None: + handle = self._handle + if handle is None: + raise RuntimeError(f"global pool lock is not acquired: {self.path}") + handle.seek(0) + handle.truncate() + handle.write(json.dumps(self._metadata, ensure_ascii=False) + "\n") + handle.flush() + os.fsync(handle.fileno()) + + def acquire(self) -> None: + if self._handle is not None: + raise RuntimeError(f"global pool lock is already acquired: {self.path}") + self.path.parent.mkdir(parents=True, exist_ok=True) + handle = self.path.open("a+", encoding="utf-8") + try: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError as exc: + if exc.errno not in {errno.EACCES, errno.EAGAIN}: + handle.close() + raise + try: + handle.seek(0) + owner = handle.read(4096).strip() or "owner metadata unavailable" + finally: + handle.close() + raise RuntimeError( + f"another global pool supervisor already owns {self.path}: {owner}" + ) from exc + metadata = { + "schema_version": 2, + "pid": os.getpid(), + "acquired_at": queue.utc_now(), + # Full resolved paths are part of the worker-launch authorization. + # Names alone are ambiguous when independent directories contain + # runs with the same basename. + "runs": [str(root.expanduser().resolve()) for root in self.run_roots], + "worker_pids": [], + } + self._handle = handle + self._metadata = metadata + try: + self._write_metadata() + except BaseException: + self.release() + raise + + def update_worker_pids(self, workers: list["ManagedWorker"]) -> None: + """Publish the only queue-worker parents allowed to spawn agent children.""" + self._metadata["worker_pids"] = [ + int(worker.process.pid) + for worker in workers + if worker.process is not None and worker.process.poll() is None + ] + self._metadata["updated_at"] = queue.utc_now() + self._write_metadata() + + def release(self) -> None: + handle = self._handle + if handle is None: + return + self._handle = None + self._metadata = {} + try: + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + finally: + handle.close() + + def __enter__(self) -> "GlobalPoolLock": + self.acquire() + return self + + def __exit__(self, exc_type: Any, exc: Any, traceback: Any) -> None: + self.release() + + +def build_worker_environment() -> dict[str, str]: + """Mark a queue worker as managed by this global supervisor.""" + environment = os.environ.copy() + environment[queue.GLOBAL_POOL_WORKER_ENV] = queue.GLOBAL_POOL_WORKER_MARKER + return environment + + +def validate_pool_capacities( + rollout_capacity: int, + reward_capacity: int, + worker_capacity: int, +) -> None: + """Allow a disabled stage, but never a supervisor with no usable pool.""" + if rollout_capacity < 0 or reward_capacity < 0: + raise ValueError("global capacities must be non-negative") + if rollout_capacity == 0 and reward_capacity == 0: + raise ValueError("at least one global capacity must be positive") + if worker_capacity < max(rollout_capacity, reward_capacity): + raise ValueError("worker capacity must cover each global pool") + + +def enabled_pool_has_work( + snapshots: list[dict[str, int]], + *, + rollout_capacity: int, + reward_capacity: int, +) -> bool: + """Whether any status serviced by an enabled pool still has work.""" + statuses: list[str] = [] + if rollout_capacity > 0: + statuses.extend(("queued", "rollout")) + if reward_capacity > 0: + statuses.extend(("reward_pending", "rewarding")) + return any(counts.get(status, 0) for counts in snapshots for status in statuses) + + +def write_pool_state(path: Path, value: dict[str, Any]) -> None: + """Publish a dashboard-readable snapshot without exposing a partial write.""" + temporary = path.with_suffix(f"{path.suffix}.tmp-{os.getpid()}") + temporary.write_text( + json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" + ) + os.replace(temporary, path) + + +@dataclass +class ManagedWorker: + run_root: Path + command: list[str] + process: subprocess.Popen[str] | None = None + log_handle: Any = None + + def start(self) -> None: + self.log_handle = (self.run_root / "worker.log").open( + "a", encoding="utf-8" + ) + self.process = subprocess.Popen( + self.command, + stdout=self.log_handle, + stderr=subprocess.STDOUT, + text=True, + env=build_worker_environment(), + start_new_session=True, + ) + + def needs_restart(self) -> bool: + return self.process is None or self.process.poll() is not None + + def restart(self) -> None: + if self.log_handle is not None: + self.log_handle.close() + self.start() + + def stop(self, timeout_s: float = 30.0) -> None: + process = self.process + if process is not None and process.poll() is None: + try: + os.killpg(process.pid, signal.SIGTERM) + except ProcessLookupError: + pass + try: + process.wait(timeout=timeout_s) + except subprocess.TimeoutExpired: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.wait(timeout=5) + if self.log_handle is not None: + self.log_handle.close() + self.log_handle = None + + +def restart_worker_safely(worker: ManagedWorker, store: queue.QueueStore) -> bool: + """Restart a dead worker without exposing its previously assigned slots. + + ``worker_config`` survives a child-process crash. Freeze it before spawning + the replacement so the new queue loop cannot claim work under a stale global + allocation during startup recovery. + """ + if not worker.needs_restart(): + return False + store.set_concurrency(rollout=0, reward=0) + worker.restart() + return True + + +def reconcile_worker_concurrency( + stores: list[queue.QueueStore], + allocation: tuple[tuple[int, int], ...], +) -> list[int]: + """Make persisted per-run slots match the global allocation. + + Reconcile against the database every supervisor tick, rather than only + against the previous calculated tuple. This restores slots after a worker + restart and promptly corrects an out-of-band ``evaluation_queue scale``. + """ + corrected: list[int] = [] + for index, (store, (rollout_slots, reward_slots)) in enumerate( + zip(stores, allocation, strict=True) + ): + configured = store.concurrency() + current = ( + int(configured["rollout_concurrency"]), + int(configured["reward_concurrency"]), + ) if configured is not None else None + desired = (rollout_slots, reward_slots) + if current == desired: + continue + store.set_concurrency(rollout=rollout_slots, reward=reward_slots) + corrected.append(index) + return corrected + + +def build_worker_command(args: argparse.Namespace, run_root: Path) -> list[str]: + script = Path(__file__).with_name("evaluation_queue.py") + command = [ + sys.executable, + str(script), + "--run", + str(run_root), + "serve", + "--provider", + args.provider, + "--model", + args.model, + "--max-turns", + str(args.max_turns), + "--teammate-max-turns", + str(args.teammate_max_turns), + "--teammate-min-timeout", + str(args.teammate_min_timeout), + "--max-output-tokens", + str(args.max_output_tokens), + "--agent-timeout", + str(args.agent_timeout), + "--score-timeout", + str(args.score_timeout), + "--rollout-concurrency", + "0", + "--reward-concurrency", + "0", + "--max-rollout-concurrency", + str(args.worker_capacity), + "--max-reward-concurrency", + str(args.worker_capacity), + "--execution-backend", + "ags", + "--score-backend", + "ags", + "--ags-env-file", + str(args.ags_env_file), + "--ags-timeout", + args.ags_timeout, + "--ags-cpu", + args.ags_cpu, + "--ags-memory", + args.ags_memory, + "--reward-attempts", + str(args.reward_attempts), + "--rollout-attempts", + str(args.rollout_attempts), + "--reward-retry-delay", + str(args.reward_retry_delay), + ] + return command + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--run", action="append", type=Path, required=True) + parser.add_argument("--rollout-capacity", type=int, default=32) + parser.add_argument("--reward-capacity", type=int, default=32) + parser.add_argument("--worker-capacity", type=int, default=64) + parser.add_argument("--poll-interval", type=float, default=2.0) + parser.add_argument("--provider", default="qwen") + parser.add_argument("--model", default="ms-rns547kc") + parser.add_argument("--max-turns", type=int, default=300) + parser.add_argument("--teammate-max-turns", type=int, default=160) + parser.add_argument("--teammate-min-timeout", type=float, default=900.0) + parser.add_argument("--max-output-tokens", type=int, default=16384) + parser.add_argument("--agent-timeout", type=float, default=7200.0) + parser.add_argument("--score-timeout", type=float, default=1200.0) + parser.add_argument("--ags-env-file", type=Path, required=True) + parser.add_argument("--ags-timeout", default="3h") + parser.add_argument("--ags-cpu", default="2") + parser.add_argument("--ags-memory", default="4Gi") + parser.add_argument("--reward-attempts", type=int, default=3) + parser.add_argument("--rollout-attempts", type=int, default=3) + parser.add_argument("--reward-retry-delay", type=float, default=5.0) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + validate_pool_capacities( + args.rollout_capacity, + args.reward_capacity, + args.worker_capacity, + ) + except ValueError as exc: + raise SystemExit(str(exc)) from exc + if args.poll_interval <= 0: + raise SystemExit("poll interval must be positive") + + run_roots = [path.expanduser().resolve() for path in args.run] + if len(set(run_roots)) != len(run_roots): + raise SystemExit("each --run must identify a distinct queue") + pool_root = GLOBAL_POOL_ROOT.expanduser().resolve() + state_path = pool_root / "global-pool-state.json" + pool_lock = GlobalPoolLock(pool_root / "global-pool.lock", run_roots) + try: + pool_lock.acquire() + except RuntimeError as exc: + raise SystemExit(str(exc)) from exc + + stores: list[queue.QueueStore] = [] + workers: list[ManagedWorker] = [] + try: + stores = [queue.QueueStore(path) for path in run_roots] + for store in stores: + store.initialize_concurrency( + 0, + 0, + max_rollout=args.worker_capacity, + max_reward=args.worker_capacity, + ) + store.set_concurrency(rollout=0, reward=0) + + workers = [ + ManagedWorker(path, build_worker_command(args, path)) + for path in run_roots + ] + stopping = False + + def request_stop(signum: int, frame: Any) -> None: + nonlocal stopping + stopping = True + print(f"received signal {signum}; stopping global workers", flush=True) + + signal.signal(signal.SIGINT, request_stop) + signal.signal(signal.SIGTERM, request_stop) + os.environ.setdefault("QWEN_ENABLE_THINKING", "1") + os.environ.setdefault("AGS_SCORE_SETUP_CONCURRENCY", "8") + + for worker in workers: + worker.start() + pool_lock.update_worker_pids(workers) + # Give every worker time to recover stale per-run states before allocating. + time.sleep(min(2.0, args.poll_interval)) + except BaseException: + try: + for store in stores: + try: + store.set_concurrency(rollout=0, reward=0) + except Exception: + pass + for worker in workers: + worker.stop() + finally: + pool_lock.release() + raise + + previous: tuple[tuple[int, int], ...] | None = None + try: + while not stopping: + workers_restarted = False + for index, worker in enumerate(workers): + if restart_worker_safely(worker, stores[index]): + workers_restarted = True + print(f"restarted worker: {worker.run_root.name}", flush=True) + if workers_restarted: + pool_lock.update_worker_pids(workers) + + snapshots = [store.counts() for store in stores] + has_work = enabled_pool_has_work( + snapshots, + rollout_capacity=args.rollout_capacity, + reward_capacity=args.reward_capacity, + ) + rollout = queue.allocate_global_slots( + snapshots, + args.rollout_capacity, + active_key="rollout", + pending_key="queued", + ) + reward = queue.allocate_global_slots( + snapshots, + args.reward_capacity, + active_key="rewarding", + pending_key="reward_pending", + ) + allocation = tuple(zip(rollout, reward, strict=True)) + pool_state = { + "status": "running" if has_work else "idle", + "updated_at": queue.utc_now(), + "pid": os.getpid(), + "worker_pids": [ + int(worker.process.pid) + for worker in workers + if worker.process is not None and worker.process.poll() is None + ], + "rollout_capacity": args.rollout_capacity, + "reward_capacity": args.reward_capacity, + "runs": [ + { + "run": root.name, + "rollout_slots": rollout_slots, + "reward_slots": reward_slots, + "counts": counts, + } + for root, rollout_slots, reward_slots, counts in zip( + run_roots, rollout, reward, snapshots, strict=True + ) + ], + } + write_pool_state(state_path, pool_state) + corrected = reconcile_worker_concurrency(stores, allocation) + if allocation != previous: + print( + json.dumps( + { + "event": "global_pool.allocated", + "rollout_capacity": args.rollout_capacity, + "reward_capacity": args.reward_capacity, + "runs": [ + { + "run": root.name, + "rollout_slots": rollout_slots, + "reward_slots": reward_slots, + "counts": counts, + } + for root, rollout_slots, reward_slots, counts in zip( + run_roots, + rollout, + reward, + snapshots, + strict=True, + ) + ], + }, + ensure_ascii=False, + ), + flush=True, + ) + elif corrected: + print( + json.dumps( + { + "event": "global_pool.reconciled", + "runs": [run_roots[index].name for index in corrected], + }, + ensure_ascii=False, + ), + flush=True, + ) + previous = allocation + + time.sleep(args.poll_interval) + finally: + try: + for store in stores: + try: + store.set_concurrency(rollout=0, reward=0) + except Exception: + pass + for worker in workers: + worker.stop() + finally: + pool_lock.release() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/teammate-evals/nl2repo-pilot/handoff_qwen104_to_ags_reward.sh b/teammate-evals/nl2repo-pilot/handoff_qwen104_to_ags_reward.sh new file mode 100755 index 0000000..b190617 --- /dev/null +++ b/teammate-evals/nl2repo-pilot/handoff_qwen104_to_ags_reward.sh @@ -0,0 +1,6 @@ +#!/bin/zsh +set -euo pipefail + +print -u2 "This historical handoff launcher is disabled: it bypassed the global NL2Repo pool." +print -u2 "Prepare/retry the run with evaluation_queue.py, then register it with global_pool_supervisor.py --run ." +exit 2 diff --git a/teammate-evals/nl2repo-pilot/latency_probe.py b/teammate-evals/nl2repo-pilot/latency_probe.py new file mode 100644 index 0000000..7a502e0 --- /dev/null +++ b/teammate-evals/nl2repo-pilot/latency_probe.py @@ -0,0 +1,790 @@ +#!/usr/bin/env python3 +"""Measure Qwen latency on a deterministic NL2Repo prompt subset. + +This probe deliberately does not run agents or hidden tests. It sends the same +real ``start.md`` documents once serially and once at the requested concurrency +so model-service queueing can be separated from sandbox and tool overhead. +Prompt contents and credentials are never written to the result artifacts. +""" + +from __future__ import annotations + +import argparse +import csv +import errno +import fcntl +import json +import math +import random +import statistics +import sys +import threading +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterable + + +REPO_ROOT = Path(__file__).resolve().parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from src.config import get_provider_config # noqa: E402 +from src.providers.qwen_provider import QwenProvider # noqa: E402 + + +DEFAULT_SEED = 20260715 +DEFAULT_MAX_PROMPT_BYTES = 64 * 1024 +PINNED_REVISION = "781a1da1ee41fb8edb0bed22f586d69111610edf" +GLOBAL_POOL_LOCK_PATH = Path(__file__).resolve().parent / "runs" / "global-pool.lock" + + +@dataclass(frozen=True) +class TaskPrompt: + name: str + path: Path + prompt_bytes: int + difficulty: str + + +@dataclass +class RequestResult: + task: str + prompt_bytes: int + difficulty: str + success: bool + ttft_seconds: float | None + latency_seconds: float + input_tokens: int + output_tokens: int + output_chars: int + finish_reason: str | None + error_type: str | None = None + error_status: int | None = None + error_message: str | None = None + + +def global_pool_is_active(lock_path: Path | None = None) -> bool: + """Probe the persistent global-pool lock without modifying it.""" + path = (lock_path or GLOBAL_POOL_LOCK_PATH).expanduser().resolve() + if not path.is_file(): + return False + try: + handle = path.open("r+", encoding="utf-8") + except FileNotFoundError: + return False + try: + try: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError as exc: + if exc.errno in {errno.EACCES, errno.EAGAIN}: + return True + raise + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + return False + finally: + handle.close() + + +def reject_if_global_pool_active(lock_path: Path | None = None) -> None: + if global_pool_is_active(lock_path): + raise SystemExit( + "latency_probe.py is disabled while the NL2Repo global pool is active; " + "wait for the evaluator to stop so the probe cannot overcommit model capacity." + ) + + +def find_upstream_root(explicit: str | None = None) -> Path: + """Find an NL2Repo-Bench checkout containing ``test_files``.""" + if explicit: + candidates = [Path(explicit).expanduser()] + else: + cache = Path.home() / ".cache" / "clawd-code" / "nl2repo-bench" + candidates = [ + cache / PINNED_REVISION[:12], + cache / PINNED_REVISION, + cache, + ] + if cache.is_dir(): + candidates.extend(sorted(cache.iterdir(), reverse=True)) + + for candidate in candidates: + if (candidate / "test_files" / "task_difficulty.csv").is_file(): + return candidate.resolve() + raise FileNotFoundError( + "NL2Repo-Bench checkout not found; pass --upstream-root or run benchmark.py --list" + ) + + +def load_tasks(upstream_root: Path) -> list[TaskPrompt]: + test_files = upstream_root / "test_files" + difficulties: dict[str, str] = {} + with (test_files / "task_difficulty.csv").open(newline="", encoding="utf-8") as handle: + for row in csv.DictReader(handle): + name = (row.get("task-name") or row.get("task") or "").strip() + if name: + difficulties[name] = (row.get("Level") or row.get("difficulty") or "").strip() + + tasks: list[TaskPrompt] = [] + for start_path in sorted(test_files.glob("*/start.md")): + tasks.append( + TaskPrompt( + name=start_path.parent.name, + path=start_path, + prompt_bytes=start_path.stat().st_size, + difficulty=difficulties.get(start_path.parent.name, ""), + ) + ) + if not tasks: + raise ValueError(f"no NL2Repo start.md files found under {test_files}") + return tasks + + +def select_tasks( + tasks: Iterable[TaskPrompt], + subset_size: int, + seed: int, + max_prompt_bytes: int, +) -> tuple[list[TaskPrompt], int]: + """Select a stable random subset after applying a prompt-size ceiling.""" + eligible = sorted( + (task for task in tasks if task.prompt_bytes <= max_prompt_bytes), + key=lambda task: task.name, + ) + if subset_size < 1: + raise ValueError("subset size must be positive") + if len(eligible) < subset_size: + raise ValueError( + f"only {len(eligible)} tasks are <= {max_prompt_bytes} bytes; " + f"cannot select {subset_size}" + ) + selected = random.Random(seed).sample(eligible, subset_size) + return sorted(selected, key=lambda task: task.name), len(eligible) + + +def percentile(values: Iterable[float], percent: float) -> float | None: + ordered = sorted(values) + if not ordered: + return None + if len(ordered) == 1: + return ordered[0] + position = (len(ordered) - 1) * percent / 100 + lower = math.floor(position) + upper = math.ceil(position) + if lower == upper: + return ordered[lower] + fraction = position - lower + return ordered[lower] * (1 - fraction) + ordered[upper] * fraction + + +def _distribution(values: list[float]) -> dict[str, float | None]: + if not values: + return {key: None for key in ("mean", "p50", "p95", "p99", "max")} + return { + "mean": statistics.fmean(values), + "p50": percentile(values, 50), + "p95": percentile(values, 95), + "p99": percentile(values, 99), + "max": max(values), + } + + +def summarize(results: list[RequestResult], duration_seconds: float) -> dict[str, Any]: + successes = [result for result in results if result.success] + errors: dict[str, int] = {} + for result in results: + if not result.success: + label = result.error_type or "UnknownError" + if result.error_status is not None: + label += f":{result.error_status}" + errors[label] = errors.get(label, 0) + 1 + return { + "requests": len(results), + "successes": len(successes), + "errors": len(results) - len(successes), + "error_breakdown": errors, + "duration_seconds": duration_seconds, + "requests_per_second": len(successes) / duration_seconds if duration_seconds else 0, + "input_tokens": sum(result.input_tokens for result in successes), + "input_tokens_per_second": ( + sum(result.input_tokens for result in successes) / duration_seconds + if duration_seconds + else 0 + ), + "output_tokens": sum(result.output_tokens for result in successes), + "output_tokens_per_second": ( + sum(result.output_tokens for result in successes) / duration_seconds + if duration_seconds + else 0 + ), + "ttft_seconds": _distribution( + [result.ttft_seconds for result in successes if result.ttft_seconds is not None] + ), + "latency_seconds": _distribution( + [result.latency_seconds for result in successes] + ), + } + + +def _safe_ratio(numerator: float | None, denominator: float | None) -> float | None: + if numerator is None or denominator in (None, 0): + return None + return numerator / denominator + + +def compare_runs( + baseline: dict[str, Any], + concurrent: dict[str, Any], + baseline_concurrency: int, + concurrency: int, + baseline_results: list[RequestResult] | None = None, + concurrent_results: list[RequestResult] | None = None, +) -> dict[str, Any]: + scale = _safe_ratio( + concurrent["requests_per_second"], baseline["requests_per_second"] + ) + ratios: list[float] = [] + if baseline_results is not None and concurrent_results is not None: + by_task = { + result.task: result + for result in baseline_results + if result.success and result.latency_seconds > 0 + } + for result in concurrent_results: + base = by_task.get(result.task) + if result.success and base is not None: + ratios.append(result.latency_seconds / base.latency_seconds) + + return { + "throughput_scale": scale, + "ideal_concurrency_scale": concurrency / baseline_concurrency, + "scaling_efficiency": ( + scale / (concurrency / baseline_concurrency) if scale is not None else None + ), + "ttft_p50_ratio": _safe_ratio( + concurrent["ttft_seconds"]["p50"], baseline["ttft_seconds"]["p50"] + ), + "ttft_p95_ratio": _safe_ratio( + concurrent["ttft_seconds"]["p95"], baseline["ttft_seconds"]["p95"] + ), + "latency_p50_ratio": _safe_ratio( + concurrent["latency_seconds"]["p50"], baseline["latency_seconds"]["p50"] + ), + "latency_p95_ratio": _safe_ratio( + concurrent["latency_seconds"]["p95"], baseline["latency_seconds"]["p95"] + ), + "paired_latency_ratio": _distribution(ratios), + } + + +def _error_details(exc: Exception, secret: str) -> tuple[str, int | None, str]: + status = getattr(exc, "status_code", None) + message = str(exc).replace(secret, "[REDACTED]") if secret else str(exc) + return type(exc).__name__, status if isinstance(status, int) else None, message[:500] + + +def request_once( + client: Any, + task: TaskPrompt, + model: str, + max_tokens: int, + gate: threading.Event, + secret: str, +) -> RequestResult: + gate.wait() + started = time.perf_counter() + first_text_at: float | None = None + usage: Any = None + finish_reason: str | None = None + output_parts: list[str] = [] + try: + prompt = task.path.read_text(encoding="utf-8") + stream = client.chat.completions.create( + model=model, + messages=[ + { + "role": "system", + "content": ( + "This is a latency benchmark. Read the repository specification and " + "reply with exactly one compact line: OK:. Do not solve it." + ), + }, + {"role": "user", "content": prompt}, + ], + temperature=0, + max_tokens=max_tokens, + stream=True, + stream_options={"include_usage": True}, + extra_body={"chat_template_kwargs": {"enable_thinking": False}}, + ) + for chunk in stream: + usage_candidate = getattr(chunk, "usage", None) + if usage_candidate is not None: + usage = usage_candidate + choices = getattr(chunk, "choices", None) or [] + if not choices: + continue + choice = choices[0] + if getattr(choice, "finish_reason", None): + finish_reason = str(choice.finish_reason) + delta = getattr(choice, "delta", None) + text = getattr(delta, "content", None) if delta is not None else None + if text: + if first_text_at is None: + first_text_at = time.perf_counter() + output_parts.append(str(text)) + finished = time.perf_counter() + return RequestResult( + task=task.name, + prompt_bytes=task.prompt_bytes, + difficulty=task.difficulty, + success=True, + ttft_seconds=(first_text_at - started) if first_text_at is not None else None, + latency_seconds=finished - started, + input_tokens=int(getattr(usage, "prompt_tokens", 0) or 0), + output_tokens=int(getattr(usage, "completion_tokens", 0) or 0), + output_chars=sum(len(part) for part in output_parts), + finish_reason=finish_reason, + ) + except Exception as exc: + finished = time.perf_counter() + error_type, error_status, error_message = _error_details(exc, secret) + return RequestResult( + task=task.name, + prompt_bytes=task.prompt_bytes, + difficulty=task.difficulty, + success=False, + ttft_seconds=(first_text_at - started) if first_text_at is not None else None, + latency_seconds=finished - started, + input_tokens=0, + output_tokens=0, + output_chars=sum(len(part) for part in output_parts), + finish_reason=finish_reason, + error_type=error_type, + error_status=error_status, + error_message=error_message, + ) + + +def run_batch( + client: Any, + tasks: list[TaskPrompt], + concurrency: int, + model: str, + max_tokens: int, + secret: str, +) -> tuple[list[RequestResult], float]: + if concurrency < 1: + raise ValueError("concurrency must be positive") + gate = threading.Event() + results: list[RequestResult] = [] + with ThreadPoolExecutor(max_workers=concurrency) as executor: + futures = [ + executor.submit(request_once, client, task, model, max_tokens, gate, secret) + for task in tasks + ] + started = time.perf_counter() + gate.set() + for completed, future in enumerate(as_completed(futures), start=1): + result = future.result() + results.append(result) + state = "ok" if result.success else f"error:{result.error_type}" + print( + f"[{completed:02d}/{len(tasks):02d}] {result.task:<24} " + f"{state:<24} {result.latency_seconds:7.2f}s", + flush=True, + ) + duration = time.perf_counter() - started + return sorted(results, key=lambda result: result.task), duration + + +def _fmt(value: float | None, suffix: str = "") -> str: + return "n/a" if value is None else f"{value:.3f}{suffix}" + + +def parse_concurrency_sweep(value: str) -> list[int]: + """Parse an ordered, comma-separated set of positive concurrency levels.""" + try: + levels = [int(part.strip()) for part in value.split(",") if part.strip()] + except ValueError as exc: + raise argparse.ArgumentTypeError("sweep values must be integers") from exc + if not levels or any(level < 1 for level in levels): + raise argparse.ArgumentTypeError("sweep values must be positive") + if len(set(levels)) != len(levels): + raise argparse.ArgumentTypeError("sweep values must be unique") + return levels + + +def write_report(path: Path, payload: dict[str, Any]) -> None: + baseline = payload["runs"]["baseline"]["summary"] + concurrent = payload["runs"]["concurrent"]["summary"] + comparison = payload["comparison"] + lines = [ + "# NL2Repo Qwen latency probe", + "", + f"Generated: {payload['generated_at']}", + "", + "This is a model-service latency probe, not a full agent or hidden-test evaluation. " + "It uses the same fixed NL2Repo prompts in both runs, disables model thinking, " + "streams a short answer, and performs no retry.", + "", + "## Configuration", + "", + f"- Model: `{payload['model']}`", + ( + f"- Subset: {payload['subset']['selected']} of " + f"{payload['subset']['eligible']} eligible tasks" + ), + f"- Seed: `{payload['subset']['seed']}`", + f"- Prompt ceiling: {payload['subset']['max_prompt_bytes']} bytes", + f"- Output limit: {payload['max_tokens']} tokens", + "", + "## Results", + "", + ( + "| Run | Concurrency | Success | Errors | Wall time | Req/s | " + "TTFT p50 | TTFT p95 | Latency p50 | Latency p95 |" + ), + "|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|", + ] + for label, summary, concurrency in ( + ("baseline", baseline, payload["runs"]["baseline"]["concurrency"]), + ("concurrent", concurrent, payload["runs"]["concurrent"]["concurrency"]), + ): + lines.append( + f"| {label} | {concurrency} | {summary['successes']} | {summary['errors']} | " + f"{_fmt(summary['duration_seconds'], 's')} | {_fmt(summary['requests_per_second'])} | " + f"{_fmt(summary['ttft_seconds']['p50'], 's')} | " + f"{_fmt(summary['ttft_seconds']['p95'], 's')} | " + f"{_fmt(summary['latency_seconds']['p50'], 's')} | " + f"{_fmt(summary['latency_seconds']['p95'], 's')} |" + ) + efficiency = comparison["scaling_efficiency"] + lines.extend( + [ + "", + "## Comparison", + "", + f"- Throughput scale: {_fmt(comparison['throughput_scale'], 'x')}", + ( + "- Scaling efficiency: " + f"{_fmt(efficiency * 100 if efficiency is not None else None, '%')}" + ), + ( + "- Aggregate input throughput: " + f"{_fmt(baseline['input_tokens_per_second'], ' tok/s')} baseline; " + f"{_fmt(concurrent['input_tokens_per_second'], ' tok/s')} concurrent" + ), + ( + "- TTFT p50 / p95 ratio: " + f"{_fmt(comparison['ttft_p50_ratio'], 'x')} / " + f"{_fmt(comparison['ttft_p95_ratio'], 'x')}" + ), + ( + "- Total latency p50 / p95 ratio: " + f"{_fmt(comparison['latency_p50_ratio'], 'x')} / " + f"{_fmt(comparison['latency_p95_ratio'], 'x')}" + ), + ( + "- Paired per-task latency ratio p50 / p95: " + f"{_fmt(comparison['paired_latency_ratio']['p50'], 'x')} / " + f"{_fmt(comparison['paired_latency_ratio']['p95'], 'x')}" + ), + "", + "## Selected tasks", + "", + "| Task | Difficulty | Prompt bytes |", + "|---|---|---:|", + ] + ) + for task in payload["tasks"]: + lines.append(f"| {task['name']} | {task['difficulty'] or 'n/a'} | {task['prompt_bytes']} |") + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def write_sweep_report(path: Path, payload: dict[str, Any]) -> None: + baseline_level = str(payload["concurrency_levels"][0]) + lines = [ + "# NL2Repo Qwen concurrency sweep", + "", + f"Generated: {payload['generated_at']}", + "", + "This is a model-service latency probe, not a full agent or hidden-test " + "evaluation. Every level uses the same fixed NL2Repo prompts, disables " + "model thinking, streams a short answer, and performs no retry.", + "", + "## Configuration", + "", + f"- Model: `{payload['model']}`", + ( + f"- Subset: {payload['subset']['selected']} of " + f"{payload['subset']['eligible']} eligible tasks" + ), + f"- Seed: `{payload['subset']['seed']}`", + f"- Prompt ceiling: {payload['subset']['max_prompt_bytes']} bytes", + f"- Output limit: {payload['max_tokens']} tokens", + "", + "## Results", + "", + ( + "| Concurrency | Success | Errors | Wall time | Req/s | Input tok/s | " + "TTFT p50 | TTFT p95 | Latency p50 | Latency p95 |" + ), + "|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|", + ] + for level in payload["concurrency_levels"]: + summary = payload["runs"][str(level)]["summary"] + lines.append( + f"| {level} | {summary['successes']} | {summary['errors']} | " + f"{_fmt(summary['duration_seconds'], 's')} | " + f"{_fmt(summary['requests_per_second'])} | " + f"{_fmt(summary['input_tokens_per_second'])} | " + f"{_fmt(summary['ttft_seconds']['p50'], 's')} | " + f"{_fmt(summary['ttft_seconds']['p95'], 's')} | " + f"{_fmt(summary['latency_seconds']['p50'], 's')} | " + f"{_fmt(summary['latency_seconds']['p95'], 's')} |" + ) + lines.extend( + [ + "", + f"## Scaling relative to concurrency {baseline_level}", + "", + "| Concurrency | Throughput scale | Efficiency | TTFT p95 ratio | Latency p95 ratio |", + "|---:|---:|---:|---:|---:|", + ] + ) + for level in payload["concurrency_levels"]: + comparison = payload["comparisons"][str(level)] + efficiency = comparison["scaling_efficiency"] + lines.append( + f"| {level} | {_fmt(comparison['throughput_scale'], 'x')} | " + f"{_fmt(efficiency * 100 if efficiency is not None else None, '%')} | " + f"{_fmt(comparison['ttft_p95_ratio'], 'x')} | " + f"{_fmt(comparison['latency_p95_ratio'], 'x')} |" + ) + lines.extend( + [ + "", + "## Selected tasks", + "", + "| Task | Difficulty | Prompt bytes |", + "|---|---|---:|", + ] + ) + for task in payload["tasks"]: + lines.append( + f"| {task['name']} | {task['difficulty'] or 'n/a'} | " + f"{task['prompt_bytes']} |" + ) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--upstream-root") + parser.add_argument("--subset-size", type=int, default=32) + parser.add_argument("--seed", type=int, default=DEFAULT_SEED) + parser.add_argument("--max-prompt-bytes", type=int, default=DEFAULT_MAX_PROMPT_BYTES) + parser.add_argument("--baseline-concurrency", type=int, default=1) + parser.add_argument("--concurrency", type=int, default=32) + parser.add_argument( + "--sweep", + type=parse_concurrency_sweep, + help="comma-separated levels, for example 1,2,4,8; overrides the two-run mode", + ) + parser.add_argument("--max-tokens", type=int, default=32) + parser.add_argument("--timeout", type=float, default=120) + parser.add_argument("--output", type=Path) + parser.add_argument("--dry-run", action="store_true") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + if not args.dry_run: + raise SystemExit( + "live latency_probe.py request pools are disabled under the " + "global-pool-only policy; --dry-run remains available for planning" + ) + upstream = find_upstream_root(args.upstream_root) + all_tasks = load_tasks(upstream) + tasks, eligible_count = select_tasks( + all_tasks, args.subset_size, args.seed, args.max_prompt_bytes + ) + print( + f"Selected {len(tasks)} of {eligible_count} eligible tasks " + f"(all={len(all_tasks)}, seed={args.seed}, ceiling={args.max_prompt_bytes} bytes)." + ) + for task in tasks: + print(f" {task.name:<24} {task.prompt_bytes:>7} bytes {task.difficulty}") + if args.dry_run: + return 0 + + provider_config = get_provider_config("qwen") + api_key = str(provider_config.get("api_key") or "") + if not api_key: + raise RuntimeError("Qwen API key is not configured; run `clawd config --use qwen3.5`") + model = str(provider_config.get("default_model") or QwenProvider.DEFAULT_MODEL) + provider = QwenProvider( + api_key=api_key, + base_url=provider_config.get("base_url"), + model=model, + ) + client = provider.client.with_options(timeout=args.timeout, max_retries=0) + + warmup_task = min(tasks, key=lambda task: task.prompt_bytes) + print(f"\nWarmup: {warmup_task.name}", flush=True) + warmup, _ = run_batch(client, [warmup_task], 1, model, args.max_tokens, api_key) + if not warmup[0].success: + raise RuntimeError( + f"warmup failed: {warmup[0].error_type}: {warmup[0].error_message}" + ) + + if args.sweep: + runs: dict[str, dict[str, Any]] = {} + for level in args.sweep: + print(f"\nSweep run (concurrency={level})", flush=True) + results, duration = run_batch( + client, tasks, level, model, args.max_tokens, api_key + ) + runs[str(level)] = { + "concurrency": level, + "summary": summarize(results, duration), + "results": [asdict(result) for result in results], + } + + baseline_level = args.sweep[0] + baseline_run = runs[str(baseline_level)] + comparisons = { + str(level): compare_runs( + baseline_run["summary"], + runs[str(level)]["summary"], + baseline_level, + level, + [RequestResult(**result) for result in baseline_run["results"]], + [RequestResult(**result) for result in runs[str(level)]["results"]], + ) + for level in args.sweep + } + output_dir = args.output or ( + Path(__file__).resolve().parent + / "latency-runs" + / datetime.now().strftime("%Y%m%d-%H%M%S-sweep") + ) + output_dir.mkdir(parents=True, exist_ok=False) + payload: dict[str, Any] = { + "schema_version": 2, + "mode": "concurrency_sweep", + "generated_at": datetime.now(timezone.utc).isoformat(), + "upstream_root": str(upstream), + "model": model, + "max_tokens": args.max_tokens, + "timeout_seconds": args.timeout, + "concurrency_levels": args.sweep, + "subset": { + "selected": len(tasks), + "eligible": eligible_count, + "all_tasks": len(all_tasks), + "seed": args.seed, + "max_prompt_bytes": args.max_prompt_bytes, + }, + "tasks": [ + { + "name": task.name, + "prompt_bytes": task.prompt_bytes, + "difficulty": task.difficulty, + } + for task in tasks + ], + "warmup": asdict(warmup[0]), + "runs": runs, + "comparisons": comparisons, + } + (output_dir / "results.json").write_text( + json.dumps(payload, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + write_sweep_report(output_dir / "REPORT.md", payload) + print(f"\nResults: {output_dir}") + return 0 if all(run["summary"]["errors"] == 0 for run in runs.values()) else 2 + + print(f"\nBaseline run (concurrency={args.baseline_concurrency})", flush=True) + baseline_results, baseline_duration = run_batch( + client, + tasks, + args.baseline_concurrency, + model, + args.max_tokens, + api_key, + ) + print(f"\nConcurrent run (concurrency={args.concurrency})", flush=True) + concurrent_results, concurrent_duration = run_batch( + client, tasks, args.concurrency, model, args.max_tokens, api_key + ) + + baseline_summary = summarize(baseline_results, baseline_duration) + concurrent_summary = summarize(concurrent_results, concurrent_duration) + comparison = compare_runs( + baseline_summary, + concurrent_summary, + args.baseline_concurrency, + args.concurrency, + baseline_results, + concurrent_results, + ) + output_dir = args.output or ( + Path(__file__).resolve().parent + / "latency-runs" + / datetime.now().strftime("%Y%m%d-%H%M%S") + ) + output_dir.mkdir(parents=True, exist_ok=False) + payload: dict[str, Any] = { + "schema_version": 1, + "generated_at": datetime.now(timezone.utc).isoformat(), + "upstream_root": str(upstream), + "model": model, + "max_tokens": args.max_tokens, + "timeout_seconds": args.timeout, + "subset": { + "selected": len(tasks), + "eligible": eligible_count, + "all_tasks": len(all_tasks), + "seed": args.seed, + "max_prompt_bytes": args.max_prompt_bytes, + }, + "tasks": [ + { + "name": task.name, + "prompt_bytes": task.prompt_bytes, + "difficulty": task.difficulty, + } + for task in tasks + ], + "warmup": asdict(warmup[0]), + "runs": { + "baseline": { + "concurrency": args.baseline_concurrency, + "summary": baseline_summary, + "results": [asdict(result) for result in baseline_results], + }, + "concurrent": { + "concurrency": args.concurrency, + "summary": concurrent_summary, + "results": [asdict(result) for result in concurrent_results], + }, + }, + "comparison": comparison, + } + (output_dir / "results.json").write_text( + json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" + ) + write_report(output_dir / "REPORT.md", payload) + print(f"\nResults: {output_dir}") + print(json.dumps(comparison, indent=2)) + return 0 if concurrent_summary["errors"] == 0 else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/teammate-evals/nl2repo-pilot/monitor_r1_ags_capacity.sh b/teammate-evals/nl2repo-pilot/monitor_r1_ags_capacity.sh new file mode 100755 index 0000000..3021b8f --- /dev/null +++ b/teammate-evals/nl2repo-pilot/monitor_r1_ags_capacity.sh @@ -0,0 +1,5 @@ +#!/bin/zsh +set -euo pipefail + +print -u2 "This historical capacity monitor is disabled: only global_pool_supervisor.py may allocate queue slots." +exit 2 diff --git a/teammate-evals/nl2repo-pilot/rescore_qwen_repeats_ags64.sh b/teammate-evals/nl2repo-pilot/rescore_qwen_repeats_ags64.sh new file mode 100755 index 0000000..9263588 --- /dev/null +++ b/teammate-evals/nl2repo-pilot/rescore_qwen_repeats_ags64.sh @@ -0,0 +1,6 @@ +#!/bin/zsh +set -euo pipefail + +print -u2 "This historical rescore launcher is disabled: it created a private 64-slot reward pool." +print -u2 "Move persisted rollouts to reward_pending, then register every run with the shared global_pool_supervisor.py." +exit 2 diff --git a/teammate-evals/nl2repo-pilot/reward_repair.py b/teammate-evals/nl2repo-pilot/reward_repair.py new file mode 100644 index 0000000..ef31e45 --- /dev/null +++ b/teammate-evals/nl2repo-pilot/reward_repair.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +"""Repair NL2Repo reward infrastructure failures without rerunning agents.""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import sys +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path +from typing import Any + + +HERE = Path(__file__).resolve().parent +BENCHMARK_PATH = HERE / "benchmark.py" +SPEC = importlib.util.spec_from_file_location("nl2repo_reward_repair_benchmark", BENCHMARK_PATH) +assert SPEC is not None and SPEC.loader is not None +benchmark = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = benchmark +SPEC.loader.exec_module(benchmark) + + +def read_json(path: Path) -> dict[str, Any] | None: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + return value if isinstance(value, dict) else None + + +def discover_infrastructure_failures( + run_root: Path, +) -> list[tuple[str, str, str]]: + failures: list[tuple[str, str, str]] = [] + for path in sorted(run_root.glob("*/*/result.json")): + result = read_json(path) + if result is None: + continue + hidden = result.get("hidden_tests") + error = str(hidden.get("error") or "") if isinstance(hidden, dict) else "" + if error: + failures.append((path.parent.parent.name, path.parent.name, error)) + return failures + + +def refresh_aggregate(run_root: Path) -> bool: + """Replace stale in-memory scheduler results with repaired per-case results.""" + aggregate_path = run_root / "results.json" + aggregate = read_json(aggregate_path) + if aggregate is None or not isinstance(aggregate.get("results"), list): + return False + refreshed: list[dict[str, Any]] = [] + for old_result in aggregate["results"]: + if not isinstance(old_result, dict): + continue + task = str(old_result.get("task") or "") + mode = str(old_result.get("mode") or "adaptive") + current = read_json(run_root / task / mode / "result.json") + refreshed.append(current or old_result) + aggregate["results"] = refreshed + benchmark._write_json(aggregate_path, aggregate) + report = benchmark.render_report( + refreshed, + str(aggregate.get("run_id") or run_root.name), + str(aggregate.get("upstream_ref") or benchmark.UPSTREAM_REF), + ) + (run_root / "REPORT.md").write_text(report, encoding="utf-8") + return True + + +def repair_case( + run_root: Path, + upstream_root: Path, + task_name: str, + mode: str, + *, + score_backend: str, + score_timeout_s: float, + keep_image: bool, +) -> dict[str, Any]: + task = benchmark.load_task(upstream_root, task_name) + return benchmark.rescore_existing_case( + task, + mode, + run_root, + score_backend=score_backend, + score_timeout_s=score_timeout_s, + keep_image=keep_image, + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--run", type=Path, required=True) + parser.add_argument("--watch", action="store_true") + parser.add_argument("--poll-interval", type=float, default=10.0) + parser.add_argument("--concurrency", type=int, default=4) + parser.add_argument("--max-attempts", type=int, default=3) + parser.add_argument("--score-backend", choices=("docker",), default="docker") + parser.add_argument("--score-timeout", type=float, default=1200) + parser.add_argument("--keep-image", action="store_true") + parser.add_argument("--upstream-root", type=Path) + parser.add_argument("--cache-root", type=Path) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + raise SystemExit( + "direct reward_repair.py scoring pools are disabled. Requeue reusable " + "rollouts and run global_pool_supervisor.py with --rollout-capacity 0 " + "and the desired --reward-capacity." + ) + # Kept below for import-level migration tooling; the production CLI cannot + # reach this legacy private ThreadPoolExecutor path. + if args.concurrency < 1 or args.max_attempts < 1 or args.poll_interval < 0: + raise SystemExit("repair concurrency/attempts must be positive") + run_root = args.run.expanduser().resolve() + if not run_root.is_dir(): + raise SystemExit(f"run directory not found: {run_root}") + upstream_root = benchmark.resolve_upstream( + args.upstream_root, cache_root=args.cache_root + ) + attempts: dict[tuple[str, str], int] = {} + + while True: + failures = discover_infrastructure_failures(run_root) + eligible = [ + failure + for failure in failures + if attempts.get((failure[0], failure[1]), 0) < args.max_attempts + ] + if eligible: + with ThreadPoolExecutor(max_workers=args.concurrency) as pool: + futures = {} + for task, mode, error in eligible: + key = (task, mode) + attempts[key] = attempts.get(key, 0) + 1 + print( + f"[{task}] repairing {mode} reward " + f"({attempts[key]}/{args.max_attempts}): {error}", + flush=True, + ) + future = pool.submit( + repair_case, + run_root, + upstream_root, + task, + mode, + score_backend=args.score_backend, + score_timeout_s=args.score_timeout, + keep_image=args.keep_image, + ) + futures[future] = key + for future in as_completed(futures): + task, mode = futures[future] + try: + result = future.result() + except Exception as exc: + print(f"[{task}] repair raised: {type(exc).__name__}: {exc}", flush=True) + continue + hidden = result.get("hidden_tests") + error = hidden.get("error") if isinstance(hidden, dict) else None + tests = hidden.get("pytest", {}) if isinstance(hidden, dict) else {} + print( + f"[{task}] reward repaired: quality={result.get('quality_score', 0):.2f} " + f"passed={tests.get('passed', 0)}/{tests.get('expected', 0)} " + f"infra_error={error or '-'}", + flush=True, + ) + + remaining = discover_infrastructure_failures(run_root) + completed = (run_root / "results.json").is_file() + if not args.watch or (completed and not remaining): + if completed and not remaining: + refresh_aggregate(run_root) + return 0 if not remaining else 2 + exhausted = all( + attempts.get((task, mode), 0) >= args.max_attempts + for task, mode, _ in remaining + ) + if completed and remaining and exhausted: + print(f"unresolved reward infrastructure failures: {remaining}", flush=True) + return 2 + time.sleep(args.poll_interval) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/teammate-evals/nl2repo-pilot/run_forced_team_fixed.sh b/teammate-evals/nl2repo-pilot/run_forced_team_fixed.sh new file mode 100755 index 0000000..8df9f1e --- /dev/null +++ b/teammate-evals/nl2repo-pilot/run_forced_team_fixed.sh @@ -0,0 +1,32 @@ +#!/bin/zsh +set -euo pipefail + +SCRIPT_DIR=${0:A:h} +REPO_ROOT=${SCRIPT_DIR:h:h} +PYTHON="$REPO_ROOT/.venv/bin/python" +GLOBAL_POOL="$SCRIPT_DIR/global_pool_supervisor.py" +RUN_ROOT="$SCRIPT_DIR/runs/20260717-qwen104-forced-team-fixed-pool32-r1" +AGS_ENV="${REPO_ROOT:h}/sandbox/ags/.env" + +export QWEN_ENABLE_THINKING=1 +export AGS_SCORE_SETUP_CONCURRENCY=8 + +exec "$PYTHON" "$GLOBAL_POOL" --run "$RUN_ROOT" \ + --provider qwen \ + --model ms-rns547kc \ + --max-turns 300 \ + --teammate-max-turns 160 \ + --teammate-min-timeout 900 \ + --max-output-tokens 16384 \ + --agent-timeout 7200 \ + --score-timeout 1200 \ + --rollout-capacity 32 \ + --reward-capacity 32 \ + --worker-capacity 64 \ + --ags-env-file "$AGS_ENV" \ + --ags-timeout 3h \ + --ags-cpu 2 \ + --ags-memory 4Gi \ + --rollout-attempts 3 \ + --reward-attempts 3 \ + --reward-retry-delay 5 diff --git a/teammate-evals/nl2repo-pilot/run_qwen104_adaptive_v2_forced_repeats.sh b/teammate-evals/nl2repo-pilot/run_qwen104_adaptive_v2_forced_repeats.sh new file mode 100755 index 0000000..b360033 --- /dev/null +++ b/teammate-evals/nl2repo-pilot/run_qwen104_adaptive_v2_forced_repeats.sh @@ -0,0 +1,79 @@ +#!/bin/zsh +set -euo pipefail + +SCRIPT_DIR=${0:A:h} +REPO_ROOT=${SCRIPT_DIR:h:h} +PYTHON="$REPO_ROOT/.venv/bin/python" +QUEUE="$SCRIPT_DIR/evaluation_queue.py" +GLOBAL_POOL="$SCRIPT_DIR/global_pool_supervisor.py" +RUNS_ROOT="$SCRIPT_DIR/runs" +AGS_ENV="${REPO_ROOT:h}/sandbox/ags/.env" +MODEL="ms-rns547kc" + +if [[ ! -x "$PYTHON" ]]; then + print -u2 "missing Python runtime: $PYTHON" + exit 1 +fi +if [[ ! -f "$AGS_ENV" ]]; then + print -u2 "missing AGS environment: $AGS_ENV" + exit 1 +fi + +# Alternate policies across time so transient endpoint load is less likely to +# bias all samples from one policy in the same direction. Forced-team r1 is the +# completed fixed run from immediately before this queue. +run_ids=( + 20260717-qwen104-adaptive-team-v2-pool32-r1 + 20260717-qwen104-forced-team-fixed-pool32-r2 + 20260717-qwen104-adaptive-team-v2-pool32-r2 + 20260717-qwen104-forced-team-fixed-pool32-r3 + 20260717-qwen104-adaptive-team-v2-pool32-r3 +) +modes=( + adaptive-team-v2 + forced-team + adaptive-team-v2 + forced-team + adaptive-team-v2 +) + +export QWEN_ENABLE_THINKING=1 +export AGS_SCORE_SETUP_CONCURRENCY=8 + +# Materialize every batch up front so the dashboard can switch to queued runs +# before their workers begin. Queue insertion is idempotent on restart. +for (( index = 1; index <= ${#run_ids}; index++ )); do + run_id=${run_ids[$index]} + mode=${modes[$index]} + run_root="$RUNS_ROOT/$run_id" + mkdir -p "$run_root" + print "[$(date -u +%FT%TZ)] preparing $run_id ($mode)" + "$PYTHON" "$QUEUE" --run "$run_root" add \ + --task-set all --mode "$mode" --priority 0 +done + +global_args=() +for run_id in "${run_ids[@]}"; do + global_args+=(--run "$RUNS_ROOT/$run_id") +done + +print "[$(date -u +%FT%TZ)] starting shared rollout=32 reward=32 pools" +exec "$PYTHON" "$GLOBAL_POOL" \ + "${global_args[@]}" \ + --provider qwen \ + --model "$MODEL" \ + --rollout-capacity 32 \ + --reward-capacity 32 \ + --worker-capacity 64 \ + --max-turns 300 \ + --teammate-max-turns 160 \ + --teammate-min-timeout 900 \ + --max-output-tokens 16384 \ + --agent-timeout 7200 \ + --score-timeout 1200 \ + --ags-env-file "$AGS_ENV" \ + --ags-timeout 3h \ + --ags-cpu 2 \ + --ags-memory 4Gi \ + --reward-attempts 3 \ + --reward-retry-delay 5 diff --git a/teammate-evals/nl2repo-pilot/run_qwen104_three_repeats.sh b/teammate-evals/nl2repo-pilot/run_qwen104_three_repeats.sh new file mode 100755 index 0000000..fab18d3 --- /dev/null +++ b/teammate-evals/nl2repo-pilot/run_qwen104_three_repeats.sh @@ -0,0 +1,67 @@ +#!/bin/zsh +set -euo pipefail + +SCRIPT_DIR=${0:A:h} +REPO_ROOT=${SCRIPT_DIR:h:h} +PYTHON="$REPO_ROOT/.venv/bin/python" +QUEUE="$SCRIPT_DIR/evaluation_queue.py" +GLOBAL_POOL="$SCRIPT_DIR/global_pool_supervisor.py" +RUNS_ROOT="$SCRIPT_DIR/runs" +AGS_ENV="${REPO_ROOT:h}/sandbox/ags/.env" +GROUP_ID="20260716-qwen104-both-repeat3-pool32" +MODEL="ms-rns547kc" + +if [[ ! -x "$PYTHON" ]]; then + print -u2 "missing Python runtime: $PYTHON" + exit 1 +fi +if [[ ! -f "$AGS_ENV" ]]; then + print -u2 "missing AGS environment: $AGS_ENV" + exit 1 +fi + +export QWEN_ENABLE_THINKING=1 +export AGS_SCORE_SETUP_CONCURRENCY=8 + +global_args=() +for repeat in 1 2 3; do + run_id="${GROUP_ID}-r${repeat}" + run_root="$RUNS_ROOT/$run_id" + mkdir -p "$run_root" + + print "[$(date -u +%FT%TZ)] preparing $run_id" + "$PYTHON" "$QUEUE" --run "$run_root" add \ + --task-set all --mode adaptive --priority 0 + "$PYTHON" "$QUEUE" --run "$run_root" add \ + --task-set all --mode forced-team --priority 0 + + # The two CLI additions produce one contiguous ID range per mode. Give the + # same rank to the corresponding rows so claims alternate modes task-by-task. + sqlite3 "$run_root/queue.sqlite3" <<'SQL' +WITH ranked AS ( + SELECT id, row_number() OVER (PARTITION BY mode ORDER BY id) AS task_rank + FROM cases +) +UPDATE cases +SET priority = 100000 - ( + SELECT task_rank FROM ranked WHERE ranked.id = cases.id +) +WHERE status = 'queued'; +SQL + global_args+=(--run "$run_root") +done + +print "[$(date -u +%FT%TZ)] starting all repeats in shared rollout=32 reward=4 pools" +exec "$PYTHON" "$GLOBAL_POOL" \ + "${global_args[@]}" \ + --provider qwen \ + --model "$MODEL" \ + --max-turns 300 \ + --teammate-max-turns 80 \ + --rollout-capacity 32 \ + --reward-capacity 4 \ + --worker-capacity 64 \ + --ags-env-file "$AGS_ENV" \ + --rollout-attempts 3 \ + --reward-attempts 3 \ + --reward-retry-delay 5 diff --git a/teammate-evals/nl2repo-pilot/run_r1_remaining_ags.sh b/teammate-evals/nl2repo-pilot/run_r1_remaining_ags.sh new file mode 100755 index 0000000..5cf5a96 --- /dev/null +++ b/teammate-evals/nl2repo-pilot/run_r1_remaining_ags.sh @@ -0,0 +1,6 @@ +#!/bin/zsh +set -euo pipefail + +print -u2 "This historical continuation launcher is disabled: it managed AGS capacity outside the global pool." +print -u2 "Register the unfinished run with global_pool_supervisor.py --run instead." +exit 2 diff --git a/teammate-evals/order-discount/.gitignore b/teammate-evals/order-discount/.gitignore new file mode 100644 index 0000000..65874a4 --- /dev/null +++ b/teammate-evals/order-discount/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +.clawd/ diff --git a/teammate-evals/order-discount/BASELINE.md b/teammate-evals/order-discount/BASELINE.md new file mode 100644 index 0000000..0fa267c --- /dev/null +++ b/teammate-evals/order-discount/BASELINE.md @@ -0,0 +1,135 @@ +# GLM 5.2 Baseline + +Date: 2026-07-12 + +Configuration: + +- Provider: Anthropic-compatible z.ai endpoint +- Model: `glm-5.2` +- Entry point: normal `clawd --stream` REPL +- Workspace: this fixture directory + +Observed behavior before implementing the teammate runtime: + +1. GLM 5.2 received the task and successfully used `Glob` to list the fixture. +2. It initially attempted to read local files with z.ai's HTTP-only `webReader`. +3. Repeated `ToolSearch` queries did not give the model a usable local `Read`, + `Write`, `Edit`, or `Bash` invocation path. +4. No team or teammate sessions were created. +5. No messages or delegated tasks were produced. +6. The model stopped honestly and reported the missing capabilities rather than + claiming that the task had succeeded. +7. Source files were unchanged after the run. + +Initial acceptance result: + +```text +Ran 6 tests +FAILED (failures=2) +``` + +Expected initial failures: + +- Member discount incorrectly reduces shipping: actual `54.00`, expected + `55.00`. +- Store credit incorrectly reduces shipping: actual `0.00`, expected `10.00`. + +This baseline establishes that a successful future run must demonstrate both +working local engineering tools and genuine teammate orchestration. + +## Tool-discovery fix verification + +After improving `ToolSearch`, adding local-tool system guidance, and serializing +Anthropic-compatible tool results as JSON text, a second authorized GLM 5.2 +smoke test succeeded: + +```text +ToolSearch (Read) +Read (./requirements.md) - lines 1-16/16 +``` + +GLM 5.2 returned the first pricing rule directly from the `Read` result. It did +not invoke `Bash`, `cat`, `webReader`, or another web tool. A separate smoke run +also invoked `Bash` successfully and reported the expected acceptance result of +four passing and two failing checks. + +At that point, the remaining evaluation blocker was genuine teammate spawning, +messaging, and task scheduling rather than local engineering tool discovery. + +## Native teammate run + +Date: 2026-07-13 + +After adding the persistent teammate runtime and native trace instrumentation, +a fresh-copy GLM 5.2 run completed the full workflow without manual source +edits. The lead created exactly the required researcher, coder, and reviewer; +scheduled the dependency chain; and received all three required message +handoffs. + +Combined evaluator result: + +```text +Collaboration evidence: PASSED +Ran 6 tests +OK +Business acceptance: PASSED +``` + +Persisted trace summary: + +- 237 native events +- 59 tool calls +- 3 teammate messages +- 67,945 aggregate input and output tokens +- 5 minutes 44 seconds elapsed +- all agents, tasks, and the team completed + +Eight tool attempts failed during the run, including incorrect path casing, +unavailable Python aliases, expected failing baseline tests, and one malformed +review command. Each failure was visible in the trace and the agents recovered +without operator intervention. The trace was recorded natively rather than +reconstructed from teammate sessions. + +## Resilient runtime release gate + +Date: 2026-07-14 + +The release gate was repeated in a clean fixture copy after adding recovery, +leases, retries, cancellation, budgets, parallel scheduling, and worktree +support. The normal user configuration selected the Anthropic-compatible z.ai +endpoint and `glm-5.2`; no credential was copied into the fixture or repository. + +The clean baseline again produced the two expected business failures and no +collaboration evidence. A subsequent real-model run created three teammates, +persisted a three-task dependency chain, exchanged the three required handoff +messages, changed only `src/order.py`, and completed without operator edits. + +Runtime settings selected by the lead: + +- 3 parallel workers +- 10-minute timeout +- 50-turn budget +- 2 automatic retries +- 15-minute task leases + +Combined evaluator result: + +```text +Collaboration evidence: PASSED +Ran 6 tests +OK +Business acceptance: PASSED +``` + +Persisted trace summary: + +- 201 native events +- 49 tool calls: 47 completed and 2 failed +- 3 teammate messages +- 40,165 teammate tokens across 19 turns +- 3 minutes 16 seconds elapsed inside `TeamRun` +- every agent and task completed on its first attempt + +The two failed tool calls were the unavailable `python` alias and the expected +failing baseline test command. The lead recovered by using `python3`, and the +coder and reviewer independently obtained six passing acceptance checks. diff --git a/teammate-evals/order-discount/README.md b/teammate-evals/order-discount/README.md new file mode 100644 index 0000000..56d00ce --- /dev/null +++ b/teammate-evals/order-discount/README.md @@ -0,0 +1,62 @@ +# Order Discount Teammate Evaluation + +This fixture evaluates whether a lead agent can coordinate researcher, coder, +and reviewer teammates to repair a small order-pricing module. + +The implementation intentionally starts with pricing defects. A clean fixture +must fail some acceptance checks before the teammate run and pass all checks +after a successful run. + +## Baseline check + +From this directory: + +```bash +../../.venv/bin/python -m unittest checks.order_acceptance -v +``` + +## Real-model run + +Run this evaluation in a clean copy because the agent intentionally edits +`src/order.py` and persists its team state under `.clawd/`. The normal Clawd +configuration is used. With an Anthropic-compatible z.ai provider configured, +run the task directly from this directory: + +```bash +../../.venv/bin/clawd run \ + --provider anthropic \ + --model glm-5.2 \ + --prompt-file TASK.md \ + --max-turns 100 +``` + +No API key is stored in this fixture. Clawd reads it from the user's normal +`~/.clawd/config.json` configuration or from `ANTHROPIC_AUTH_TOKEN` and +`ANTHROPIC_BASE_URL` environment variables. + +After the run, execute the baseline command again and inspect the team state in +`.clawd/teams/`. The expected collaboration evidence is described in +`acceptance.json`. + +Run the combined automated evaluator from this directory: + +```bash +../../.venv/bin/python evaluate.py +``` + +It verifies the business checks plus teammate records, independent sessions, +task ownership and dependencies, message handoffs, event history, and final +team status. + +Inspect the complete run locally after or during execution: + +```bash +../../.venv/bin/clawd trace . --open +``` + +The viewer shows the event timeline, agent lanes, task graph, tool inputs and +results, message handoffs, failures, timing, and token usage. It follows a run +through server-sent events when opened before execution starts. + +Scheduler recovery, retry, cancellation, budget, parallelism, and worktree +behavior are covered separately by `../runtime-resilience/evaluate.py`. diff --git a/teammate-evals/order-discount/TASK.md b/teammate-evals/order-discount/TASK.md new file mode 100644 index 0000000..0a03262 --- /dev/null +++ b/teammate-evals/order-discount/TASK.md @@ -0,0 +1,51 @@ +# Teammate Evaluation Task + +Repair the member discount behavior in this order calculator. You must perform +the work as a teammate workflow rather than solving it as a single agent. + +Use exactly these teammates: + +- `researcher`: read `requirements.md` and the existing implementation, then + identify every pricing rule and edge case. This teammate must not edit files. +- `coder`: wait until the researcher sends its findings, then implement the + repair and run the acceptance checks. +- `reviewer`: wait for the coder's task, independently inspect the diff and run + all acceptance checks. If any defect remains, send a concrete message to the + coder and require a repair before approving. + +The lead agent must: + +1. Create the team and all three teammate records. +2. Create separate analysis, implementation, and review tasks with explicit + dependencies. +3. Ensure the researcher sends its findings to the coder with `SendMessage`. +4. Ensure the coder reports its implementation and test result to the reviewer. +5. Ensure the reviewer reports approval or requested changes to the lead. +6. Wait for all tasks to complete before producing the final response. +7. Summarize changed files, test results, messages exchanged, and each + teammate's contribution. + +Use `TeammateCreate` for each teammate and give it an explicit tool allowlist: + +- researcher: `Read`, `Glob`, `Grep` +- coder: `Read`, `Glob`, `Grep`, `Write`, `Edit`, `Bash` +- reviewer: `Read`, `Glob`, `Grep`, `Bash` + +Create tasks with `TaskCreate` keys `analysis`, `implementation`, and `review`. +Set each task's `owner` to the matching teammate name and declare `blockedBy` +using the preceding task key. After setup, call `TeamRun`; it schedules ready +tasks, delivers teammate messages, and completes the team only after all tasks +succeed. `SendMessage` determines the sender from the active teammate session. + +Do not modify `requirements.md`, `TASK.md`, or files under `checks/`. Do not +weaken or delete tests. The final command below must pass: + +```bash +python -m unittest checks.order_acceptance -v +``` + +The complete business-and-collaboration evaluation must also pass: + +```bash +python evaluate.py +``` diff --git a/teammate-evals/order-discount/acceptance.json b/teammate-evals/order-discount/acceptance.json new file mode 100644 index 0000000..184b47d --- /dev/null +++ b/teammate-evals/order-discount/acceptance.json @@ -0,0 +1,41 @@ +{ + "model": "glm-5.2", + "required_agents": [ + "researcher", + "coder", + "reviewer" + ], + "required_tasks": [ + { + "name": "analysis", + "owner": "researcher", + "blocked_by": [] + }, + { + "name": "implementation", + "owner": "coder", + "blocked_by": ["analysis"] + }, + { + "name": "review", + "owner": "reviewer", + "blocked_by": ["implementation"] + } + ], + "required_messages": [ + { + "from": "researcher", + "to": "coder" + }, + { + "from": "coder", + "to": "reviewer" + }, + { + "from": "reviewer", + "to": "lead" + } + ], + "required_test_command": "python -m unittest checks.order_acceptance -v", + "required_final_status": "completed" +} diff --git a/teammate-evals/order-discount/checks/__init__.py b/teammate-evals/order-discount/checks/__init__.py new file mode 100644 index 0000000..12c5600 --- /dev/null +++ b/teammate-evals/order-discount/checks/__init__.py @@ -0,0 +1 @@ +"""Acceptance checks for the order-discount teammate evaluation.""" diff --git a/teammate-evals/order-discount/checks/order_acceptance.py b/teammate-evals/order-discount/checks/order_acceptance.py new file mode 100644 index 0000000..6f22ca8 --- /dev/null +++ b/teammate-evals/order-discount/checks/order_acceptance.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import unittest +from decimal import Decimal + +from src.order import LineItem, Order + + +D = Decimal + + +class OrderAcceptanceChecks(unittest.TestCase): + def test_non_member_pays_merchandise_and_shipping(self) -> None: + order = Order(items=(LineItem(D("40.00")),), shipping_fee=D("10.00")) + self.assertEqual(order.total(), D("50.00")) + + def test_member_discount_does_not_reduce_shipping(self) -> None: + order = Order( + items=(LineItem(D("50.00")),), + is_member=True, + shipping_fee=D("10.00"), + ) + self.assertEqual(order.total(), D("55.00")) + + def test_free_shipping_uses_pre_discount_subtotal(self) -> None: + order = Order( + items=(LineItem(D("50.00"), quantity=2),), + is_member=True, + shipping_fee=D("10.00"), + ) + self.assertEqual(order.total(), D("90.00")) + + def test_store_credit_cannot_reduce_shipping(self) -> None: + order = Order( + items=(LineItem(D("20.00")),), + is_member=True, + shipping_fee=D("10.00"), + store_credit=D("30.00"), + ) + self.assertEqual(order.total(), D("10.00")) + + def test_rounding_uses_half_up(self) -> None: + order = Order( + items=(LineItem(D("10.05")),), + is_member=True, + shipping_fee=D("0.00"), + ) + self.assertEqual(order.total(), D("9.05")) + + def test_invalid_values_are_rejected(self) -> None: + with self.assertRaises(ValueError): + LineItem(D("-1.00")) + with self.assertRaises(ValueError): + LineItem(D("1.00"), quantity=0) + with self.assertRaises(ValueError): + Order(items=(), shipping_fee=D("-1.00")) + with self.assertRaises(ValueError): + Order(items=(), store_credit=D("-1.00")) + + +if __name__ == "__main__": + unittest.main() diff --git a/teammate-evals/order-discount/evaluate.py b/teammate-evals/order-discount/evaluate.py new file mode 100644 index 0000000..84ab42a --- /dev/null +++ b/teammate-evals/order-discount/evaluate.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +import argparse +import json +import re +import shlex +import subprocess +import sys +from pathlib import Path +from typing import Any + + +def _read_json(path: Path) -> dict[str, Any]: + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError(f"expected a JSON object in {path}") + return data + + +def _normalize(value: str) -> str: + return re.sub(r"[^a-z0-9]+", "-", value.strip().lower()).strip("-") + + +def verify_collaboration(workspace: Path) -> list[str]: + errors: list[str] = [] + acceptance = _read_json(workspace / "acceptance.json") + active_path = workspace / ".clawd" / "team.json" + if not active_path.exists(): + return ["missing active team state: .clawd/team.json"] + team = _read_json(active_path) + team_id = str(team.get("team_id") or "") + team_dir = workspace / ".clawd" / "teams" / team_id + if not team_id or not team_dir.is_dir(): + return [f"missing team directory for team_id={team_id!r}"] + + agent_records = [ + _read_json(path) for path in sorted((team_dir / "agents").glob("*.json")) + ] + agents_by_name = { + str(agent.get("name") or "").lower(): agent for agent in agent_records + } + required_agent_names = { + str(name).lower() for name in acceptance.get("required_agents", []) + } + extra_agents = sorted(set(agents_by_name) - required_agent_names) + if extra_agents: + errors.append(f"unexpected teammates: {', '.join(extra_agents)}") + agent_names_by_id = { + str(agent.get("agent_id")): str(agent.get("name")) for agent in agent_records + } + agent_names_by_id[str(team.get("lead_agent_id"))] = "lead" + for required_name in acceptance.get("required_agents", []): + agent = agents_by_name.get(str(required_name).lower()) + if agent is None: + errors.append(f"missing teammate: {required_name}") + continue + session_id = str(agent.get("session_id") or "") + session_path = team_dir / "sessions" / f"{session_id}.json" + if not session_path.exists(): + errors.append(f"missing session for teammate: {required_name}") + continue + conversation = _read_json(session_path).get("conversation") + messages = conversation.get("messages") if isinstance(conversation, dict) else None + if not isinstance(messages, list) or not messages: + errors.append(f"empty session for teammate: {required_name}") + + tasks_data = _read_json(team_dir / "tasks.json") + tasks = [task for task in tasks_data.values() if isinstance(task, dict)] + tasks_by_key = { + _normalize(str(task.get("key") or task.get("subject") or "")): task + for task in tasks + } + task_keys_by_id = { + str(task.get("id")): _normalize(str(task.get("key") or task.get("subject") or "")) + for task in tasks + } + for required in acceptance.get("required_tasks", []): + required_key = _normalize(str(required.get("name") or "")) + task = tasks_by_key.get(required_key) + if task is None: + errors.append(f"missing task: {required_key}") + continue + owner_name = agent_names_by_id.get(str(task.get("owner")), str(task.get("owner"))) + if owner_name != required.get("owner"): + errors.append( + f"task {required_key} owner is {owner_name!r}, expected {required.get('owner')!r}" + ) + actual_dependencies = sorted( + task_keys_by_id.get(str(task_id), str(task_id)) + for task_id in task.get("blockedBy") or [] + ) + expected_dependencies = sorted(str(item) for item in required.get("blocked_by") or []) + if actual_dependencies != expected_dependencies: + errors.append( + f"task {required_key} dependencies are {actual_dependencies}, expected {expected_dependencies}" + ) + if task.get("status") != "completed": + errors.append(f"task {required_key} status is {task.get('status')!r}, expected 'completed'") + + message_pairs: set[tuple[str, str]] = set() + for path in sorted((team_dir / "messages").glob("*.json")): + message = _read_json(path) + sender = agent_names_by_id.get(str(message.get("sender_id")), str(message.get("sender_id"))) + recipient = agent_names_by_id.get( + str(message.get("recipient_id")), str(message.get("recipient_id")) + ) + message_pairs.add((sender, recipient)) + for required in acceptance.get("required_messages", []): + pair = (str(required.get("from")), str(required.get("to"))) + if pair not in message_pairs: + errors.append(f"missing message handoff: {pair[0]} -> {pair[1]}") + + expected_status = acceptance.get("required_final_status") + if team.get("status") != expected_status: + errors.append( + f"team status is {team.get('status')!r}, expected {expected_status!r}" + ) + events_path = team_dir / "events.jsonl" + if not events_path.exists() or not events_path.read_text(encoding="utf-8").strip(): + errors.append("missing team event log") + return errors + + +def run_business_tests(workspace: Path) -> subprocess.CompletedProcess[str]: + acceptance = _read_json(workspace / "acceptance.json") + command = shlex.split(str(acceptance["required_test_command"])) + if command and command[0] in {"python", "python3"}: + command[0] = sys.executable + return subprocess.run(command, cwd=workspace, capture_output=True, text=True) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Evaluate the order-discount teammate workflow.") + parser.add_argument("--workspace", type=Path, default=Path(__file__).resolve().parent) + parser.add_argument("--collaboration-only", action="store_true") + args = parser.parse_args() + workspace = args.workspace.resolve() + + errors = verify_collaboration(workspace) + if errors: + print("Collaboration evidence: FAILED") + for error in errors: + print(f"- {error}") + else: + print("Collaboration evidence: PASSED") + + tests_ok = True + if not args.collaboration_only: + completed = run_business_tests(workspace) + if completed.stdout: + print(completed.stdout, end="") + if completed.stderr: + print(completed.stderr, end="", file=sys.stderr) + tests_ok = completed.returncode == 0 + print(f"Business acceptance: {'PASSED' if tests_ok else 'FAILED'}") + return 0 if not errors and tests_ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/teammate-evals/order-discount/requirements.md b/teammate-evals/order-discount/requirements.md new file mode 100644 index 0000000..e554863 --- /dev/null +++ b/teammate-evals/order-discount/requirements.md @@ -0,0 +1,16 @@ +# Order Pricing Requirements + +The calculator works with monetary values represented by `Decimal`. + +1. The merchandise subtotal is the sum of each unit price multiplied by its + quantity. +2. Members receive a 10% discount on merchandise only. +3. Shipping is never discounted. +4. Orders with a merchandise subtotal of at least 100 receive free shipping. + Eligibility is determined from the original subtotal before member discount + or store credit. +5. Store credit is applied after the member discount and only to merchandise. + Credit may reduce merchandise to zero but must never reduce shipping. +6. The final total is rounded to two decimal places using `ROUND_HALF_UP`. +7. Quantity must be a positive integer. Unit price, shipping fee, and store + credit must be non-negative. diff --git a/teammate-evals/order-discount/src/__init__.py b/teammate-evals/order-discount/src/__init__.py new file mode 100644 index 0000000..a6cd68c --- /dev/null +++ b/teammate-evals/order-discount/src/__init__.py @@ -0,0 +1,3 @@ +from .order import LineItem, Order + +__all__ = ["LineItem", "Order"] diff --git a/teammate-evals/order-discount/src/order.py b/teammate-evals/order-discount/src/order.py new file mode 100644 index 0000000..a9b1a3b --- /dev/null +++ b/teammate-evals/order-discount/src/order.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal, ROUND_HALF_UP + + +CENT = Decimal("0.01") +MEMBER_RATE = Decimal("0.10") +FREE_SHIPPING_THRESHOLD = Decimal("100.00") + + +def _money(value: Decimal) -> Decimal: + return value.quantize(CENT, rounding=ROUND_HALF_UP) + + +@dataclass(frozen=True) +class LineItem: + unit_price: Decimal + quantity: int = 1 + + def __post_init__(self) -> None: + if self.unit_price < 0: + raise ValueError("unit_price must be non-negative") + if not isinstance(self.quantity, int) or isinstance(self.quantity, bool) or self.quantity <= 0: + raise ValueError("quantity must be a positive integer") + + @property + def total(self) -> Decimal: + return self.unit_price * self.quantity + + +@dataclass(frozen=True) +class Order: + items: tuple[LineItem, ...] + is_member: bool = False + shipping_fee: Decimal = Decimal("10.00") + store_credit: Decimal = Decimal("0.00") + + def __post_init__(self) -> None: + if self.shipping_fee < 0: + raise ValueError("shipping_fee must be non-negative") + if self.store_credit < 0: + raise ValueError("store_credit must be non-negative") + + @property + def merchandise_subtotal(self) -> Decimal: + return sum((item.total for item in self.items), start=Decimal("0.00")) + + def total(self) -> Decimal: + subtotal = self.merchandise_subtotal + shipping = Decimal("0.00") if subtotal >= FREE_SHIPPING_THRESHOLD else self.shipping_fee + payable = subtotal + shipping + if self.is_member: + payable *= Decimal("1.00") - MEMBER_RATE + payable = max(Decimal("0.00"), payable - self.store_credit) + return _money(payable) diff --git a/teammate-evals/peer-collaboration/README.md b/teammate-evals/peer-collaboration/README.md new file mode 100644 index 0000000..ade2a00 --- /dev/null +++ b/teammate-evals/peer-collaboration/README.md @@ -0,0 +1,125 @@ +# Peer-native collaboration benchmark + +This directory evaluates whether complete coding agents can coordinate as equal +peers without a privileged LLM lead, a pre-created task graph, assigned owners, +or fixed professions. It is an inference-only benchmark: no model training, +fine-tuning, reinforcement learning, learned routing, or learned policy is used. + +The peer runtime is separate from the existing lead-controlled teammate runtime. +A non-intelligent supervisor only creates sessions and workspaces, enforces +budgets and timeouts, records events, wakes idle sessions, validates Git +revisions, and stops the run after the first valid submission. + +## Conditions + +- `solo`: one agent; no peer message tools. +- `independent` / `none`: multiple agents with the same mission and no message tools. +- `artifact-only`: multiple agents with no message tools; coordination can occur + only through repository state, commits, or other artifacts visible under the + selected workspace mode. +- `star`: every peer has the same code capabilities and budget, but message + transport permits only edges that touch the designated coordinator peer. +- `p2p`: any peer can directly message or broadcast to any other peer. + +Communication policy is enforced in the tool registry and transport ACL. Prompt +text is not used as an access-control mechanism. Workspace visibility is an +orthogonal experimental variable: `shared` deliberately exposes concurrent file +effects, while `worktree` starts each peer at the same Git revision in an +isolated detached worktree. Consequently, `independent + shared` still exposes +incidental file effects even though it has no messaging channel; researchers +should normally use `independent + worktree` for a strict independent baseline. + +## Scripted smoke + +The deterministic coupled fixture requires one peer to publish an item +normalization interface and another peer to consume it before implementing the +client. It exercises persistent sessions, direct delivery, idle wakeup, shared +workspace edits, Git submission, acceptance testing, and schema validation +without an API key: + +```bash +.venv/bin/python teammate-evals/peer-collaboration/scripted_smoke.py \ + --output-dir /tmp/clawd-peer-smoke +``` + +The command must exit zero and the saved `result.json` must show a consumed +message, an accepted submission, no orphan threads, and acceptance exit code 0. + +## Real-model pilot + +Real calls are explicit opt-in and are never run by the ordinary test suite. For +the configured GLM-5.2 Anthropic-compatible endpoint: + +```bash +.venv/bin/python teammate-evals/peer-collaboration/runner.py \ + --repo /path/to/clean/git/repository \ + --prompt-file TASK.md \ + --peers 2 \ + --communication p2p \ + --workspace-mode worktree \ + --provider anthropic \ + --model glm-5.2 \ + --timeout-seconds 600 \ + --max-turns 20 \ + --token-budget 100000 \ + --output-dir /tmp/glm52-peer-pilot \ + --acceptance-command 'python -m pytest -q' +``` + +The equivalent product CLI is: + +```bash +.venv/bin/clawd peer run \ + --repo /path/to/repository \ + --prompt-file TASK.md \ + --peers 2 \ + --communication p2p \ + --workspace-mode worktree \ + --provider anthropic \ + --model glm-5.2 \ + --output-dir /tmp/peer-run +``` + +## Persistent artifacts + +Each run directory contains: + +- `manifest.json`: mission, repository revision, condition, peer count, + provider/model, budgets, workspace mode, backend, participant IDs, and exact + tool surfaces; +- `run.json`: mutable lifecycle, aggregate usage, stop reason, and accepted + submission; +- `participants/*.json`: stable IDs, session IDs, status timestamps, workspace, + and per-peer usage; +- `sessions/*.json`: persistent conversation state and model-boundary index; +- `messages/*.json`: payload plus created, delivered, and consumed state; +- `broadcasts/*.json`: broadcast ID and the exact per-recipient deliveries; +- `submissions/*.json`: accepted, rejected, and already-submitted attempts; +- `events.jsonl`: auditable events with UTC wall-clock and monotonic timestamps; +- `result.json`: terminal summary, commit attribution, acceptance stdout/stderr + and exit code, usage, wall time, and cleanup outcome. + +Schemas are in `schemas/`; `schema_validation.py` validates the saved manifest +and result without adding a runtime dependency. + +The trace supports offline reconstruction of message edges, delivery and +consumption latency, response latency, volume, policy rejections, aggregate and +per-peer tokens/calls, workspace heads, commits, submit races, wall time, and +acceptance quality. Repeated work, stale work, conflicts, and rework remain +analysis-layer proxies derived from tool/file/commit events; the runtime does +not declare causal or scaling conclusions. + +## Limitations + +- The first backend uses threads and cooperative stop checks. It is not process + isolation. A provider call that ignores its own network timeout cannot be + safely killed by Python; the guarded registry still rejects later tool calls. +- Claude Code CLI and Codex CLI process adapters are not implemented. The small + session backend protocol is designed to accept them later. +- Local Git repositories support both workspace modes. Remote sandbox worktree + isolation requires one sandbox per peer or an explicit synchronization layer. +- Token budgets stop new work after reported usage crosses the limit; already + in-flight concurrent calls can cause bounded overshoot. +- Dynamic peer recruitment and resizing are outside this version. +- One run is one observation. Statistical aggregation and causal claims belong + in a separate analysis layer. diff --git a/teammate-evals/peer-collaboration/fixture/TASK.md b/teammate-evals/peer-collaboration/fixture/TASK.md new file mode 100644 index 0000000..f9bf401 --- /dev/null +++ b/teammate-evals/peer-collaboration/fixture/TASK.md @@ -0,0 +1,11 @@ +# Coupled peer task + +Implement a tiny item normalization contract split across `src/protocol.py` and +`src/client.py`. + +`normalize_item(raw)` must return a dictionary with a string `id` and a tuple of +trimmed, non-empty string `tags`. `build_request(raw)` must consume that exact +normalized representation and return `{"item": normalized}`. + +The two components must agree on the interface; run the acceptance tests and +submit the final Git revision. diff --git a/teammate-evals/peer-collaboration/fixture/src/__init__.py b/teammate-evals/peer-collaboration/fixture/src/__init__.py new file mode 100644 index 0000000..114be5f --- /dev/null +++ b/teammate-evals/peer-collaboration/fixture/src/__init__.py @@ -0,0 +1 @@ +"""Coupled peer smoke fixture.""" diff --git a/teammate-evals/peer-collaboration/fixture/tests/test_acceptance.py b/teammate-evals/peer-collaboration/fixture/tests/test_acceptance.py new file mode 100644 index 0000000..c824201 --- /dev/null +++ b/teammate-evals/peer-collaboration/fixture/tests/test_acceptance.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import unittest + +from src.client import build_request +from src.protocol import normalize_item + + +class AcceptanceTests(unittest.TestCase): + def test_normalize_contract(self) -> None: + self.assertEqual( + normalize_item({"id": 7, "tags": [" alpha ", "", "beta"]}), + {"id": "7", "tags": ("alpha", "beta")}, + ) + + def test_client_uses_normalized_representation(self) -> None: + self.assertEqual( + build_request({"id": "x", "tags": ["one"]}), + {"item": {"id": "x", "tags": ("one",)}}, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/teammate-evals/peer-collaboration/runner.py b/teammate-evals/peer-collaboration/runner.py new file mode 100644 index 0000000..207d6db --- /dev/null +++ b/teammate-evals/peer-collaboration/runner.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import argparse +import json +import shlex +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent +REPO_ROOT = ROOT.parents[1] + + +def main() -> int: + parser = argparse.ArgumentParser(description="Run a real-model peer collaboration pilot.") + parser.add_argument("--repo", type=Path, required=True) + parser.add_argument("--prompt-file", type=Path, required=True) + parser.add_argument("--peers", type=int, required=True) + parser.add_argument( + "--communication", + required=True, + choices=("solo", "independent", "none", "artifact-only", "star", "p2p"), + ) + parser.add_argument("--workspace-mode", choices=("shared", "worktree"), required=True) + parser.add_argument("--provider", required=True) + parser.add_argument("--model", required=True) + parser.add_argument("--timeout-seconds", type=float, default=600) + parser.add_argument("--max-turns", type=int, default=30) + parser.add_argument("--max-output-tokens", type=int, default=4096) + parser.add_argument("--token-budget", type=int) + parser.add_argument("--turn-budget", type=int) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--coordinator-peer") + parser.add_argument("--acceptance-command") + args = parser.parse_args() + if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + from src.peer.runner import run_peer_collaboration + + repo = args.repo.expanduser().resolve() + prompt = args.prompt_file.expanduser() + if not prompt.is_absolute(): + prompt = repo / prompt + result = run_peer_collaboration( + prompt.read_text(encoding="utf-8"), + repo=repo, + peers=args.peers, + communication=args.communication, + workspace_mode=args.workspace_mode, + provider_name=args.provider, + model=args.model, + timeout_seconds=args.timeout_seconds, + max_turns=args.max_turns, + max_output_tokens=args.max_output_tokens, + token_budget=args.token_budget, + turn_budget=args.turn_budget, + output_dir=args.output_dir, + coordinator_peer=args.coordinator_peer, + acceptance_command=( + shlex.split(args.acceptance_command) if args.acceptance_command else None + ), + ) + print(json.dumps(result, ensure_ascii=False, indent=2)) + if result["status"] != "completed": + return 2 + if result.get("acceptance") and result["acceptance"]["exit_code"] != 0: + return 3 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/teammate-evals/peer-collaboration/schema_validation.py b/teammate-evals/peer-collaboration/schema_validation.py new file mode 100644 index 0000000..8c31ae2 --- /dev/null +++ b/teammate-evals/peer-collaboration/schema_validation.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parent +SCHEMAS = ROOT / "schemas" + + +def _matches_type(value: Any, expected: str) -> bool: + if expected == "object": + return isinstance(value, dict) + if expected == "array": + return isinstance(value, list) + if expected == "string": + return isinstance(value, str) + if expected == "integer": + return isinstance(value, int) and not isinstance(value, bool) + if expected == "number": + return isinstance(value, (int, float)) and not isinstance(value, bool) + if expected == "boolean": + return isinstance(value, bool) + return True + + +def _validate(value: Any, schema: dict[str, Any], path: str) -> list[str]: + errors: list[str] = [] + expected = schema.get("type") + if isinstance(expected, str) and not _matches_type(value, expected): + return [f"{path}: expected {expected}, got {type(value).__name__}"] + if isinstance(value, dict): + for key in schema.get("required", []): + if key not in value: + errors.append(f"{path}: missing required property {key!r}") + properties = schema.get("properties", {}) + for key, child in properties.items(): + if key in value and isinstance(child, dict): + errors.extend(_validate(value[key], child, f"{path}.{key}")) + if isinstance(value, list) and isinstance(schema.get("items"), dict): + for index, item in enumerate(value): + errors.extend(_validate(item, schema["items"], f"{path}[{index}]")) + return errors + + +def validate_file(document: str | Path, schema_name: str) -> list[str]: + value = json.loads(Path(document).read_text(encoding="utf-8")) + schema = json.loads((SCHEMAS / schema_name).read_text(encoding="utf-8")) + return _validate(value, schema, "$") + + +def validate_run(run_dir: str | Path) -> None: + directory = Path(run_dir) + errors = [ + *validate_file(directory / "manifest.json", "manifest.schema.json"), + *validate_file(directory / "result.json", "result.schema.json"), + ] + if errors: + raise ValueError("peer run schema validation failed:\n" + "\n".join(errors)) diff --git a/teammate-evals/peer-collaboration/schemas/manifest.schema.json b/teammate-evals/peer-collaboration/schemas/manifest.schema.json new file mode 100644 index 0000000..247c19d --- /dev/null +++ b/teammate-evals/peer-collaboration/schemas/manifest.schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Clawd peer collaboration manifest", + "type": "object", + "required": [ + "schema_version", "run_id", "created_at", "config", "repo_path", + "repo_revision", "mission_sha256", "mission", "peer_ids", "backend", + "noncommunication_tools", "communication_tools", "process_isolation" + ], + "properties": { + "schema_version": {"type": "integer"}, + "run_id": {"type": "string"}, + "created_at": {"type": "string"}, + "config": { + "type": "object", + "required": [ + "repo_path", "mission", "peers", "communication", "workspace_mode", + "provider", "timeout_seconds", "max_turns", "max_output_tokens", + "cleanup_worktrees" + ] + }, + "repo_path": {"type": "string"}, + "repo_revision": {"type": "string"}, + "mission_sha256": {"type": "string"}, + "mission": {"type": "string"}, + "peer_ids": {"type": "array", "items": {"type": "string"}}, + "backend": {"type": "string"}, + "noncommunication_tools": {"type": "array", "items": {"type": "string"}}, + "communication_tools": {"type": "array", "items": {"type": "string"}}, + "process_isolation": {"type": "boolean"} + } +} diff --git a/teammate-evals/peer-collaboration/schemas/result.schema.json b/teammate-evals/peer-collaboration/schemas/result.schema.json new file mode 100644 index 0000000..caf4245 --- /dev/null +++ b/teammate-evals/peer-collaboration/schemas/result.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Clawd peer collaboration result", + "type": "object", + "required": [ + "schema_version", "run_id", "status", "stop_reason", "run", "participants", + "messages", "broadcasts", "submissions", "workspace_attribution", "usage", + "wall_time_seconds", "orphan_threads", "retained_worktrees", "manifest_path", + "events_path", "result_path" + ], + "properties": { + "schema_version": {"type": "integer"}, + "run_id": {"type": "string"}, + "status": {"type": "string"}, + "run": {"type": "object"}, + "participants": {"type": "array", "items": {"type": "object"}}, + "messages": {"type": "array", "items": {"type": "object"}}, + "broadcasts": {"type": "array", "items": {"type": "object"}}, + "submissions": {"type": "array", "items": {"type": "object"}}, + "workspace_attribution": {"type": "array", "items": {"type": "object"}}, + "usage": {"type": "object"}, + "wall_time_seconds": {"type": "number"}, + "orphan_threads": {"type": "array", "items": {"type": "string"}}, + "retained_worktrees": {"type": "array", "items": {"type": "string"}}, + "manifest_path": {"type": "string"}, + "events_path": {"type": "string"}, + "result_path": {"type": "string"} + } +} diff --git a/teammate-evals/peer-collaboration/scripted_smoke.py b/teammate-evals/peer-collaboration/scripted_smoke.py new file mode 100644 index 0000000..39c6ace --- /dev/null +++ b/teammate-evals/peer-collaboration/scripted_smoke.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +import argparse +import importlib.util +import json +import shutil +import subprocess +import sys +import tempfile +import threading +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parent +REPO_ROOT = ROOT.parents[1] +FIXTURE = ROOT / "fixture" + + +def _load_schema_module() -> Any: + path = ROOT / "schema_validation.py" + spec = importlib.util.spec_from_file_location("peer_schema_validation", path) + if spec is None or spec.loader is None: + raise RuntimeError("failed to load schema validator") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _run(command: list[str], cwd: Path) -> str: + return subprocess.run( + command, cwd=cwd, check=True, capture_output=True, text=True + ).stdout.strip() + + +def prepare_workspace(destination: Path) -> Path: + workspace = destination / "workspace" + shutil.copytree(FIXTURE, workspace) + _run(["git", "init", "-q"], workspace) + _run(["git", "config", "user.name", "Clawd Peer Smoke"], workspace) + _run(["git", "config", "user.email", "peer-smoke@example.invalid"], workspace) + _run(["git", "add", "."], workspace) + _run(["git", "commit", "-qm", "peer smoke fixture"], workspace) + return workspace + + +def run_smoke(output_dir: Path) -> dict[str, Any]: + if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + from src.peer.backend import PeerBoundaryResult, ScriptedPeerBackend + from src.peer.runner import run_peer_collaboration + from src.tool_system.defaults import build_default_registry + from src.tool_system.protocol import ToolCall + + output_dir.mkdir(parents=True, exist_ok=True) + workspace = prepare_workspace(output_dir) + peer_two_initially_idle = threading.Event() + + protocol_source = '''from __future__ import annotations + +from typing import Any + + +def normalize_item(raw: dict[str, Any]) -> dict[str, Any]: + tags = tuple(str(tag).strip() for tag in raw.get("tags", ()) if str(tag).strip()) + return {"id": str(raw["id"]), "tags": tags} +''' + client_source = '''from __future__ import annotations + +from typing import Any + +from .protocol import normalize_item + + +def build_request(raw: dict[str, Any]) -> dict[str, Any]: + return {"item": normalize_item(raw)} +''' + + def handler(session, prompt, registry, context): + if session.spec.peer_name == "peer-2" and session.boundary_index == 1: + peer_two_initially_idle.set() + return PeerBoundaryResult(response_text="waiting for interface", num_turns=1) + if session.spec.peer_name == "peer-1" and session.boundary_index == 1: + if not peer_two_initially_idle.wait(2): + raise RuntimeError("peer-2 did not reach its idle boundary") + written = registry.dispatch( + ToolCall( + "Write", + {"file_path": "src/protocol.py", "content": protocol_source}, + ), + context, + ) + if written.is_error: + raise RuntimeError(str(written.output)) + sent = registry.dispatch( + ToolCall( + "SendMessage", + { + "to": "peer-2", + "summary": "normalization interface", + "message": { + "function": "normalize_item(raw)", + "returns": {"id": "str", "tags": "tuple[str, ...]"}, + }, + }, + ), + context, + ) + if sent.is_error: + raise RuntimeError(str(sent.output)) + return PeerBoundaryResult(response_text="interface sent", num_turns=1) + if session.spec.peer_name == "peer-2" and session.boundary_index == 2: + if "normalize_item" not in prompt or "tuple[str, ...]" not in prompt: + raise RuntimeError("peer-2 did not receive the interface contract") + written = registry.dispatch( + ToolCall( + "Write", + {"file_path": "src/client.py", "content": client_source}, + ), + context, + ) + if written.is_error: + raise RuntimeError(str(written.output)) + tested = registry.dispatch( + ToolCall( + "Bash", + { + "command": ( + f"{sys.executable} -m unittest discover -s tests -v && " + "git add src/protocol.py src/client.py && " + "git commit -m 'implement negotiated item contract'" + ) + }, + ), + context, + ) + if tested.is_error: + raise RuntimeError(str(tested.output)) + revision = _run(["git", "rev-parse", "HEAD"], Path(session.spec.workspace_path)) + submitted = registry.dispatch( + ToolCall( + "PeerSubmit", + { + "revision": revision, + "summary": "Negotiated, implemented, and tested the shared interface.", + }, + ), + context, + ) + if submitted.is_error: + raise RuntimeError(str(submitted.output)) + return PeerBoundaryResult(response_text="submitted", num_turns=1) + return PeerBoundaryResult(response_text="available", num_turns=1) + + mission = (workspace / "TASK.md").read_text(encoding="utf-8") + result = run_peer_collaboration( + mission, + repo=workspace, + peers=2, + communication="p2p", + workspace_mode="shared", + timeout_seconds=10, + max_turns=8, + output_dir=output_dir / "runs", + acceptance_command=[ + sys.executable, + "-m", + "unittest", + "discover", + "-s", + "tests", + "-v", + ], + backend=ScriptedPeerBackend(handler), + base_registry=build_default_registry(include_user_tools=False), + run_id="scripted-smoke", + ) + run_dir = Path(result["result_path"]).parent + _load_schema_module().validate_run(run_dir) + if result["status"] != "completed": + raise RuntimeError(f"scripted smoke did not complete: {result['status']}") + if result.get("acceptance", {}).get("exit_code") != 0: + raise RuntimeError("scripted smoke acceptance failed") + if not result["messages"] or not result["accepted_submission"]: + raise RuntimeError("scripted smoke did not exercise messaging and submission") + return result + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output-dir", type=Path) + args = parser.parse_args() + if args.output_dir is None: + temporary = tempfile.TemporaryDirectory(prefix="clawd-peer-smoke-") + output = Path(temporary.name) + else: + temporary = None + output = args.output_dir.expanduser().resolve() + try: + result = run_smoke(output) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + finally: + if temporary is not None: + temporary.cleanup() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/teammate-evals/runtime-resilience/README.md b/teammate-evals/runtime-resilience/README.md new file mode 100644 index 0000000..85b64d6 --- /dev/null +++ b/teammate-evals/runtime-resilience/README.md @@ -0,0 +1,28 @@ +# Teammate Runtime Resilience Evaluation + +This deterministic evaluation exercises scheduler behavior that is difficult +to verify reliably with one live-model task: + +- expired-lease crash recovery +- automatic and lead-requested retries +- reviewer rejection followed by coder repair and re-review +- cooperative cancellation +- lead-only worker stop with task requeue and survivor progress +- timeout, token, and turn budgets +- parallel ready-task execution without lost task updates +- isolated git worktree execution and integration + +Run every scenario from the repository root: + +```bash +.venv/bin/python teammate-evals/runtime-resilience/evaluate.py +``` + +Run one scenario: + +```bash +.venv/bin/python teammate-evals/runtime-resilience/evaluate.py --scenario crash-resume +``` + +The evaluator uses scripted providers and temporary workspaces, so it does not +require an API key and does not modify the repository under test. diff --git a/teammate-evals/runtime-resilience/evaluate.py b/teammate-evals/runtime-resilience/evaluate.py new file mode 100644 index 0000000..400798c --- /dev/null +++ b/teammate-evals/runtime-resilience/evaluate.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import argparse +import sys +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + + +SCENARIOS = { + "crash-resume": ( + "tests.test_teammate_resilience.TestTeammateResilience." + "test_recovers_expired_in_progress_lease" + ), + "retry": ( + "tests.test_teammate_resilience.TestTeammateResilience." + "test_automatically_retries_transient_failure" + ), + "review-reject": ( + "tests.test_teammate_resilience.TestTeammateResilience." + "test_reviewer_rejection_can_drive_repair_and_re_review" + ), + "cancel": ( + "tests.test_teammate_resilience.TestTeammateResilience." + "test_cooperative_cancel_is_observed_after_active_model_call" + ), + "worker-stop": ( + "tests.test_teammate_resilience.TestTeammateResilience." + "test_lead_stops_one_worker_without_cancelling_team" + ), + "budgets": ( + "tests.test_teammate_resilience.TestTeammateResilience." + "test_turn_budget_limits_model_round_trips" + ), + "parallel": ( + "tests.test_teammate_resilience.TestTeammateResilience." + "test_ready_tasks_run_in_parallel_without_lost_updates" + ), + "worktree": ( + "tests.test_teammate_resilience.TestTeammateWorktree." + "test_auto_integrates_isolated_teammate_changes" + ), +} + + +def main() -> int: + parser = argparse.ArgumentParser(description="Evaluate teammate runtime resilience.") + parser.add_argument( + "--scenario", + action="append", + choices=sorted(SCENARIOS), + help="Run only the selected scenario; repeat for multiple scenarios.", + ) + args = parser.parse_args() + selected = args.scenario or list(SCENARIOS) + + loader = unittest.TestLoader() + suite = unittest.TestSuite( + loader.loadTestsFromName(SCENARIOS[name]) for name in selected + ) + print(f"Teammate resilience scenarios: {', '.join(selected)}") + result = unittest.TextTestRunner(verbosity=2).run(suite) + print(f"Resilience evaluation: {'PASSED' if result.wasSuccessful() else 'FAILED'}") + return 0 if result.wasSuccessful() else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/teammate-evals/solo-vs-team/BASELINE_GLM52.md b/teammate-evals/solo-vs-team/BASELINE_GLM52.md new file mode 100644 index 0000000..b93b3ca --- /dev/null +++ b/teammate-evals/solo-vs-team/BASELINE_GLM52.md @@ -0,0 +1,83 @@ +# GLM-5.2 Baseline: Solo vs Team + +Date: 2026-07-14 + +This is the first real-model baseline for the five-scenario benchmark. Each +scenario was run once in solo mode and once in team mode against the configured +Anthropic-compatible endpoint with model `glm-5.2`. Team mode used a serial +analyst -> implementer -> reviewer workflow with required handoff messages. +This records the original forced-pipeline v1 protocol; the current benchmark's +primary comparison is solo vs adaptive lead-controlled orchestration. + +## Results + +| Scenario | Solo quality | Team quality | Solo sec | Team sec | Solo tokens | Team tokens | Team protocol | +|---|---:|---:|---:|---:|---:|---:|:---:| +| config-migration | 100 | 100 | 103.689 | 534.832 | 15,596 | 88,180 | yes | +| csv-reconciliation | 100 | 100 | 155.862 | 495.247 | 38,744 | 98,291 | yes | +| invoice-allocation | 100 | 100 | 83.769 | 664.008 | 22,127 | 131,458 | yes | +| permission-policy | 100 | 100 | 183.874 | 839.041 | 41,740 | 241,899 | no | +| webhook-idempotency | 100 | 100 | 112.159 | 649.926 | 17,935 | 123,035 | no | + +Aggregate results: + +- Business acceptance: solo 5/5, team 5/5. +- Strict success, including the execution protocol: solo 5/5, team 3/5. +- Mean quality delta: 0 points. +- Mean elapsed time: solo 127.9 seconds, team 636.6 seconds (4.98x). +- Mean token use: solo 27,228, team 136,573 (5.02x). +- Protected task, requirements, and test files remained unchanged in all runs. + +## What Happened + +The team workflow did real collaborative work: all five runs created three +agents and three dependent tasks, completed all tasks, and persisted all three +required handoffs. On these small, explicit tasks, however, the extra analysis +and independent review did not improve acceptance quality because solo already +reached 100%. + +Two team runs missed the strict protocol gate after producing correct code: + +- `permission-policy` exceeded the team's 600 second timeout after the final + task had completed. The lead then verified the tests and disbanded the team, + leaving the historical team status as `cancelled`. +- `webhook-idempotency` exceeded its 300 second team timeout after all three + tasks had completed and remained `failed`. + +The runtime checks timeout and usage budgets before checking whether all tasks +are complete. That ordering can turn a just-completed workflow into a failure at +the next scheduler iteration. The benchmark now preserves metrics even when +`TeamDelete` removes the active team pointer by reading historical team state. + +The traces also exposed a task API usability issue. Workers naturally used the +stable keys requested by the prompt, such as `analysis` and `review`, when +calling `TaskUpdate`, but that tool currently accepts only internal task IDs. +Workers recovered by listing tasks and retrying, at unnecessary token and tool +cost. One worker also tried the intuitive status value `done` instead of +`completed`. + +## Decision + +For work of this size, solo should remain the default. A three-role serial team +cost about five times as much without a quality gain in this sample. Team mode +is better reserved for work with genuinely separable parallel investigation, +large context that one agent cannot hold reliably, distinct ownership areas, or +high-risk changes where independent review is worth the added cost. + +The next implementation priorities are: + +1. Check all-tasks-complete before timeout and budget failure in `TeamRun`. +2. Resolve `TaskUpdate.taskId` by either internal ID or stable task key, and make + accepted status values clearer. +3. Add adaptive orchestration and reviewer budgets so small tasks stay solo and + expensive review loops are bounded. +4. Build benchmark v2 with hidden held-out tests, larger cross-module changes, + parallelizable research, and repeated trials per cell. + +## Limitations + +This is a smoke benchmark, not a statistically significant model evaluation. +There is one sample per cell, and acceptance tests are visible to the agents +although their integrity is hash-checked. The baseline is useful for catching +large regressions and workflow problems; quality claims need repeated trials and +held-out tests. diff --git a/teammate-evals/solo-vs-team/README.md b/teammate-evals/solo-vs-team/README.md new file mode 100644 index 0000000..70bdee2 --- /dev/null +++ b/teammate-evals/solo-vs-team/README.md @@ -0,0 +1,60 @@ +# Solo vs Team Benchmark + +This benchmark compares one-agent execution with adaptive lead-controlled +teammate execution on the same five repository repair tasks: + +The first real `glm-5.2` run and its conclusions are recorded in +[`BASELINE_GLM52.md`](BASELINE_GLM52.md). + +1. `invoice-allocation`: money, ordering, partial allocation, and aging rules. +2. `webhook-idempotency`: concurrency, retries, ordering, and tenant isolation. +3. `config-migration`: multi-version migration, validation, interpolation, and redaction. +4. `permission-policy`: inheritance, wildcard matching, tenant scoping, and deny precedence. +5. `csv-reconciliation`: parsing, deterministic matching, ambiguity, and malformed data. + +Each run gets a clean workspace. `TASK.md`, `requirements.md`, and acceptance +tests are hashed before and after execution. Solo and adaptive runs receive the +same business task; only team tools are disabled for solo. In adaptive mode the +lead decides whether to create a team and, if so, chooses its size, roles, +models, tools, workspaces, task graph, concurrency, and communication topology. +Quality is the percentage of deterministic acceptance tests passed. The report +also records elapsed time, tokens, model/tool calls, and collaboration evidence. + +Validate the intentionally broken fixtures without using a model: + +```bash +.venv/bin/python teammate-evals/solo-vs-team/benchmark.py --validate-fixtures +``` + +Run all ten comparisons with the configured Anthropic-compatible GLM endpoint: + +```bash +.venv/bin/python teammate-evals/solo-vs-team/benchmark.py \ + --provider anthropic \ + --model glm-5.2 +``` + +Run one scenario or one mode while iterating: + +```bash +.venv/bin/python teammate-evals/solo-vs-team/benchmark.py \ + --scenario webhook-idempotency \ + --mode adaptive +``` + +Use `--mode forced-team` only to diagnose the teammate runtime while still +letting the lead choose the team structure. `--mode all` runs solo, adaptive, +and forced-team. No benchmark mode prescribes planner/executor/verifier roles. + +Artifacts are written under `runs//`: each isolated workspace, +the exact generated prompt, stdout/stderr, per-run JSON, aggregate `results.json`, +and `REPORT.md`. Credentials are inherited from normal Clawd configuration or +environment variables and are never written by the benchmark. + +Recompute acceptance, integrity, protocol, and usage metrics from an existing +run without making another model request: + +```bash +.venv/bin/python teammate-evals/solo-vs-team/benchmark.py \ + --rescore-output teammate-evals/solo-vs-team/runs/ +``` diff --git a/teammate-evals/solo-vs-team/benchmark.py b/teammate-evals/solo-vs-team/benchmark.py new file mode 100644 index 0000000..44d0fda --- /dev/null +++ b/teammate-evals/solo-vs-team/benchmark.py @@ -0,0 +1,686 @@ +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import shutil +import subprocess +import sys +import time +import traceback +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable + + +ROOT = Path(__file__).resolve().parent +REPO_ROOT = ROOT.parents[1] +SCENARIOS_ROOT = ROOT / "scenarios" +DEFAULT_TEST_COMMAND = [ + sys.executable, + "-m", + "unittest", + "discover", + "-s", + "tests", + "-v", +] +PROTECTED_PATTERNS = ("TASK.md", "requirements.md", "tests/**/*.py") + + +def _read_json(path: Path) -> dict[str, Any]: + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError(f"expected an object in {path}") + return data + + +def load_scenarios(selected: list[str] | None = None) -> list[dict[str, Any]]: + wanted = set(selected or []) + scenarios: list[dict[str, Any]] = [] + for manifest_path in sorted(SCENARIOS_ROOT.glob("*/scenario.json")): + manifest = _read_json(manifest_path) + scenario_id = str(manifest.get("id") or manifest_path.parent.name) + if wanted and scenario_id not in wanted: + continue + workspace = manifest_path.parent / "workspace" + if not workspace.is_dir(): + raise ValueError(f"missing workspace fixture for {scenario_id}") + manifest["id"] = scenario_id + manifest["fixture"] = str(workspace) + scenarios.append(manifest) + missing = wanted - {scenario["id"] for scenario in scenarios} + if missing: + raise ValueError(f"unknown scenarios: {', '.join(sorted(missing))}") + return scenarios + + +def _hash_file(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def protected_snapshot(workspace: Path) -> dict[str, str]: + snapshot: dict[str, str] = {} + for pattern in PROTECTED_PATTERNS: + for path in sorted(workspace.glob(pattern)): + if path.is_file(): + snapshot[path.relative_to(workspace).as_posix()] = _hash_file(path) + return snapshot + + +def build_prompt(workspace: Path, mode: str) -> str: + task = (workspace / "TASK.md").read_text(encoding="utf-8").strip() + common = f"""{task} + +Read requirements.md before editing. Do not modify TASK.md, requirements.md, or +anything under tests/. Run this command before finishing: + +python -m unittest discover -s tests -v +""" + if mode == "solo": + return common + """ + +Execution protocol: work directly as one agent. Do not create a teammate team +or call any Team*/Teammate* tool. Inspect, implement, test, and review your own +change, then summarize the result. +""" + if mode == "adaptive": + return common + """ + +Execution protocol: act as the lead and decide whether this task benefits from a +team. It is valid to solve it directly without creating any teammate. If you do +delegate, choose the number of teammates, their task-specific roles, models, +tool allowlists, workspace modes, task graph, and concurrency yourself. Do not +default to a fixed role pipeline. Teammates may communicate directly through +SendMessage and ReadMessages when useful. Observe their state, intervene or +stop work when needed, and personally perform final integration and validation. +The communication topology should emerge from the work rather than be imposed. +""" + if mode in {"team", "forced-team"}: + return common + """ + +Diagnostic execution protocol: use at least one teammate, but choose the team +size, roles, models, tool allowlists, workspace modes, task graph, concurrency, +and communication topology from the task itself. Do not use a predefined role +pipeline. Teammates may communicate directly through SendMessage and +ReadMessages. Observe the team, adapt it when needed, and personally perform +final integration and validation. +""" + raise ValueError(f"unknown mode: {mode}") + + +def _parse_unittest_output(output: str, returncode: int) -> dict[str, Any]: + match = re.search(r"Ran\s+(\d+)\s+tests?", output) + total = int(match.group(1)) if match else 0 + counts = {"failures": 0, "errors": 0, "skipped": 0} + summary = re.search(r"FAILED\s*\(([^)]*)\)", output) + if summary: + for key, value in re.findall(r"(failures|errors|skipped)=(\d+)", summary.group(1)): + counts[key] = int(value) + ok_skipped = re.search(r"OK\s*\(([^)]*)\)", output) + if ok_skipped: + skipped = re.search(r"skipped=(\d+)", ok_skipped.group(1)) + if skipped: + counts["skipped"] = int(skipped.group(1)) + if returncode != 0 and total and not counts["failures"] and not counts["errors"]: + counts["errors"] = total + passed = max(0, total - counts["failures"] - counts["errors"] - counts["skipped"]) + return { + "total": total, + "passed": passed, + **counts, + "returncode": returncode, + } + + +def run_acceptance(workspace: Path, timeout_s: float = 60.0) -> dict[str, Any]: + completed = subprocess.run( + DEFAULT_TEST_COMMAND, + cwd=workspace, + capture_output=True, + text=True, + timeout=timeout_s, + ) + output = f"{completed.stdout}\n{completed.stderr}" + result = _parse_unittest_output(output, completed.returncode) + result["output"] = output.strip() + return result + + +def _load_team_metrics(workspace: Path) -> dict[str, Any]: + active_path = workspace / ".clawd" / "team.json" + active = active_path.exists() + if active: + team_path = active_path + else: + historical = list((workspace / ".clawd" / "teams").glob("*/team.json")) + team_path = max(historical, key=lambda path: path.stat().st_mtime) if historical else None + if team_path is None: + return { + "present": False, + "active": False, + "status": None, + "agents": 0, + "tasks": 0, + "completed_tasks": 0, + "messages": 0, + "worker_usage": {}, + "trace_model_calls": 0, + "trace_tool_calls": 0, + } + team = _read_json(team_path) + team_id = str(team.get("team_id") or team_path.parent.name) + team_dir = workspace / ".clawd" / "teams" / team_id + agents = list((team_dir / "agents").glob("*.json")) + messages = list((team_dir / "messages").glob("*.json")) + tasks = _read_json(team_dir / "tasks.json") if (team_dir / "tasks.json").exists() else {} + event_types: list[str] = [] + events_path = team_dir / "events.jsonl" + if events_path.exists(): + for line in events_path.read_text(encoding="utf-8").splitlines(): + try: + event_types.append(str(json.loads(line).get("type") or "")) + except (json.JSONDecodeError, AttributeError): + continue + return { + "present": True, + "active": active, + "team_id": team_id, + "status": team.get("status"), + "agents": len(agents), + "tasks": len(tasks), + "completed_tasks": sum( + 1 for task in tasks.values() if isinstance(task, dict) and task.get("status") == "completed" + ), + "messages": len(messages), + "worker_usage": team.get("usage") or {}, + "trace_model_calls": event_types.count("model.response"), + "trace_tool_calls": event_types.count("tool.started"), + "completed_events": event_types.count("team.completed"), + "failed_events": event_types.count("team.failed"), + "cancelled_events": event_types.count("team.cancelled"), + } + + +def _protocol_ok(mode: str, team: dict[str, Any]) -> bool: + if mode == "solo": + return not team["present"] + if mode == "adaptive" and not team["present"]: + return True + return bool( + team["present"] + and team["status"] == "completed" + and team["agents"] >= 1 + and team["tasks"] >= 1 + and team["completed_tasks"] == team["tasks"] + ) + + +def _run_child( + workspace: Path, + prompt_path: Path, + result_path: Path, + provider: str, + model: str, + max_turns: int, +) -> int: + if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + from src.runner import run_prompt + + lead_events: list[dict[str, Any]] = [] + + def capture(event: Any) -> None: + lead_events.append( + { + "kind": event.kind, + "tool_name": event.tool_name, + "usage": event.usage, + "duration_ms": event.duration_ms, + } + ) + + payload: dict[str, Any] + try: + result = run_prompt( + prompt_path.read_text(encoding="utf-8"), + workspace=workspace, + provider_name=provider, + model=model, + max_turns=max_turns, + on_event=capture, + ) + payload = { + "ok": result.response_text != "[Max tool turns reached]", + "response_text": result.response_text, + "lead_usage": result.usage or {}, + "lead_turns": result.num_turns, + "lead_model_calls": sum(event["kind"] == "model_response" for event in lead_events), + "lead_tool_calls": sum(event["kind"] == "tool_use" for event in lead_events), + } + except Exception as exc: + payload = { + "ok": False, + "error": str(exc), + "traceback": traceback.format_exc(), + "lead_usage": {}, + "lead_turns": 0, + "lead_model_calls": sum(event["kind"] == "model_response" for event in lead_events), + "lead_tool_calls": sum(event["kind"] == "tool_use" for event in lead_events), + } + result_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") + return 0 if payload["ok"] else 1 + + +def run_case( + scenario: dict[str, Any], + mode: str, + output_root: Path, + *, + provider: str, + model: str, + max_turns: int, + timeout_s: float, +) -> dict[str, Any]: + case_root = output_root / scenario["id"] / mode + workspace = case_root / "workspace" + case_root.mkdir(parents=True, exist_ok=True) + shutil.copytree(Path(scenario["fixture"]), workspace) + before = protected_snapshot(workspace) + prompt_path = case_root / "PROMPT.md" + prompt_path.write_text(build_prompt(workspace, mode), encoding="utf-8") + result_path = case_root / "agent-result.json" + command = [ + sys.executable, + str(Path(__file__).resolve()), + "_run-one", + "--workspace", + str(workspace), + "--prompt-file", + str(prompt_path), + "--result-file", + str(result_path), + "--provider", + provider, + "--model", + model, + "--max-turns", + str(max_turns), + ] + started = time.monotonic() + timed_out = False + try: + completed = subprocess.run( + command, + cwd=REPO_ROOT, + capture_output=True, + text=True, + timeout=timeout_s, + env=os.environ.copy(), + ) + returncode = completed.returncode + stdout = completed.stdout + stderr = completed.stderr + except subprocess.TimeoutExpired as exc: + timed_out = True + returncode = 124 + stdout = exc.stdout or "" + stderr = exc.stderr or "" + elapsed = time.monotonic() - started + (case_root / "stdout.log").write_text(str(stdout), encoding="utf-8") + (case_root / "stderr.log").write_text(str(stderr), encoding="utf-8") + agent = _read_json(result_path) if result_path.exists() else { + "ok": False, + "error": "run timed out" if timed_out else "missing agent result", + "lead_usage": {}, + "lead_turns": 0, + "lead_model_calls": 0, + "lead_tool_calls": 0, + } + result = _score_case( + scenario, + mode, + workspace, + agent, + provider=provider, + model=model, + elapsed=elapsed, + timed_out=timed_out, + returncode=returncode, + protected_before=before, + ) + (case_root / "result.json").write_text( + json.dumps(result, indent=2, ensure_ascii=False), encoding="utf-8" + ) + return result + + +def _score_case( + scenario: dict[str, Any], + mode: str, + workspace: Path, + agent: dict[str, Any], + *, + provider: str, + model: str, + elapsed: float, + timed_out: bool, + returncode: int, + protected_before: dict[str, str], +) -> dict[str, Any]: + acceptance = run_acceptance(workspace) + protected_after = protected_snapshot(workspace) + integrity_ok = protected_before == protected_after + changed_protected = sorted( + path + for path in set(protected_before) | set(protected_after) + if protected_before.get(path) != protected_after.get(path) + ) + team = _load_team_metrics(workspace) + protocol_ok = _protocol_ok(mode, team) + lead_usage = agent.get("lead_usage") or {} + worker_usage = team.get("worker_usage") or {} + input_tokens = int(lead_usage.get("input_tokens", 0) or 0) + int( + worker_usage.get("input_tokens", 0) or 0 + ) + output_tokens = int(lead_usage.get("output_tokens", 0) or 0) + int( + worker_usage.get("output_tokens", 0) or 0 + ) + total = int(acceptance["total"]) + quality = 100.0 * int(acceptance["passed"]) / total if total and integrity_ok else 0.0 + return { + "scenario": scenario["id"], + "title": scenario.get("title", scenario["id"]), + "mode": mode, + "provider": provider, + "model": model, + "elapsed_s": round(elapsed, 3), + "timed_out": timed_out, + "agent_returncode": returncode, + "agent_ok": bool(agent.get("ok")), + "agent_error": agent.get("error"), + "acceptance": acceptance, + "integrity_ok": integrity_ok, + "changed_protected": changed_protected, + "protocol_ok": protocol_ok, + "used_team": bool(team["present"]), + "quality_score": round(quality, 2), + "success": bool( + agent.get("ok") + and acceptance["returncode"] == 0 + and integrity_ok + and protocol_ok + ), + "usage": { + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "total_tokens": input_tokens + output_tokens, + "lead_turns": int(agent.get("lead_turns", 0) or 0), + "worker_turns": int(worker_usage.get("turns", 0) or 0), + }, + "calls": { + "model": team["trace_model_calls"] if team["present"] else agent.get("lead_model_calls", 0), + "tools": team["trace_tool_calls"] if team["present"] else agent.get("lead_tool_calls", 0), + }, + "team": team, + "workspace": str(workspace), + } + + +def rescore_output(output_root: Path) -> tuple[list[dict[str, Any]], str]: + aggregate_path = output_root / "results.json" + aggregate = _read_json(aggregate_path) + scenarios = {scenario["id"]: scenario for scenario in load_scenarios()} + rescored: list[dict[str, Any]] = [] + for previous in aggregate.get("results", []): + scenario_id = str(previous.get("scenario") or "") + mode = str(previous.get("mode") or "") + scenario = scenarios.get(scenario_id) + if scenario is None or mode not in {"solo", "adaptive", "team", "forced-team"}: + raise ValueError(f"cannot rescore unknown case: {scenario_id}/{mode}") + case_root = output_root / scenario_id / mode + workspace = case_root / "workspace" + agent_path = case_root / "agent-result.json" + agent = _read_json(agent_path) if agent_path.exists() else { + "ok": False, + "error": "missing agent result", + "lead_usage": {}, + "lead_turns": 0, + "lead_model_calls": 0, + "lead_tool_calls": 0, + } + result = _score_case( + scenario, + mode, + workspace, + agent, + provider=str(previous.get("provider") or aggregate.get("provider") or ""), + model=str(previous.get("model") or aggregate.get("model") or ""), + elapsed=float(previous.get("elapsed_s", 0) or 0), + timed_out=bool(previous.get("timed_out")), + returncode=int(previous.get("agent_returncode", 1) or 0), + protected_before=protected_snapshot(Path(scenario["fixture"])), + ) + (case_root / "result.json").write_text( + json.dumps(result, indent=2, ensure_ascii=False), encoding="utf-8" + ) + rescored.append(result) + + run_id = str(aggregate.get("run_id") or output_root.name) + aggregate["results"] = rescored + aggregate_path.write_text(json.dumps(aggregate, indent=2, ensure_ascii=False), encoding="utf-8") + report = render_report(rescored, run_id) + (output_root / "REPORT.md").write_text(report, encoding="utf-8") + return rescored, report + + +def _format_cell(result: dict[str, Any] | None, key: Callable[[dict[str, Any]], Any]) -> str: + if result is None: + return "-" + return str(key(result)) + + +def render_report(results: list[dict[str, Any]], run_id: str) -> str: + by_scenario: dict[str, dict[str, dict[str, Any]]] = {} + for result in results: + by_scenario.setdefault(result["scenario"], {})[result["mode"]] = result + available_modes = {result["mode"] for result in results} + if "adaptive" in available_modes: + comparison_mode = "adaptive" + comparison_label = "Adaptive" + elif "forced-team" in available_modes: + comparison_mode = "forced-team" + comparison_label = "Forced team" + else: + comparison_mode = "team" + comparison_label = "Team" + lines = [ + "# Solo vs Team Benchmark", + "", + f"Run: `{run_id}`", + "", + f"| Scenario | Solo quality | {comparison_label} quality | Delta | Solo sec | {comparison_label} sec | Solo tokens | {comparison_label} tokens | Used team | Protocol |", + "|---|---:|---:|---:|---:|---:|---:|---:|:---:|:---:|", + ] + deltas: list[float] = [] + for scenario, modes in sorted(by_scenario.items()): + solo = modes.get("solo") + team = modes.get(comparison_mode) + delta = None + if solo and team: + delta = float(team["quality_score"]) - float(solo["quality_score"]) + deltas.append(delta) + lines.append( + "| " + + " | ".join( + [ + scenario, + _format_cell(solo, lambda item: item["quality_score"]), + _format_cell(team, lambda item: item["quality_score"]), + "-" if delta is None else f"{delta:+.2f}", + _format_cell(solo, lambda item: item["elapsed_s"]), + _format_cell(team, lambda item: item["elapsed_s"]), + _format_cell(solo, lambda item: item["usage"]["total_tokens"]), + _format_cell(team, lambda item: item["usage"]["total_tokens"]), + _format_cell(team, lambda item: "yes" if item.get("used_team") else "no"), + _format_cell(team, lambda item: "yes" if item["protocol_ok"] else "no"), + ] + ) + + " |" + ) + success = sum(bool(result["success"]) for result in results) + lines.extend( + [ + "", + f"Successful runs: **{success}/{len(results)}**", + ( + f"Mean {comparison_label.lower()} quality delta: **{sum(deltas) / len(deltas):+.2f} points**" + if deltas + else f"Mean {comparison_label.lower()} quality delta: unavailable" + ), + "", + "Quality is the percentage of deterministic acceptance tests passed. A run is only", + "successful when protected files are unchanged and its execution protocol is respected.", + ] + ) + return "\n".join(lines) + "\n" + + +def validate_fixtures() -> list[str]: + errors: list[str] = [] + scenarios = load_scenarios() + if len(scenarios) != 5: + errors.append(f"expected 5 scenarios, found {len(scenarios)}") + for scenario in scenarios: + workspace = Path(scenario["fixture"]) + for required in ("TASK.md", "requirements.md", "tests"): + if not (workspace / required).exists(): + errors.append(f"{scenario['id']}: missing {required}") + result = run_acceptance(workspace) + if result["total"] < 5: + errors.append(f"{scenario['id']}: expected at least 5 acceptance tests") + if result["returncode"] == 0: + errors.append(f"{scenario['id']}: fixture unexpectedly passes before repair") + if not protected_snapshot(workspace): + errors.append(f"{scenario['id']}: no protected files found") + return errors + + +def _child_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("_command") + parser.add_argument("--workspace", type=Path, required=True) + parser.add_argument("--prompt-file", type=Path, required=True) + parser.add_argument("--result-file", type=Path, required=True) + parser.add_argument("--provider", required=True) + parser.add_argument("--model", required=True) + parser.add_argument("--max-turns", type=int, required=True) + return parser + + +def main() -> int: + if len(sys.argv) > 1 and sys.argv[1] == "_run-one": + args = _child_parser().parse_args() + return _run_child( + args.workspace.resolve(), + args.prompt_file.resolve(), + args.result_file.resolve(), + args.provider, + args.model, + args.max_turns, + ) + + parser = argparse.ArgumentParser(description="Compare solo and adaptive teammate execution.") + parser.add_argument("--list", action="store_true", help="List benchmark scenarios") + parser.add_argument("--validate-fixtures", action="store_true") + parser.add_argument("--scenario", action="append", help="Scenario ID; repeat to select several") + parser.add_argument( + "--mode", + choices=("solo", "adaptive", "forced-team", "team", "both", "all"), + default="both", + help="both runs solo+adaptive; team is a legacy alias for forced-team", + ) + parser.add_argument("--provider", default="anthropic") + parser.add_argument("--model", default="glm-5.2") + parser.add_argument("--max-turns", type=int, default=100) + parser.add_argument("--timeout", type=float, default=1200.0, help="Seconds per agent run") + parser.add_argument("--output", type=Path, help="Run output directory") + parser.add_argument( + "--rescore-output", + type=Path, + help="Recompute metrics for an existing output directory without calling a model", + ) + args = parser.parse_args() + + scenarios = load_scenarios(args.scenario) + if args.list: + for scenario in scenarios: + print(f"{scenario['id']}: {scenario.get('title', '')}") + return 0 + if args.validate_fixtures: + errors = validate_fixtures() + if errors: + for error in errors: + print(f"- {error}") + return 1 + print(f"{len(scenarios)} scenarios valid and intentionally failing at baseline") + return 0 + if args.rescore_output: + output_root = args.rescore_output.resolve() + _, report = rescore_output(output_root) + print(report) + print(f"Artifacts: {output_root}") + return 0 + + if args.max_turns < 1 or args.timeout <= 0: + parser.error("--max-turns and --timeout must be positive") + run_id = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + output_root = (args.output or ROOT / "runs" / run_id).resolve() + output_root.mkdir(parents=True, exist_ok=True) + if args.mode == "both": + modes = ("solo", "adaptive") + elif args.mode == "all": + modes = ("solo", "adaptive", "forced-team") + elif args.mode == "team": + modes = ("forced-team",) + else: + modes = (args.mode,) + results: list[dict[str, Any]] = [] + for scenario in scenarios: + for mode in modes: + print(f"[{scenario['id']}] running {mode}...", flush=True) + result = run_case( + scenario, + mode, + output_root, + provider=args.provider, + model=args.model, + max_turns=args.max_turns, + timeout_s=args.timeout, + ) + results.append(result) + print( + f" quality={result['quality_score']:.2f} success={result['success']} " + f"time={result['elapsed_s']:.1f}s tokens={result['usage']['total_tokens']}", + flush=True, + ) + payload = { + "run_id": run_id, + "provider": args.provider, + "model": args.model, + "results": results, + } + (output_root / "results.json").write_text( + json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8" + ) + report = render_report(results, run_id) + (output_root / "REPORT.md").write_text(report, encoding="utf-8") + print(f"\n{report}") + print(f"Artifacts: {output_root}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/teammate-evals/solo-vs-team/scenarios/config-migration/scenario.json b/teammate-evals/solo-vs-team/scenarios/config-migration/scenario.json new file mode 100644 index 0000000..89cd787 --- /dev/null +++ b/teammate-evals/solo-vs-team/scenarios/config-migration/scenario.json @@ -0,0 +1,4 @@ +{ + "id": "config-migration", + "title": "Versioned configuration migration and redaction" +} diff --git a/teammate-evals/solo-vs-team/scenarios/config-migration/workspace/TASK.md b/teammate-evals/solo-vs-team/scenarios/config-migration/workspace/TASK.md new file mode 100644 index 0000000..829e068 --- /dev/null +++ b/teammate-evals/solo-vs-team/scenarios/config-migration/workspace/TASK.md @@ -0,0 +1,6 @@ +# Configuration migration repair + +Repair `src/config_migration.py`. The service must safely load three historical +configuration formats, interpolate deployment variables, validate the canonical +form, and expose a recursively redacted public view. Keep the two public +function signatures unchanged. diff --git a/teammate-evals/solo-vs-team/scenarios/config-migration/workspace/requirements.md b/teammate-evals/solo-vs-team/scenarios/config-migration/workspace/requirements.md new file mode 100644 index 0000000..73828e8 --- /dev/null +++ b/teammate-evals/solo-vs-team/scenarios/config-migration/workspace/requirements.md @@ -0,0 +1,30 @@ +# Configuration requirements + +`migrate_config(raw, env)` returns this canonical version 3 shape: + +```python +{ + "version": 3, + "service": {"url": "https://...", "auth": {"token": "..."}}, + "retry": {"max_attempts": 3}, + "features": {...}, +} +``` + +- Version 1 is selected when `version` is absent or `1`; fields are `endpoint`, + `token`, `retries`, and optional `features`. +- Version 2 fields are `service_url`, `credentials.api_token`, `retry_count`, + and optional `features`. +- Version 3 already uses the canonical shape. Return a deep copy, never an alias. +- Interpolate `${NAME}` placeholders recursively in string values using the + supplied `env` mapping. Multiple placeholders in one string are supported; + a missing name raises `ValueError` naming that variable. +- The canonical service URL must use HTTPS and have a host. Token must be a + non-empty string. Retry count must be an integer from 0 through 10; booleans + are not integers for this purpose. Features must be a mapping. +- Unsupported versions and malformed nested structures raise `ValueError`. +- Neither input mapping nor nested values may be mutated. + +`public_config(config)` returns a deep detached copy where values under keys +`token`, `password`, `secret`, and `api_key` are replaced with `[REDACTED]` at +every nesting depth, including dictionaries inside lists. diff --git a/teammate-evals/solo-vs-team/scenarios/config-migration/workspace/src/__init__.py b/teammate-evals/solo-vs-team/scenarios/config-migration/workspace/src/__init__.py new file mode 100644 index 0000000..0fdc12a --- /dev/null +++ b/teammate-evals/solo-vs-team/scenarios/config-migration/workspace/src/__init__.py @@ -0,0 +1 @@ +"""Configuration migration fixture.""" diff --git a/teammate-evals/solo-vs-team/scenarios/config-migration/workspace/src/config_migration.py b/teammate-evals/solo-vs-team/scenarios/config-migration/workspace/src/config_migration.py new file mode 100644 index 0000000..ce7e9d1 --- /dev/null +++ b/teammate-evals/solo-vs-team/scenarios/config-migration/workspace/src/config_migration.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from typing import Any, Mapping + + +def migrate_config(raw: Mapping[str, Any], env: Mapping[str, str]) -> dict[str, Any]: + version = raw.get("version", 1) + if version != 1: + return dict(raw) + endpoint = str(raw.get("endpoint", "")).replace( + "${SERVICE_HOST}", env.get("SERVICE_HOST", "") + ) + return { + "version": 3, + "service": {"url": endpoint, "auth": {"token": raw.get("token")}}, + "retry": {"max_attempts": int(raw.get("retries", 3))}, + "features": raw.get("features", {}), + } + + +def public_config(config: Mapping[str, Any]) -> dict[str, Any]: + result = dict(config) + if "token" in result: + result["token"] = "[REDACTED]" + return result diff --git a/teammate-evals/solo-vs-team/scenarios/config-migration/workspace/tests/__init__.py b/teammate-evals/solo-vs-team/scenarios/config-migration/workspace/tests/__init__.py new file mode 100644 index 0000000..13122fc --- /dev/null +++ b/teammate-evals/solo-vs-team/scenarios/config-migration/workspace/tests/__init__.py @@ -0,0 +1 @@ +"""Acceptance tests.""" diff --git a/teammate-evals/solo-vs-team/scenarios/config-migration/workspace/tests/test_acceptance.py b/teammate-evals/solo-vs-team/scenarios/config-migration/workspace/tests/test_acceptance.py new file mode 100644 index 0000000..9787cef --- /dev/null +++ b/teammate-evals/solo-vs-team/scenarios/config-migration/workspace/tests/test_acceptance.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import copy +import unittest + +from src.config_migration import migrate_config, public_config + + +class ConfigMigrationAcceptance(unittest.TestCase): + def test_migrates_v1_and_interpolates_multiple_variables(self) -> None: + raw = { + "endpoint": "https://${HOST}/${STAGE}", + "token": "abc", + "retries": 2, + "features": {"label": "${STAGE}-worker"}, + } + actual = migrate_config(raw, {"HOST": "api.example.com", "STAGE": "prod"}) + self.assertEqual(actual["service"]["url"], "https://api.example.com/prod") + self.assertEqual(actual["features"]["label"], "prod-worker") + self.assertEqual(actual["retry"]["max_attempts"], 2) + + def test_migrates_v2_nested_credentials(self) -> None: + raw = { + "version": 2, + "service_url": "https://api.example.com", + "credentials": {"api_token": "v2-secret"}, + "retry_count": 4, + "features": {"fast": True}, + } + actual = migrate_config(raw, {}) + self.assertEqual(actual["version"], 3) + self.assertEqual(actual["service"]["auth"]["token"], "v2-secret") + self.assertEqual(actual["retry"], {"max_attempts": 4}) + + def test_v3_is_validated_and_deep_copied(self) -> None: + raw = { + "version": 3, + "service": {"url": "https://api.example.com", "auth": {"token": "x"}}, + "retry": {"max_attempts": 0}, + "features": {"nested": {"enabled": True}}, + } + actual = migrate_config(raw, {}) + actual["features"]["nested"]["enabled"] = False + self.assertTrue(raw["features"]["nested"]["enabled"]) + + def test_missing_environment_variable_is_explicit(self) -> None: + raw = {"endpoint": "https://${MISSING}", "token": "x", "retries": 1} + with self.assertRaisesRegex(ValueError, "MISSING"): + migrate_config(raw, {}) + + def test_rejects_invalid_url_token_retry_and_features(self) -> None: + valid = { + "version": 3, + "service": {"url": "https://api.example.com", "auth": {"token": "x"}}, + "retry": {"max_attempts": 1}, + "features": {}, + } + variants = [] + for path, value in ( + (("service", "url"), "http://api.example.com"), + (("service", "auth", "token"), ""), + (("retry", "max_attempts"), True), + (("retry", "max_attempts"), 11), + (("features",), []), + ): + item = copy.deepcopy(valid) + target = item + for key in path[:-1]: + target = target[key] + target[path[-1]] = value + variants.append(item) + for variant in variants: + with self.subTest(variant=variant): + with self.assertRaises(ValueError): + migrate_config(variant, {}) + + def test_rejects_unsupported_or_malformed_versions(self) -> None: + with self.assertRaises(ValueError): + migrate_config({"version": 99}, {}) + with self.assertRaises(ValueError): + migrate_config({"version": 2, "credentials": "secret"}, {}) + + def test_input_is_never_mutated(self) -> None: + raw = { + "version": 2, + "service_url": "https://api.example.com", + "credentials": {"api_token": "x"}, + "retry_count": 1, + "features": {"items": ["${NAME}"]}, + } + before = copy.deepcopy(raw) + migrate_config(raw, {"NAME": "resolved"}) + self.assertEqual(raw, before) + + def test_public_config_redacts_recursively_and_is_detached(self) -> None: + config = { + "service": {"auth": {"token": "x", "password": "p"}}, + "items": [{"api_key": "k", "safe": {"secret": "s"}}], + } + public = public_config(config) + self.assertEqual(public["service"]["auth"]["token"], "[REDACTED]") + self.assertEqual(public["service"]["auth"]["password"], "[REDACTED]") + self.assertEqual(public["items"][0]["api_key"], "[REDACTED]") + self.assertEqual(public["items"][0]["safe"]["secret"], "[REDACTED]") + public["items"][0]["safe"]["extra"] = True + self.assertNotIn("extra", config["items"][0]["safe"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/teammate-evals/solo-vs-team/scenarios/csv-reconciliation/scenario.json b/teammate-evals/solo-vs-team/scenarios/csv-reconciliation/scenario.json new file mode 100644 index 0000000..02f4779 --- /dev/null +++ b/teammate-evals/solo-vs-team/scenarios/csv-reconciliation/scenario.json @@ -0,0 +1,4 @@ +{ + "id": "csv-reconciliation", + "title": "Deterministic bank and ledger reconciliation" +} diff --git a/teammate-evals/solo-vs-team/scenarios/csv-reconciliation/workspace/TASK.md b/teammate-evals/solo-vs-team/scenarios/csv-reconciliation/workspace/TASK.md new file mode 100644 index 0000000..d91dfcd --- /dev/null +++ b/teammate-evals/solo-vs-team/scenarios/csv-reconciliation/workspace/TASK.md @@ -0,0 +1,6 @@ +# CSV reconciliation repair + +Repair `src/reconcile.py`. The existing implementation zips rows by position and +uses binary floating point, producing false matches and losing malformed-row +diagnostics. Preserve the public dataclasses and `reconcile` signature while +implementing the deterministic matching rules. diff --git a/teammate-evals/solo-vs-team/scenarios/csv-reconciliation/workspace/requirements.md b/teammate-evals/solo-vs-team/scenarios/csv-reconciliation/workspace/requirements.md new file mode 100644 index 0000000..f9c8d87 --- /dev/null +++ b/teammate-evals/solo-vs-team/scenarios/csv-reconciliation/workspace/requirements.md @@ -0,0 +1,21 @@ +# Reconciliation requirements + +Inputs are CSV strings. Bank headers are `reference,date,amount`; ledger headers +are `entry_id,reference,date,amount`. Dates use ISO `YYYY-MM-DD` and amounts are +`Decimal` values rounded to cents using `ROUND_HALF_UP`. + +- Trim fields and normalize references to uppercase for matching. Preserve the + original bank reference and ledger entry ID in `Match`. +- Parse rows independently. A malformed date, amount, missing bank reference, or + missing ledger entry ID is excluded and adds `bank row N: ...` or + `ledger row N: ...` to `errors`, where the header is row 1. +- Match valid rows in two deterministic phases, consuming each row at most once. +- Phase 1: normalized reference, equal amount, and dates no more than two days + apart. If several ledger rows qualify, choose the smallest `(date, entry_id)`. +- Phase 2 for remaining rows: equal amount and dates no more than one day apart, + ignoring reference. Match only when exactly one remaining ledger candidate + exists; ambiguous candidates stay unmatched. +- Process bank rows in input order. Return unmatched bank references and + unmatched ledger entry IDs in input order. +- Do not silently drop duplicate rows, zero or negative amounts, or parse errors. + Zero and negative amounts are valid when both sides agree. diff --git a/teammate-evals/solo-vs-team/scenarios/csv-reconciliation/workspace/src/__init__.py b/teammate-evals/solo-vs-team/scenarios/csv-reconciliation/workspace/src/__init__.py new file mode 100644 index 0000000..fcee9a2 --- /dev/null +++ b/teammate-evals/solo-vs-team/scenarios/csv-reconciliation/workspace/src/__init__.py @@ -0,0 +1 @@ +"""Reconciliation fixture.""" diff --git a/teammate-evals/solo-vs-team/scenarios/csv-reconciliation/workspace/src/reconcile.py b/teammate-evals/solo-vs-team/scenarios/csv-reconciliation/workspace/src/reconcile.py new file mode 100644 index 0000000..5c893d1 --- /dev/null +++ b/teammate-evals/solo-vs-team/scenarios/csv-reconciliation/workspace/src/reconcile.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import csv +import io +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Match: + bank_reference: str + ledger_entry_id: str + amount: str + method: str + + +@dataclass(frozen=True) +class ReconciliationReport: + matches: tuple[Match, ...] + unmatched_bank: tuple[str, ...] + unmatched_ledger: tuple[str, ...] + errors: tuple[str, ...] + + +def reconcile(bank_csv: str, ledger_csv: str) -> ReconciliationReport: + bank_rows = list(csv.DictReader(io.StringIO(bank_csv))) + ledger_rows = list(csv.DictReader(io.StringIO(ledger_csv))) + matches: list[Match] = [] + for bank, ledger in zip(bank_rows, ledger_rows): + if float(bank["amount"]) == float(ledger["amount"]): + matches.append( + Match(bank["reference"], ledger["entry_id"], bank["amount"], "position") + ) + matched = len(matches) + return ReconciliationReport( + tuple(matches), + tuple(row["reference"] for row in bank_rows[matched:]), + tuple(row["entry_id"] for row in ledger_rows[matched:]), + (), + ) diff --git a/teammate-evals/solo-vs-team/scenarios/csv-reconciliation/workspace/tests/__init__.py b/teammate-evals/solo-vs-team/scenarios/csv-reconciliation/workspace/tests/__init__.py new file mode 100644 index 0000000..13122fc --- /dev/null +++ b/teammate-evals/solo-vs-team/scenarios/csv-reconciliation/workspace/tests/__init__.py @@ -0,0 +1 @@ +"""Acceptance tests.""" diff --git a/teammate-evals/solo-vs-team/scenarios/csv-reconciliation/workspace/tests/test_acceptance.py b/teammate-evals/solo-vs-team/scenarios/csv-reconciliation/workspace/tests/test_acceptance.py new file mode 100644 index 0000000..8a01fd3 --- /dev/null +++ b/teammate-evals/solo-vs-team/scenarios/csv-reconciliation/workspace/tests/test_acceptance.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import unittest + +from src.reconcile import reconcile + + +class ReconciliationAcceptance(unittest.TestCase): + def test_reference_matching_normalizes_and_is_not_positional(self) -> None: + bank = "reference,date,amount\n ref-a ,2025-01-02,10.00\nREF-B,2025-01-03,20\n" + ledger = ( + "entry_id,reference,date,amount\n" + "L2,ref-b,2025-01-03,20.00\n" + "L1,REF-A,2025-01-01,10\n" + ) + report = reconcile(bank, ledger) + self.assertEqual( + [(match.bank_reference, match.ledger_entry_id, match.method) for match in report.matches], + [("ref-a", "L1", "reference"), ("REF-B", "L2", "reference")], + ) + + def test_reference_candidate_tie_breaks_by_date_then_id(self) -> None: + bank = "reference,date,amount\nX,2025-01-03,5\n" + ledger = ( + "entry_id,reference,date,amount\n" + "B,X,2025-01-02,5\n" + "A,X,2025-01-02,5\n" + ) + report = reconcile(bank, ledger) + self.assertEqual(report.matches[0].ledger_entry_id, "A") + self.assertEqual(report.unmatched_ledger, ("B",)) + + def test_reference_dates_must_be_within_two_days(self) -> None: + bank = "reference,date,amount\nX,2025-01-10,5\n" + ledger = "entry_id,reference,date,amount\nL,X,2025-01-07,5\n" + report = reconcile(bank, ledger) + self.assertEqual(report.matches, ()) + self.assertEqual(report.unmatched_bank, ("X",)) + + def test_unique_fallback_uses_amount_and_one_day_window(self) -> None: + bank = "reference,date,amount\nBANK-X,2025-01-10,9.995\n" + ledger = "entry_id,reference,date,amount\nL,OTHER,2025-01-11,10.00\n" + report = reconcile(bank, ledger) + self.assertEqual(report.matches[0].method, "amount_date") + self.assertEqual(report.matches[0].amount, "10.00") + + def test_ambiguous_fallback_stays_unmatched(self) -> None: + bank = "reference,date,amount\nBANK-X,2025-01-10,10\n" + ledger = ( + "entry_id,reference,date,amount\n" + "L1,A,2025-01-09,10\n" + "L2,B,2025-01-11,10\n" + ) + report = reconcile(bank, ledger) + self.assertEqual(report.matches, ()) + self.assertEqual(report.unmatched_bank, ("BANK-X",)) + self.assertEqual(report.unmatched_ledger, ("L1", "L2")) + + def test_malformed_rows_are_reported_and_excluded(self) -> None: + bank = ( + "reference,date,amount\n" + ",2025-01-01,1\n" + "B,not-a-date,2\n" + "C,2025-01-01,nope\n" + "OK,2025-01-01,3\n" + ) + ledger = ( + "entry_id,reference,date,amount\n" + ",OK,2025-01-01,3\n" + "L,OK,2025-01-01,3\n" + ) + report = reconcile(bank, ledger) + self.assertEqual(len(report.errors), 4) + self.assertTrue(report.errors[0].startswith("bank row 2:")) + self.assertTrue(report.errors[-1].startswith("ledger row 2:")) + self.assertEqual(report.matches[0].ledger_entry_id, "L") + + def test_negative_and_duplicate_rows_are_preserved(self) -> None: + bank = "reference,date,amount\nR,2025-01-01,-2\nR,2025-01-01,-2\n" + ledger = ( + "entry_id,reference,date,amount\n" + "L1,R,2025-01-01,-2\n" + "L2,R,2025-01-01,-2\n" + ) + report = reconcile(bank, ledger) + self.assertEqual([match.ledger_entry_id for match in report.matches], ["L1", "L2"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/teammate-evals/solo-vs-team/scenarios/invoice-allocation/scenario.json b/teammate-evals/solo-vs-team/scenarios/invoice-allocation/scenario.json new file mode 100644 index 0000000..e6498e3 --- /dev/null +++ b/teammate-evals/solo-vs-team/scenarios/invoice-allocation/scenario.json @@ -0,0 +1,4 @@ +{ + "id": "invoice-allocation", + "title": "Invoice payment allocation and aging" +} diff --git a/teammate-evals/solo-vs-team/scenarios/invoice-allocation/workspace/TASK.md b/teammate-evals/solo-vs-team/scenarios/invoice-allocation/workspace/TASK.md new file mode 100644 index 0000000..5a44823 --- /dev/null +++ b/teammate-evals/solo-vs-team/scenarios/invoice-allocation/workspace/TASK.md @@ -0,0 +1,6 @@ +# Invoice settlement repair + +Repair the invoice settlement module in `src/settlement.py`. Payment allocation +and aging reports currently disagree with the product rules and mishandle +several financial edge cases. Preserve the public dataclasses and function +signatures while making the complete acceptance suite pass. diff --git a/teammate-evals/solo-vs-team/scenarios/invoice-allocation/workspace/requirements.md b/teammate-evals/solo-vs-team/scenarios/invoice-allocation/workspace/requirements.md new file mode 100644 index 0000000..8b28318 --- /dev/null +++ b/teammate-evals/solo-vs-team/scenarios/invoice-allocation/workspace/requirements.md @@ -0,0 +1,24 @@ +# Settlement requirements + +All monetary values use `Decimal` and are rounded to cents with `ROUND_HALF_UP`. + +## Payment allocation + +- `allocate_payment` rejects non-positive payment amounts. +- Only open invoices for the requested customer are eligible. Paid and void + invoices, invoices belonging to other customers, and invoices with no + outstanding balance are ignored. +- Outstanding balance is `total - paid`, rounded to cents. Invalid invoices + where paid is negative or exceeds total must raise `ValueError`. +- Allocate oldest due date first, using invoice ID as the deterministic tie + breaker. Partial allocation is allowed. +- Return one `Allocation` per affected invoice and preserve any excess as + `unapplied`; never manufacture or lose a cent. + +## Aging summary + +- `aging_summary` includes only positive outstanding balances on open invoices. +- Buckets are `current` for invoices due on or after `as_of`, `days_1_30`, + `days_31_60`, and `days_over_60`. +- Boundary days 30 and 60 belong to the earlier bucket. +- Every bucket is returned even when its value is zero. diff --git a/teammate-evals/solo-vs-team/scenarios/invoice-allocation/workspace/src/__init__.py b/teammate-evals/solo-vs-team/scenarios/invoice-allocation/workspace/src/__init__.py new file mode 100644 index 0000000..74094f3 --- /dev/null +++ b/teammate-evals/solo-vs-team/scenarios/invoice-allocation/workspace/src/__init__.py @@ -0,0 +1 @@ +"""Invoice allocation fixture.""" diff --git a/teammate-evals/solo-vs-team/scenarios/invoice-allocation/workspace/src/settlement.py b/teammate-evals/solo-vs-team/scenarios/invoice-allocation/workspace/src/settlement.py new file mode 100644 index 0000000..525860f --- /dev/null +++ b/teammate-evals/solo-vs-team/scenarios/invoice-allocation/workspace/src/settlement.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date +from decimal import Decimal + + +CENT = Decimal("0.01") + + +@dataclass(frozen=True) +class Invoice: + invoice_id: str + customer_id: str + due_on: date + total: Decimal + paid: Decimal = Decimal("0.00") + status: str = "open" + + +@dataclass(frozen=True) +class Allocation: + invoice_id: str + amount: Decimal + + +@dataclass(frozen=True) +class PaymentResult: + allocations: tuple[Allocation, ...] + unapplied: Decimal + + +def allocate_payment( + invoices: list[Invoice], customer_id: str, amount: Decimal +) -> PaymentResult: + remaining = round(float(amount), 2) + eligible = sorted( + (invoice for invoice in invoices if invoice.status != "paid"), + key=lambda invoice: invoice.due_on, + reverse=True, + ) + allocations: list[Allocation] = [] + for invoice in eligible: + if remaining <= 0: + break + applied = min(remaining, float(invoice.total)) + allocations.append(Allocation(invoice.invoice_id, Decimal(str(applied)))) + remaining -= applied + return PaymentResult(tuple(allocations), Decimal(str(remaining))) + + +def aging_summary(invoices: list[Invoice], as_of: date) -> dict[str, Decimal]: + buckets = { + "current": Decimal("0.00"), + "days_1_30": Decimal("0.00"), + "days_31_60": Decimal("0.00"), + "days_over_60": Decimal("0.00"), + } + for invoice in invoices: + balance = invoice.total + days = (as_of - invoice.due_on).days + if days < 0: + key = "current" + elif days < 30: + key = "days_1_30" + elif days < 60: + key = "days_31_60" + else: + key = "days_over_60" + buckets[key] += balance + return buckets diff --git a/teammate-evals/solo-vs-team/scenarios/invoice-allocation/workspace/tests/__init__.py b/teammate-evals/solo-vs-team/scenarios/invoice-allocation/workspace/tests/__init__.py new file mode 100644 index 0000000..13122fc --- /dev/null +++ b/teammate-evals/solo-vs-team/scenarios/invoice-allocation/workspace/tests/__init__.py @@ -0,0 +1 @@ +"""Acceptance tests.""" diff --git a/teammate-evals/solo-vs-team/scenarios/invoice-allocation/workspace/tests/test_acceptance.py b/teammate-evals/solo-vs-team/scenarios/invoice-allocation/workspace/tests/test_acceptance.py new file mode 100644 index 0000000..654739a --- /dev/null +++ b/teammate-evals/solo-vs-team/scenarios/invoice-allocation/workspace/tests/test_acceptance.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import unittest +from datetime import date, timedelta +from decimal import Decimal + +from src.settlement import Invoice, aging_summary, allocate_payment + + +D = Decimal + + +class SettlementAcceptance(unittest.TestCase): + def test_allocates_oldest_due_date_then_id(self) -> None: + invoices = [ + Invoice("B", "c1", date(2025, 1, 1), D("20")), + Invoice("A", "c1", date(2025, 1, 1), D("20")), + Invoice("C", "c1", date(2025, 2, 1), D("20")), + ] + result = allocate_payment(invoices, "c1", D("25")) + self.assertEqual( + [(item.invoice_id, item.amount) for item in result.allocations], + [("A", D("20.00")), ("B", D("5.00"))], + ) + + def test_filters_customer_and_non_open_invoices(self) -> None: + invoices = [ + Invoice("other", "c2", date(2024, 1, 1), D("10")), + Invoice("void", "c1", date(2024, 1, 2), D("10"), status="void"), + Invoice("paid", "c1", date(2024, 1, 3), D("10"), paid=D("10")), + Invoice("open", "c1", date(2024, 1, 4), D("10")), + ] + result = allocate_payment(invoices, "c1", D("10")) + self.assertEqual([item.invoice_id for item in result.allocations], ["open"]) + + def test_uses_outstanding_balance_and_preserves_excess(self) -> None: + invoice = Invoice("part", "c1", date(2025, 1, 1), D("20"), paid=D("7.25")) + result = allocate_payment([invoice], "c1", D("20")) + self.assertEqual(result.allocations[0].amount, D("12.75")) + self.assertEqual(result.unapplied, D("7.25")) + + def test_rounds_half_up_without_float_loss(self) -> None: + invoice = Invoice("round", "c1", date(2025, 1, 1), D("1.005")) + result = allocate_payment([invoice], "c1", D("1.005")) + self.assertEqual(result.allocations[0].amount, D("1.01")) + self.assertEqual(result.unapplied, D("0.00")) + + def test_rejects_invalid_payment_and_invoice_balances(self) -> None: + with self.assertRaises(ValueError): + allocate_payment([], "c1", D("0")) + with self.assertRaises(ValueError): + allocate_payment( + [Invoice("bad", "c1", date.today(), D("5"), paid=D("6"))], + "c1", + D("1"), + ) + + def test_aging_excludes_closed_and_uses_outstanding(self) -> None: + today = date(2025, 4, 1) + invoices = [ + Invoice("open", "c1", today - timedelta(days=5), D("10"), paid=D("3")), + Invoice("void", "c1", today - timedelta(days=5), D("50"), status="void"), + Invoice("paid", "c1", today - timedelta(days=5), D("5"), paid=D("5")), + ] + self.assertEqual(aging_summary(invoices, today)["days_1_30"], D("7.00")) + + def test_aging_boundaries_are_stable(self) -> None: + today = date(2025, 4, 1) + invoices = [ + Invoice("future", "c", today + timedelta(days=1), D("1")), + Invoice("d30", "c", today - timedelta(days=30), D("2")), + Invoice("d60", "c", today - timedelta(days=60), D("3")), + Invoice("d61", "c", today - timedelta(days=61), D("4")), + ] + self.assertEqual( + aging_summary(invoices, today), + { + "current": D("1.00"), + "days_1_30": D("2.00"), + "days_31_60": D("3.00"), + "days_over_60": D("4.00"), + }, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/teammate-evals/solo-vs-team/scenarios/permission-policy/scenario.json b/teammate-evals/solo-vs-team/scenarios/permission-policy/scenario.json new file mode 100644 index 0000000..6dc4f8b --- /dev/null +++ b/teammate-evals/solo-vs-team/scenarios/permission-policy/scenario.json @@ -0,0 +1,4 @@ +{ + "id": "permission-policy", + "title": "Inherited tenant-aware permission policy" +} diff --git a/teammate-evals/solo-vs-team/scenarios/permission-policy/workspace/TASK.md b/teammate-evals/solo-vs-team/scenarios/permission-policy/workspace/TASK.md new file mode 100644 index 0000000..c0bd071 --- /dev/null +++ b/teammate-evals/solo-vs-team/scenarios/permission-policy/workspace/TASK.md @@ -0,0 +1,6 @@ +# Permission engine repair + +Repair `src/policy.py`. The current authorization evaluator ignores role +inheritance, deny rules, wildcard semantics, and tenant placeholders. Preserve +the public dataclasses and `PolicyEngine.is_allowed` API while enforcing the +complete policy contract. diff --git a/teammate-evals/solo-vs-team/scenarios/permission-policy/workspace/requirements.md b/teammate-evals/solo-vs-team/scenarios/permission-policy/workspace/requirements.md new file mode 100644 index 0000000..7b4d73c --- /dev/null +++ b/teammate-evals/solo-vs-team/scenarios/permission-policy/workspace/requirements.md @@ -0,0 +1,18 @@ +# Policy requirements + +- A `Rule` effect is exactly `allow` or `deny`. Action and resource patterns use + shell-style `*` wildcards with case-sensitive matching. +- Users may have several roles. Roles inherit all rules from their declared + parents, transitively. +- Unknown user role names do not grant access. Unknown inherited role names and + inheritance cycles are configuration errors and raise `ValueError` when the + engine is created. +- Evaluate all matching rules across all assigned and inherited roles. Any + matching deny overrides every allow. At least one allow is required; default + is deny. +- Resource patterns may contain `{tenant}`. Replace it with the non-empty + `context["tenant"]` value before matching. If no tenant is supplied, that rule + cannot match. Substitution is literal: wildcard characters in a tenant value + must not become policy wildcards. +- Engine construction must detach its internal role mapping from caller-owned + dictionaries and rule lists. diff --git a/teammate-evals/solo-vs-team/scenarios/permission-policy/workspace/src/__init__.py b/teammate-evals/solo-vs-team/scenarios/permission-policy/workspace/src/__init__.py new file mode 100644 index 0000000..68f3ede --- /dev/null +++ b/teammate-evals/solo-vs-team/scenarios/permission-policy/workspace/src/__init__.py @@ -0,0 +1 @@ +"""Permission policy fixture.""" diff --git a/teammate-evals/solo-vs-team/scenarios/permission-policy/workspace/src/policy.py b/teammate-evals/solo-vs-team/scenarios/permission-policy/workspace/src/policy.py new file mode 100644 index 0000000..a4f8cc8 --- /dev/null +++ b/teammate-evals/solo-vs-team/scenarios/permission-policy/workspace/src/policy.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Mapping + + +@dataclass(frozen=True) +class Rule: + effect: str + action: str + resource: str + + +@dataclass +class Role: + name: str + rules: list[Rule] = field(default_factory=list) + inherits: tuple[str, ...] = () + + +class PolicyEngine: + def __init__(self, roles: Mapping[str, Role]) -> None: + self.roles = dict(roles) + + def is_allowed( + self, + role_names: list[str], + action: str, + resource: str, + context: Mapping[str, str] | None = None, + ) -> bool: + for role_name in role_names: + role = self.roles.get(role_name) + if role is None: + continue + for rule in role.rules: + if rule.effect == "allow" and rule.action == action and rule.resource == resource: + return True + return False diff --git a/teammate-evals/solo-vs-team/scenarios/permission-policy/workspace/tests/__init__.py b/teammate-evals/solo-vs-team/scenarios/permission-policy/workspace/tests/__init__.py new file mode 100644 index 0000000..13122fc --- /dev/null +++ b/teammate-evals/solo-vs-team/scenarios/permission-policy/workspace/tests/__init__.py @@ -0,0 +1 @@ +"""Acceptance tests.""" diff --git a/teammate-evals/solo-vs-team/scenarios/permission-policy/workspace/tests/test_acceptance.py b/teammate-evals/solo-vs-team/scenarios/permission-policy/workspace/tests/test_acceptance.py new file mode 100644 index 0000000..50e7c5a --- /dev/null +++ b/teammate-evals/solo-vs-team/scenarios/permission-policy/workspace/tests/test_acceptance.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import unittest + +from src.policy import PolicyEngine, Role, Rule + + +class PermissionPolicyAcceptance(unittest.TestCase): + def test_exact_allow_and_default_deny(self) -> None: + engine = PolicyEngine({"reader": Role("reader", [Rule("allow", "read", "docs/one")])}) + self.assertTrue(engine.is_allowed(["reader"], "read", "docs/one")) + self.assertFalse(engine.is_allowed(["reader"], "write", "docs/one")) + + def test_shell_wildcards_are_case_sensitive(self) -> None: + engine = PolicyEngine({"reader": Role("reader", [Rule("allow", "read*", "docs/*")])}) + self.assertTrue(engine.is_allowed(["reader"], "read:meta", "docs/one")) + self.assertFalse(engine.is_allowed(["reader"], "Read:meta", "docs/one")) + + def test_transitive_inheritance(self) -> None: + roles = { + "base": Role("base", [Rule("allow", "read", "docs/*")]), + "editor": Role("editor", [Rule("allow", "write", "docs/*")], ("base",)), + "admin": Role("admin", [], ("editor",)), + } + engine = PolicyEngine(roles) + self.assertTrue(engine.is_allowed(["admin"], "read", "docs/a")) + self.assertTrue(engine.is_allowed(["admin"], "write", "docs/a")) + + def test_deny_overrides_allow_across_roles(self) -> None: + roles = { + "writer": Role("writer", [Rule("allow", "write", "docs/*")]), + "suspended": Role("suspended", [Rule("deny", "*", "docs/locked")]), + } + engine = PolicyEngine(roles) + self.assertFalse(engine.is_allowed(["writer", "suspended"], "write", "docs/locked")) + self.assertTrue(engine.is_allowed(["writer", "suspended"], "write", "docs/open")) + + def test_tenant_placeholder_is_required_and_literal(self) -> None: + engine = PolicyEngine( + {"member": Role("member", [Rule("allow", "read", "tenant/{tenant}/*")])} + ) + self.assertTrue( + engine.is_allowed(["member"], "read", "tenant/acme/report", {"tenant": "acme"}) + ) + self.assertFalse(engine.is_allowed(["member"], "read", "tenant/acme/report", {})) + self.assertFalse( + engine.is_allowed(["member"], "read", "tenant/anything/report", {"tenant": "*"}) + ) + + def test_unknown_parent_and_cycles_are_rejected(self) -> None: + with self.assertRaises(ValueError): + PolicyEngine({"child": Role("child", inherits=("missing",))}) + with self.assertRaises(ValueError): + PolicyEngine( + { + "a": Role("a", inherits=("b",)), + "b": Role("b", inherits=("a",)), + } + ) + + def test_invalid_effect_is_rejected(self) -> None: + with self.assertRaises(ValueError): + PolicyEngine({"bad": Role("bad", [Rule("maybe", "read", "*")])}) + + def test_engine_detaches_caller_owned_collections(self) -> None: + rules = [Rule("allow", "read", "docs/*")] + roles = {"reader": Role("reader", rules)} + engine = PolicyEngine(roles) + rules.clear() + roles.clear() + self.assertTrue(engine.is_allowed(["reader"], "read", "docs/a")) + + +if __name__ == "__main__": + unittest.main() diff --git a/teammate-evals/solo-vs-team/scenarios/webhook-idempotency/scenario.json b/teammate-evals/solo-vs-team/scenarios/webhook-idempotency/scenario.json new file mode 100644 index 0000000..9fb2930 --- /dev/null +++ b/teammate-evals/solo-vs-team/scenarios/webhook-idempotency/scenario.json @@ -0,0 +1,4 @@ +{ + "id": "webhook-idempotency", + "title": "Concurrent webhook idempotency and ordering" +} diff --git a/teammate-evals/solo-vs-team/scenarios/webhook-idempotency/workspace/TASK.md b/teammate-evals/solo-vs-team/scenarios/webhook-idempotency/workspace/TASK.md new file mode 100644 index 0000000..00c8704 --- /dev/null +++ b/teammate-evals/solo-vs-team/scenarios/webhook-idempotency/workspace/TASK.md @@ -0,0 +1,5 @@ +# Webhook processor repair + +Repair `WebhookProcessor` in `src/webhooks.py`. Production deliveries can be +duplicated, concurrent, out of order, or retried after handler failures. Keep +the public API intact and make processing deterministic and tenant-safe. diff --git a/teammate-evals/solo-vs-team/scenarios/webhook-idempotency/workspace/requirements.md b/teammate-evals/solo-vs-team/scenarios/webhook-idempotency/workspace/requirements.md new file mode 100644 index 0000000..86dcade --- /dev/null +++ b/teammate-evals/solo-vs-team/scenarios/webhook-idempotency/workspace/requirements.md @@ -0,0 +1,17 @@ +# Webhook processing requirements + +- `WebhookEvent.tenant_id` and `event_id` must be non-empty and `sequence` must + be a positive integer; invalid events raise `ValueError` before the handler. +- Idempotency is scoped by tenant and event ID. The first successful delivery + returns `processed`; later deliveries return `duplicate` without invoking the + handler. +- Different event IDs with a sequence less than or equal to the last successful + sequence for that tenant return `stale` without invoking the handler. +- Handler exceptions propagate. A failed event must remain retryable and must + not advance ordering state. +- Concurrent calls for the same tenant/event must invoke the handler exactly + once. All other callers return `duplicate` after the successful call. +- State for one tenant must never suppress or reorder another tenant. +- `snapshot()` returns a detached mapping keyed by tenant with sorted processed + IDs and the last successful sequence. Callers cannot mutate processor state + through the returned value. diff --git a/teammate-evals/solo-vs-team/scenarios/webhook-idempotency/workspace/src/__init__.py b/teammate-evals/solo-vs-team/scenarios/webhook-idempotency/workspace/src/__init__.py new file mode 100644 index 0000000..54b8a1f --- /dev/null +++ b/teammate-evals/solo-vs-team/scenarios/webhook-idempotency/workspace/src/__init__.py @@ -0,0 +1 @@ +"""Webhook fixture.""" diff --git a/teammate-evals/solo-vs-team/scenarios/webhook-idempotency/workspace/src/webhooks.py b/teammate-evals/solo-vs-team/scenarios/webhook-idempotency/workspace/src/webhooks.py new file mode 100644 index 0000000..14c787c --- /dev/null +++ b/teammate-evals/solo-vs-team/scenarios/webhook-idempotency/workspace/src/webhooks.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable + + +@dataclass(frozen=True) +class WebhookEvent: + tenant_id: str + event_id: str + sequence: int + payload: dict[str, Any] + + +class WebhookProcessor: + def __init__(self) -> None: + self._processed: set[str] = set() + self._last_sequence = 0 + + def process( + self, + event: WebhookEvent, + handler: Callable[[WebhookEvent], None], + ) -> str: + if event.event_id in self._processed: + return "duplicate" + self._processed.add(event.event_id) + self._last_sequence = max(self._last_sequence, event.sequence) + handler(event) + return "processed" + + def snapshot(self) -> dict[str, dict[str, Any]]: + return { + "global": { + "processed_ids": list(self._processed), + "last_sequence": self._last_sequence, + } + } diff --git a/teammate-evals/solo-vs-team/scenarios/webhook-idempotency/workspace/tests/__init__.py b/teammate-evals/solo-vs-team/scenarios/webhook-idempotency/workspace/tests/__init__.py new file mode 100644 index 0000000..13122fc --- /dev/null +++ b/teammate-evals/solo-vs-team/scenarios/webhook-idempotency/workspace/tests/__init__.py @@ -0,0 +1 @@ +"""Acceptance tests.""" diff --git a/teammate-evals/solo-vs-team/scenarios/webhook-idempotency/workspace/tests/test_acceptance.py b/teammate-evals/solo-vs-team/scenarios/webhook-idempotency/workspace/tests/test_acceptance.py new file mode 100644 index 0000000..6124980 --- /dev/null +++ b/teammate-evals/solo-vs-team/scenarios/webhook-idempotency/workspace/tests/test_acceptance.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import threading +import time +import unittest + +from src.webhooks import WebhookEvent, WebhookProcessor + + +class WebhookAcceptance(unittest.TestCase): + def test_duplicate_success_is_not_reprocessed(self) -> None: + processor = WebhookProcessor() + event = WebhookEvent("a", "evt-1", 1, {}) + calls: list[str] = [] + self.assertEqual(processor.process(event, lambda item: calls.append(item.event_id)), "processed") + self.assertEqual(processor.process(event, lambda item: calls.append(item.event_id)), "duplicate") + self.assertEqual(calls, ["evt-1"]) + + def test_handler_failure_is_retryable(self) -> None: + processor = WebhookProcessor() + event = WebhookEvent("a", "evt-1", 1, {}) + with self.assertRaisesRegex(RuntimeError, "temporary"): + processor.process(event, lambda _: (_ for _ in ()).throw(RuntimeError("temporary"))) + calls: list[int] = [] + self.assertEqual(processor.process(event, lambda _: calls.append(1)), "processed") + self.assertEqual(calls, [1]) + + def test_stale_sequence_is_ignored(self) -> None: + processor = WebhookProcessor() + processor.process(WebhookEvent("a", "new", 5, {}), lambda _: None) + called: list[bool] = [] + result = processor.process(WebhookEvent("a", "old", 4, {}), lambda _: called.append(True)) + self.assertEqual(result, "stale") + self.assertEqual(called, []) + + def test_equal_sequence_with_new_id_is_stale(self) -> None: + processor = WebhookProcessor() + processor.process(WebhookEvent("a", "first", 5, {}), lambda _: None) + self.assertEqual( + processor.process(WebhookEvent("a", "second", 5, {}), lambda _: None), + "stale", + ) + + def test_tenant_state_is_isolated(self) -> None: + processor = WebhookProcessor() + calls: list[str] = [] + for tenant in ("a", "b"): + result = processor.process( + WebhookEvent(tenant, "same-id", 1, {}), + lambda event: calls.append(event.tenant_id), + ) + self.assertEqual(result, "processed") + self.assertEqual(calls, ["a", "b"]) + + def test_concurrent_duplicate_invokes_handler_once(self) -> None: + processor = WebhookProcessor() + event = WebhookEvent("a", "race", 1, {}) + barrier = threading.Barrier(4) + calls: list[int] = [] + results: list[str] = [] + lock = threading.Lock() + + def handler(_: WebhookEvent) -> None: + time.sleep(0.03) + with lock: + calls.append(1) + + def run() -> None: + barrier.wait(timeout=1) + result = processor.process(event, handler) + with lock: + results.append(result) + + threads = [threading.Thread(target=run) for _ in range(4)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=2) + self.assertEqual(calls, [1]) + self.assertEqual(sorted(results), ["duplicate", "duplicate", "duplicate", "processed"]) + + def test_validation_happens_before_handler(self) -> None: + processor = WebhookProcessor() + called: list[bool] = [] + for event in ( + WebhookEvent("", "id", 1, {}), + WebhookEvent("a", "", 1, {}), + WebhookEvent("a", "id", 0, {}), + WebhookEvent("a", "id", True, {}), + ): + with self.assertRaises(ValueError): + processor.process(event, lambda _: called.append(True)) + self.assertEqual(called, []) + + def test_snapshot_is_sorted_and_detached(self) -> None: + processor = WebhookProcessor() + processor.process(WebhookEvent("a", "z", 1, {}), lambda _: None) + processor.process(WebhookEvent("a", "a", 2, {}), lambda _: None) + snapshot = processor.snapshot() + self.assertEqual(snapshot["a"], {"processed_ids": ["a", "z"], "last_sequence": 2}) + snapshot["a"]["processed_ids"].append("injected") + self.assertNotIn("injected", processor.snapshot()["a"]["processed_ids"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_agent_loop.py b/tests/test_agent_loop.py index 64fb4e5..3f8c1e3 100644 --- a/tests/test_agent_loop.py +++ b/tests/test_agent_loop.py @@ -6,10 +6,16 @@ import tempfile from src.agent.conversation import Conversation +from src.providers.anthropic_provider import AnthropicProvider from src.providers.base import ChatResponse from src.tool_system.defaults import build_default_registry from src.tool_system.context import ToolContext -from src.tool_system.agent_loop import run_agent_loop, AgentLoopResult +from src.tool_system.agent_loop import ( + AgentLoopResult, + _team_lifecycle_warning, + run_agent_loop, +) +from src.tool_system.tools import TaskCreateTool, TeamCreateTool, TeammateCreateTool class TestAgentLoop(unittest.TestCase): @@ -68,6 +74,7 @@ def test_agent_loop_calls_tool(self): provider=mock_provider, tool_registry=self.registry, tool_context=self.context, + max_output_tokens=8192, verbose=False, ) @@ -77,6 +84,9 @@ def test_agent_loop_calls_tool(self): # Verify provider was called twice self.assertEqual(mock_provider.chat.call_count, 2) + self.assertTrue( + all(call.kwargs["max_tokens"] == 8192 for call in mock_provider.chat.call_args_list) + ) # Verify file was created hello_py = self.workspace / "hello.py" @@ -133,6 +143,92 @@ def test_agent_loop_creates_hello_world(self): self.assertTrue(hello_path.exists()) self.assertEqual(hello_path.read_text(), "print('hello world')") + def test_anthropic_tool_results_are_serialized_as_text(self): + requirements = self.workspace / "requirements.md" + requirements.write_text("First pricing rule.\n", encoding="utf-8") + conversation = Conversation() + conversation.add_user_message("Read requirements.md") + provider = AnthropicProvider(api_key="test", model="test-model") + provider.chat = MagicMock(side_effect=[ + ChatResponse( + content="", + model="test-model", + usage={"input_tokens": 1, "output_tokens": 1}, + finish_reason="tool_use", + tool_uses=[{ + "id": "toolu_read", + "name": "Read", + "input": {"file_path": "requirements.md"}, + }], + ), + ChatResponse( + content="The first pricing rule is present.", + model="test-model", + usage={"input_tokens": 1, "output_tokens": 1}, + finish_reason="stop", + tool_uses=None, + ), + ]) + + run_agent_loop( + conversation=conversation, + provider=provider, + tool_registry=self.registry, + tool_context=self.context, + ) + + tool_result = conversation.messages[2].content[0] + self.assertIsInstance(tool_result.content, str) + self.assertIn("First pricing rule.", tool_result.content) + + def test_anthropic_groups_and_compacts_write_results(self): + conversation = Conversation(max_history=3) + conversation.add_user_message("Create two files") + provider = AnthropicProvider(api_key="test", model="test-model") + provider.chat = MagicMock(side_effect=[ + ChatResponse( + content="", + model="test-model", + usage={"input_tokens": 1, "output_tokens": 1}, + finish_reason="tool_use", + tool_uses=[ + { + "id": "toolu_one", + "name": "Write", + "input": {"file_path": "one.txt", "content": "secret-one"}, + }, + { + "id": "toolu_two", + "name": "Write", + "input": {"file_path": "two.txt", "content": "secret-two"}, + }, + ], + ), + ChatResponse( + content="done", + model="test-model", + usage={"input_tokens": 1, "output_tokens": 1}, + finish_reason="stop", + tool_uses=None, + ), + ]) + + run_agent_loop( + conversation=conversation, + provider=provider, + tool_registry=self.registry, + tool_context=self.context, + max_turns=4, + ) + + self.assertGreaterEqual(conversation.max_history, 10) + self.assertEqual(conversation.messages[0].content, "Create two files") + result_blocks = conversation.messages[2].content + self.assertEqual(len(result_blocks), 2) + self.assertNotIn("secret-one", result_blocks[0].content) + self.assertNotIn("structuredPatch", result_blocks[0].content) + self.assertIn('"success": true', result_blocks[0].content) + def test_agent_loop_stream_emits_final_text_chunks(self): """Streaming mode emits final response chunks without changing the result.""" conversation = Conversation() @@ -299,6 +395,227 @@ def test_agent_loop_stream_falls_back_when_structured_streaming_is_unavailable(s self.assertEqual(result.response_text, "Hello from fallback!") provider.chat.assert_called_once() + def test_empty_response_without_tools_is_corrected_and_retried(self): + conversation = Conversation() + conversation.add_user_message("Create done.txt") + provider = MagicMock() + output_path = self.workspace / "done.txt" + provider.chat_stream_response.side_effect = NotImplementedError() + provider.chat.side_effect = [ + ChatResponse( + content="", + reasoning_content="I should create the file.", + model="test-model", + usage={}, + finish_reason="stop", + tool_uses=None, + ), + ChatResponse( + content="Continuing with the implementation.", + model="test-model", + usage={}, + finish_reason="tool_calls", + tool_uses=[{ + "id": "write-after-empty", + "name": "Write", + "input": {"file_path": str(output_path), "content": "done"}, + }], + ), + ChatResponse( + content="Implementation complete.", + model="test-model", + usage={}, + finish_reason="stop", + tool_uses=None, + ), + ] + events = [] + + result = run_agent_loop( + conversation=conversation, + provider=provider, + tool_registry=self.registry, + tool_context=self.context, + max_turns=6, + on_event=events.append, + ) + + self.assertEqual(result.response_text, "Implementation complete.") + self.assertEqual(provider.chat.call_count, 3) + self.assertEqual(output_path.read_text(encoding="utf-8"), "done") + retries = [event for event in events if event.kind == "empty_response_retry"] + self.assertEqual(len(retries), 1) + corrective_messages = [ + message.content + for message in conversation.messages + if message.role == "user" + and isinstance(message.content, str) + and message.content.startswith("Your previous response contained neither") + ] + self.assertEqual(len(corrective_messages), 1) + + def test_repeated_empty_responses_fail_instead_of_scoring_as_success(self): + conversation = Conversation() + conversation.add_user_message("Do the task") + provider = MagicMock() + provider.chat_stream_response.side_effect = NotImplementedError() + provider.chat.return_value = ChatResponse( + content="", + reasoning_content="thinking only", + model="test-model", + usage={}, + finish_reason="stop", + tool_uses=None, + ) + events = [] + + with self.assertRaisesRegex(RuntimeError, "corrective retries"): + run_agent_loop( + conversation=conversation, + provider=provider, + tool_registry=self.registry, + tool_context=self.context, + max_turns=6, + on_event=events.append, + ) + + self.assertEqual(provider.chat.call_count, 4) + self.assertEqual( + [event.kind for event in events].count("empty_response_retry"), 3 + ) + self.assertEqual([event.kind for event in events].count("run_failed"), 1) + + def test_incomplete_team_blocks_final_answer_until_lead_cleans_up(self): + conversation = Conversation() + conversation.add_user_message("Review the implementation") + TeamCreateTool().run({"team_name": "unfinished"}, self.context) + TeammateCreateTool().run( + { + "name": "reviewer", + "role": "review", + "instructions": "Review the implementation", + "tools": ["Read"], + }, + self.context, + ) + + provider = MagicMock() + provider.chat_stream_response.side_effect = NotImplementedError() + provider.chat.side_effect = [ + ChatResponse( + content="The task is complete.", + model="test-model", + usage={"input_tokens": 1, "output_tokens": 1}, + finish_reason="stop", + tool_uses=None, + ), + ChatResponse( + content="I need to settle the team first.", + model="test-model", + usage={"input_tokens": 1, "output_tokens": 1}, + finish_reason="tool_use", + tool_uses=[{ + "id": "toolu_delete", + "name": "TeamDelete", + "input": {}, + }], + ), + ChatResponse( + content="The task is complete after team cleanup.", + model="test-model", + usage={"input_tokens": 1, "output_tokens": 1}, + finish_reason="stop", + tool_uses=None, + ), + ] + + result = run_agent_loop( + conversation=conversation, + provider=provider, + tool_registry=self.registry, + tool_context=self.context, + max_turns=5, + ) + + self.assertEqual(result.response_text, "The task is complete after team cleanup.") + self.assertEqual(provider.chat.call_count, 3) + self.assertIsNone(self.context.team) + warnings = [ + message.content + for message in conversation.messages + if message.role == "user" + and isinstance(message.content, str) + and message.content.startswith("Team lifecycle guard:") + ] + self.assertEqual(len(warnings), 1) + self.assertIn("TaskCreate", warnings[0]) + + def test_completed_team_with_late_pending_task_is_not_settled(self): + created = TeamCreateTool().run({"team_name": "reopen"}, self.context).output + TeammateCreateTool().run( + { + "name": "worker", + "role": "implementation", + "instructions": "Implement the assigned task.", + "tools": ["Read"], + }, + self.context, + ) + team = self.context.team_store.load_team(created["team_id"]) + team.transition_to("running") + team.transition_to("completed") + self.context.team_store.save_team(team) + self.context.reload_team_state() + TaskCreateTool().run( + { + "key": "late", + "subject": "Late work", + "description": "Complete work discovered after the first batch.", + "owner": "worker", + }, + self.context, + ) + + warning = _team_lifecycle_warning(self.context) + + self.assertIsNotNone(warning) + self.assertIn("TeamRun", warning) + self.assertIn("pending=1", warning) + + def test_strict_team_with_finished_tasks_requests_team_verify(self): + created = TeamCreateTool().run( + {"team_name": "strict", "quality_gates": True}, self.context + ).output + TeammateCreateTool().run( + { + "name": "worker", + "role": "implementation", + "instructions": "Implement the assigned task.", + "tools": ["Read"], + }, + self.context, + ) + task_id = TaskCreateTool().run( + { + "key": "done", + "subject": "Done", + "description": "Already completed", + "owner": "worker", + }, + self.context, + ).output["task"]["id"] + self.context.tasks[task_id]["status"] = "completed" + self.context.persist_tasks() + team = self.context.team_store.load_team(created["team_id"]) + team.transition_to("running") + self.context.team_store.save_team(team) + + warning = _team_lifecycle_warning(self.context) + + self.assertIsNotNone(warning) + self.assertIn("TeamVerify", warning) + self.assertIn("validation", warning) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_ags_backend.py b/tests/test_ags_backend.py new file mode 100644 index 0000000..d626290 --- /dev/null +++ b/tests/test_ags_backend.py @@ -0,0 +1,402 @@ +from __future__ import annotations + +import asyncio +import concurrent.futures +import importlib.util +import io +import os +import sys +import tarfile +import tempfile +import types +import unittest +from pathlib import Path +from unittest.mock import patch + +from src.execution.ags import AGSSettings, AGSWorkspaceBackend, _build_sandbox_command +from src.execution.backend import CommandOutcome + + +_BENCHMARK_PATH = ( + Path(__file__).resolve().parents[1] + / "teammate-evals" + / "nl2repo-pilot" + / "benchmark.py" +) +_BENCHMARK_SPEC = importlib.util.spec_from_file_location( + "nl2repo_pilot_benchmark_ags_test", _BENCHMARK_PATH +) +assert _BENCHMARK_SPEC is not None and _BENCHMARK_SPEC.loader is not None +_BENCHMARK = importlib.util.module_from_spec(_BENCHMARK_SPEC) +sys.modules[_BENCHMARK_SPEC.name] = _BENCHMARK +_BENCHMARK_SPEC.loader.exec_module(_BENCHMARK) + + +class _FakeRuntime: + def __init__(self, response: object) -> None: + self.response = response + self.command = None + + async def execute(self, command: object) -> object: + self.command = command + return self.response + + +class _FakeCommand: + def __init__(self, **kwargs: object) -> None: + self.__dict__.update(kwargs) + + +class TestAGSWorkspaceBackend(unittest.TestCase): + def settings(self) -> AGSSettings: + return AGSSettings(secret_id="test-id", secret_key="test-key") + + def test_sandbox_command_removes_only_swerex_runtime_environment(self) -> None: + command = _build_sandbox_command( + 'python3 -c "import sys; print(sys.executable)"', + timeout_s=60, + runtime_mount_path="/nix", + ) + + self.assertEqual(command[:2], ["/bin/bash", "-c"]) + self.assertEqual( + command[-3:], + ["/nix/swerex", "60", 'python3 -c "import sys; print(sys.executable)"'], + ) + wrapper = command[2] + self.assertIn('"$runtime_root"/*) continue', wrapper) + self.assertIn("unset VIRTUAL_ENV", wrapper) + self.assertIn("unset PYTHONHOME", wrapper) + self.assertIn("--kill-after=5s", wrapper) + self.assertIn('/bin/bash -c "$user_command"', wrapper) + + def test_submit_cancels_future_and_provides_descriptive_timeout(self) -> None: + backend = AGSWorkspaceBackend(self.settings()) + backend._loop = object() # type: ignore[assignment] + cancelled = False + + class TimedOutFuture: + def result(self, timeout: float | None = None) -> object: + raise concurrent.futures.TimeoutError + + def done(self) -> bool: + return False + + def cancel(self) -> bool: + nonlocal cancelled + cancelled = True + return True + + async def pending() -> None: + return None + + coroutine = pending() + try: + with patch("asyncio.run_coroutine_threadsafe", return_value=TimedOutFuture()): + with self.assertRaisesRegex( + TimeoutError, + "sandbox probe timed out after 3s; the pending request was cancelled", + ): + backend._submit(coroutine, timeout=3, operation="sandbox probe") + finally: + coroutine.close() + + self.assertTrue(cancelled) + + def test_submit_preserves_timeout_raised_by_completed_coroutine(self) -> None: + backend = AGSWorkspaceBackend(self.settings()) + backend._loop = object() # type: ignore[assignment] + cancelled = False + + class CompletedFuture: + def result(self, timeout: float | None = None) -> object: + raise TimeoutError("remote command timeout") + + def done(self) -> bool: + return True + + def cancel(self) -> bool: + nonlocal cancelled + cancelled = True + return True + + async def completed() -> None: + return None + + coroutine = completed() + try: + with patch("asyncio.run_coroutine_threadsafe", return_value=CompletedFuture()): + with self.assertRaisesRegex(TimeoutError, "remote command timeout"): + backend._submit(coroutine, timeout=3, operation="sandbox probe") + finally: + coroutine.close() + + self.assertFalse(cancelled) + + def test_exec_uses_process_group_timeout_and_reports_exit_124(self) -> None: + response = types.SimpleNamespace(exit_code=124, stdout="partial output", stderr="") + runtime = _FakeRuntime(response) + backend = AGSWorkspaceBackend(self.settings()) + backend._started = True + backend._deployment = types.SimpleNamespace(runtime=runtime) + backend._loop = object() # type: ignore[assignment] + + command_module = types.ModuleType("swerex.runtime.abstract") + command_module.Command = _FakeCommand # type: ignore[attr-defined] + runtime_module = types.ModuleType("swerex.runtime") + runtime_module.abstract = command_module # type: ignore[attr-defined] + swerex_module = types.ModuleType("swerex") + swerex_module.runtime = runtime_module # type: ignore[attr-defined] + + def submit(coroutine: object, **_: object) -> object: + return asyncio.run(coroutine) # type: ignore[arg-type] + + with ( + patch.dict( + sys.modules, + { + "swerex": swerex_module, + "swerex.runtime": runtime_module, + "swerex.runtime.abstract": command_module, + }, + ), + patch.object(backend, "_submit", side_effect=submit), + ): + result = backend.exec("sleep 10", cwd="/workspace", timeout_s=2) + + self.assertEqual(result.exit_code, 124) + self.assertEqual(result.stdout, "partial output") + self.assertIn("timed out after 2s", result.stderr) + self.assertIsNotNone(runtime.command) + self.assertFalse(runtime.command.shell) + self.assertEqual(runtime.command.timeout, 12) + self.assertEqual(runtime.command.command[-1], "sleep 10") + + def test_exec_converts_outer_timeout_to_tool_result(self) -> None: + backend = AGSWorkspaceBackend(self.settings()) + backend._started = True + backend._deployment = types.SimpleNamespace(runtime=types.SimpleNamespace()) + backend._loop = object() # type: ignore[assignment] + + async def never_called(_: object) -> object: + raise AssertionError + + backend._deployment.runtime.execute = never_called + + def time_out(coroutine: object, **_: object) -> object: + coroutine.close() # type: ignore[attr-defined] + raise TimeoutError( + "sandbox command (1s limit) timed out after 21s; the pending request was cancelled" + ) + + with patch.object(backend, "_submit", side_effect=time_out): + result = backend.exec("sleep 10", cwd="/workspace", timeout_s=1) + + self.assertEqual(result.exit_code, 124) + self.assertIn("pending request was cancelled", result.stderr) + + def test_reset_workspace_preserves_the_ags_mountpoint(self) -> None: + backend = AGSWorkspaceBackend(self.settings()) + commands: list[tuple[str, str, int]] = [] + + def execute(command: str, *, cwd: str, timeout_s: int) -> CommandOutcome: + commands.append((command, cwd, timeout_s)) + return CommandOutcome(0) + + with patch.object(backend, "exec", side_effect=execute): + backend.reset_workspace() + + self.assertEqual(len(commands), 1) + command, cwd, timeout_s = commands[0] + self.assertNotIn("rm -rf /workspace &&", command) + self.assertIn("rm -rf -- /workspace/*", command) + self.assertEqual(cwd, "/") + self.assertEqual(timeout_s, 120) + + @staticmethod + def _archive(*members: tuple[tarfile.TarInfo, bytes]) -> tarfile.TarFile: + packed = io.BytesIO() + with tarfile.open(fileobj=packed, mode="w") as archive: + for member, content in members: + member.size = len(content) + archive.addfile(member, io.BytesIO(content) if content else None) + packed.seek(0) + return tarfile.open(fileobj=packed, mode="r") + + def test_safe_extract_accepts_regular_workspace_prefixed_file(self) -> None: + member = tarfile.TarInfo("workspace/model.py") + content = b"class Model:\n pass\n" + with tempfile.TemporaryDirectory() as temporary: + destination = Path(temporary) / "download" + with self._archive((member, content)) as archive: + AGSWorkspaceBackend._safe_extract(archive, destination) + + self.assertEqual((destination / "workspace/model.py").read_bytes(), content) + + def test_safe_extract_accepts_internal_symbolic_and_hard_links(self) -> None: + target = tarfile.TarInfo("workspace/computer/model.py") + symbolic = tarfile.TarInfo("workspace/model.py") + symbolic.type = tarfile.SYMTYPE + symbolic.linkname = "computer/model.py" + hard = tarfile.TarInfo("workspace/model-copy.py") + hard.type = tarfile.LNKTYPE + hard.linkname = "workspace/computer/model.py" + content = b"MODEL = True\n" + + with tempfile.TemporaryDirectory() as temporary: + destination = Path(temporary) / "download" + with self._archive((target, content), (symbolic, b""), (hard, b"")) as archive: + AGSWorkspaceBackend._safe_extract(archive, destination) + + workspace = destination / "workspace" + self.assertFalse((workspace / "model.py").is_symlink()) + self.assertEqual((workspace / "model.py").read_bytes(), content) + self.assertEqual((workspace / "model-copy.py").read_bytes(), content) + self.assertEqual( + os.stat(workspace / "computer/model.py").st_ino, + os.stat(workspace / "model-copy.py").st_ino, + ) + + def test_safe_extract_materializes_internal_directory_symlink(self) -> None: + package = tarfile.TarInfo("workspace/implementation") + package.type = tarfile.DIRTYPE + module = tarfile.TarInfo("workspace/implementation/module.py") + alias = tarfile.TarInfo("workspace/package") + alias.type = tarfile.SYMTYPE + alias.linkname = "implementation" + + with tempfile.TemporaryDirectory() as temporary: + destination = Path(temporary) / "download" + with self._archive( + (package, b""), + (module, b"VALUE = 1\n"), + (alias, b""), + ) as archive: + AGSWorkspaceBackend._safe_extract(archive, destination) + + materialized = destination / "workspace/package" + self.assertTrue(materialized.is_dir()) + self.assertFalse(materialized.is_symlink()) + self.assertEqual( + (materialized / "module.py").read_text(encoding="utf-8"), + "VALUE = 1\n", + ) + + def test_materialized_workspace_can_be_staged_for_score_context(self) -> None: + target = tarfile.TarInfo("workspace/computer/model.py") + symbolic = tarfile.TarInfo("workspace/model.py") + symbolic.type = tarfile.SYMTYPE + symbolic.linkname = "computer/model.py" + hard = tarfile.TarInfo("workspace/model-copy.py") + hard.type = tarfile.LNKTYPE + hard.linkname = "workspace/computer/model.py" + + task = { + "image": "example.invalid/autorccar:1.0", + "hidden_paths": [], + "test_commands": ["pytest"], + } + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + downloaded = root / "downloaded" + with self._archive( + (target, b"MODEL = True\n"), + (symbolic, b""), + (hard, b""), + ) as archive: + AGSWorkspaceBackend._safe_extract(archive, downloaded) + + metadata = _BENCHMARK.stage_score_context( + task, + downloaded / "workspace", + root / "score", + ) + staged = root / "score/workspace" + self.assertEqual(metadata["score_context_stats"]["source"]["file_count"], 3) + self.assertEqual((staged / "model.py").read_text(), "MODEL = True\n") + self.assertEqual((staged / "model-copy.py").read_text(), "MODEL = True\n") + self.assertFalse(any(path.is_symlink() for path in staged.rglob("*"))) + + def test_safe_extract_rejects_absolute_and_parent_escape_paths(self) -> None: + attacks = ("/tmp/absolute.py", "workspace/../../../escape.py") + for name in attacks: + with self.subTest(name=name), tempfile.TemporaryDirectory() as temporary: + destination = Path(temporary) / "download" + member = tarfile.TarInfo(name) + with self._archive((member, b"bad")) as archive: + with self.assertRaisesRegex(ValueError, "unsafe path"): + AGSWorkspaceBackend._safe_extract(archive, destination) + self.assertFalse((Path(temporary) / "escape.py").exists()) + + def test_safe_extract_rejects_links_that_escape_or_are_not_regular(self) -> None: + attacks: list[tuple[tarfile.TarInfo, bytes]] = [] + symbolic = tarfile.TarInfo("workspace/model.py") + symbolic.type = tarfile.SYMTYPE + symbolic.linkname = "../../outside.py" + attacks.append((symbolic, b"")) + + hard = tarfile.TarInfo("workspace/model.py") + hard.type = tarfile.LNKTYPE + hard.linkname = "../outside.py" + attacks.append((hard, b"")) + + dangling = tarfile.TarInfo("workspace/dangling.py") + dangling.type = tarfile.SYMTYPE + dangling.linkname = "missing.py" + attacks.append((dangling, b"")) + + for member, content in attacks: + with self.subTest(kind=member.type), tempfile.TemporaryDirectory() as temporary: + destination = Path(temporary) / "download" + with self._archive((member, content)) as archive: + with self.assertRaisesRegex(ValueError, "unsafe (path|entry)"): + AGSWorkspaceBackend._safe_extract(archive, destination) + self.assertFalse((Path(temporary) / "outside.py").exists()) + + def test_safe_extract_enforces_archive_and_materialization_limits(self) -> None: + first = tarfile.TarInfo("workspace/one.py") + second = tarfile.TarInfo("workspace/two.py") + with ( + tempfile.TemporaryDirectory() as temporary, + self._archive((first, b"1"), (second, b"2")) as archive, + patch("src.execution.ags.AGS_ARCHIVE_MAX_MEMBERS", 1), + ): + with self.assertRaisesRegex(ValueError, "member-count limit"): + AGSWorkspaceBackend._safe_extract( + archive, Path(temporary) / "download" + ) + + oversized = tarfile.TarInfo("workspace/large.py") + with ( + tempfile.TemporaryDirectory() as temporary, + self._archive((oversized, b"1234")) as archive, + patch("src.execution.ags.AGS_ARCHIVE_MAX_TOTAL_BYTES", 3), + ): + with self.assertRaisesRegex(ValueError, "expanded-size limit"): + AGSWorkspaceBackend._safe_extract( + archive, Path(temporary) / "download" + ) + + target_dir = tarfile.TarInfo("workspace/implementation") + target_dir.type = tarfile.DIRTYPE + target = tarfile.TarInfo("workspace/implementation/module.py") + alias = tarfile.TarInfo("workspace/package") + alias.type = tarfile.SYMTYPE + alias.linkname = "implementation" + with ( + tempfile.TemporaryDirectory() as temporary, + self._archive( + (target_dir, b""), + (target, b"1234"), + (alias, b""), + ) as archive, + patch("src.execution.ags.AGS_ARCHIVE_MAX_TOTAL_BYTES", 7), + ): + with self.assertRaisesRegex(ValueError, "expanded-size limit"): + AGSWorkspaceBackend._safe_extract( + archive, Path(temporary) / "download" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_command_system.py b/tests/test_command_system.py index fb25f3c..a8b7118 100644 --- a/tests/test_command_system.py +++ b/tests/test_command_system.py @@ -281,7 +281,7 @@ def test_skills_command_with_project_root(self): self.assertIn("test-project-skill", result) -class TestCommandEngine(unittest.TestCase): +class TestCommandEngine(unittest.IsolatedAsyncioTestCase): """Tests for the command engine.""" def setUp(self): @@ -367,7 +367,7 @@ def test_skill_to_prompt_command(self): self.assertEqual(cmd.markdown_content, "Hello $name") -class TestInitCommand(unittest.TestCase): +class TestInitCommand(unittest.IsolatedAsyncioTestCase): """Tests for the /init command implementation.""" def setUp(self): diff --git a/tests/test_config.py b/tests/test_config.py index 414bb87..4a3fbae 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -3,7 +3,7 @@ from __future__ import annotations import unittest -from unittest.mock import patch, Mock +from unittest.mock import patch from pathlib import Path import tempfile import json @@ -19,6 +19,7 @@ set_api_key, set_default_provider, get_default_provider, + use_model_profile, _encode_api_key, _decode_api_key ) @@ -43,7 +44,7 @@ def test_config_dir_created(self): self.assertFalse(config_dir.exists()) with patch('src.config.Path.home', return_value=home): - path = get_config_path() + get_config_path() self.assertTrue(config_dir.exists()) @@ -59,6 +60,7 @@ def test_get_default_config(self): self.assertIn("anthropic", config["providers"]) self.assertIn("openai", config["providers"]) self.assertIn("glm", config["providers"]) + self.assertIn("qwen", config["providers"]) def test_default_provider_is_anthropic(self): """Test that default provider is Anthropic.""" @@ -80,6 +82,22 @@ def test_default_models(self): config["providers"]["glm"]["default_model"], "zai/glm-5" ) + self.assertEqual(config["providers"]["qwen"]["default_model"], "ms-mnhdj86z") + self.assertTrue(config["providers"]["qwen"]["base_url"].endswith("/ms-mnhdj86z/v1")) + + def test_existing_config_is_migrated_with_qwen_profile(self): + with tempfile.TemporaryDirectory() as temp_dir: + config_path = Path(temp_dir) / ".clawd" / "config.json" + config_path.parent.mkdir(parents=True) + config_path.write_text( + json.dumps({"default_provider": "glm", "providers": {"glm": {"api_key": ""}}}), + encoding="utf-8", + ) + with patch('src.config.get_config_path', return_value=config_path): + config = load_config() + + self.assertIn("qwen", config["providers"]) + self.assertEqual(config["providers"]["glm"]["default_model"], "zai/glm-5") class TestAPIKeyEncoding(unittest.TestCase): @@ -315,6 +333,34 @@ def test_get_default_provider(self): provider = get_default_provider() self.assertEqual(provider, "anthropic") + def test_model_profiles_switch_protocol_endpoint_and_model(self): + with tempfile.TemporaryDirectory() as temp_dir: + config_path = Path(temp_dir) / ".clawd" / "config.json" + with patch('src.config.get_config_path', return_value=config_path): + use_model_profile("qwen3.5") + self.assertEqual(get_default_provider(), "qwen") + qwen = get_provider_config("qwen") + self.assertEqual(qwen["default_model"], "ms-mnhdj86z") + + use_model_profile("glm5") + self.assertEqual(get_default_provider(), "anthropic") + glm = get_provider_config("anthropic") + self.assertEqual(glm["base_url"], "https://api.z.ai/api/anthropic") + self.assertEqual(glm["default_model"], "glm-5.2") + + def test_profile_switch_preserves_saved_api_keys(self): + with tempfile.TemporaryDirectory() as temp_dir: + config_path = Path(temp_dir) / ".clawd" / "config.json" + with patch('src.config.get_config_path', return_value=config_path): + set_api_key("anthropic", "glm-secret") + set_api_key("qwen", "qwen-secret") + use_model_profile("qwen") + use_model_profile("glm") + config = load_config() + + self.assertEqual(config["providers"]["anthropic"]["api_key"], "glm-secret") + self.assertEqual(config["providers"]["qwen"]["api_key"], "qwen-secret") + if __name__ == '__main__': unittest.main() diff --git a/tests/test_context_system.py b/tests/test_context_system.py index 955f0a6..718d9dc 100644 --- a/tests/test_context_system.py +++ b/tests/test_context_system.py @@ -9,7 +9,7 @@ from src.context_system import build_context_prompt from src.context_system.git_context import collect_git_context from src.providers.base import ChatResponse -from src.tool_system.agent_loop import run_agent_loop +from src.tool_system.agent_loop import _build_effective_system_prompt, run_agent_loop from src.tool_system.context import ToolContext from src.tool_system.defaults import build_default_registry @@ -65,9 +65,21 @@ def test_agent_loop_injects_context_prompt_for_non_anthropic(self) -> None: system_message = provider.chat.call_args.args[0][0] self.assertEqual(system_message["role"], "system") self.assertIn("## Runtime Context", system_message["content"]) + self.assertIn("## Local Engineering Tools", system_message["content"]) + self.assertIn("## Adaptive Team Orchestration", system_message["content"]) + self.assertIn("valid to complete the task without creating a team", system_message["content"]) + self.assertIn("Never send local paths", system_message["content"]) self.assertIn("## Project Instructions", system_message["content"]) self.assertIn("Follow the CLAUDE instructions.", system_message["content"]) + def test_teammate_prompt_does_not_receive_leader_orchestration_guidance(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + context = ToolContext(workspace_root=Path(tmp), actor_id="worker-1") + + prompt = _build_effective_system_prompt("style", context) + + self.assertNotIn("## Adaptive Team Orchestration", prompt) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_nl2repo_dashboard.py b/tests/test_nl2repo_dashboard.py new file mode 100644 index 0000000..9c1ba33 --- /dev/null +++ b/tests/test_nl2repo_dashboard.py @@ -0,0 +1,825 @@ +from __future__ import annotations + +import importlib.util +import json +import sqlite3 +import sys +import tempfile +import unittest +from datetime import datetime, timedelta, timezone +from pathlib import Path + + +MODULE_PATH = ( + Path(__file__).resolve().parents[1] + / "teammate-evals" + / "nl2repo-pilot" + / "dashboard.py" +) +SPEC = importlib.util.spec_from_file_location("nl2repo_dashboard", MODULE_PATH) +assert SPEC is not None and SPEC.loader is not None +dashboard = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = dashboard +SPEC.loader.exec_module(dashboard) + + +def write_json(path: Path, value: object) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value), encoding="utf-8") + + +def write_jsonl(path: Path, values: list[dict[str, object]]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + "".join(json.dumps(value) + "\n" for value in values), encoding="utf-8" + ) + + +class TestNL2RepoDashboard(unittest.TestCase): + def test_rollout_infrastructure_preserves_null_protocol_and_effective_metrics(self) -> None: + metrics = dashboard._normalize_result_metrics( + { + "result_schema_version": 2, + "agent_ok": False, + "delivery_valid": False, + "protocol_status": "not_evaluated", + "protocol_credit": None, + "code_quality_score": None, + "effective_quality_score": None, + "reward_outcome": "pending", + "reward_score_valid": False, + "metric_eligibility": { + "code_quality": False, + "protocol_yield": False, + "effective_quality": False, + }, + "failure_domain": "infrastructure", + "is_infrastructure": True, + "retryable": True, + } + ) + + self.assertIsNone(metrics["protocol_credit"]) + self.assertIsNone(metrics["effective_quality_score"]) + self.assertFalse(any(metrics["metric_eligibility"].values())) + + def test_reward_infrastructure_is_excluded_from_qpe_aggregates(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + write_json( + root / "run-metadata.json", + { + "run_id": "infra-precedence", + "tasks": ["scored", "scorer-infra"], + "modes": ["forced-team"], + }, + ) + common = { + "result_schema_version": 2, + "agent_ok": True, + "integrity_ok": True, + "delivery_valid": True, + "hidden_tests": { + "pytest": { + "expected": 1, + "passed": 1, + "failed": 0, + "errors": 0, + "returncode": 0, + "all_passed": True, + } + }, + } + write_json( + root / "scored" / "forced-team" / "result.json", + { + **common, + "quality_score": 80.0, + "code_quality_score": 80.0, + "protocol_status": "passed", + "protocol_credit": 1.0, + "effective_quality_score": 80.0, + "reward_outcome": "scored", + "reward_score_valid": True, + "metric_eligibility": { + "code_quality": True, + "protocol_yield": True, + "effective_quality": True, + }, + }, + ) + write_json( + root / "scorer-infra" / "forced-team" / "result.json", + { + **common, + "quality_score": 0.0, + "code_quality_score": None, + "protocol_status": "failed", + "protocol_credit": 0.0, + "effective_quality_score": None, + "reward_outcome": "infra_error", + "reward_score_valid": False, + "metric_eligibility": { + "code_quality": False, + "protocol_yield": False, + "effective_quality": False, + }, + "failure_domain": "infrastructure", + "is_infrastructure": True, + "retryable": True, + "hidden_tests": { + "error": "scorer sandbox unavailable", + "pytest": common["hidden_tests"]["pytest"], + }, + }, + ) + + state = dashboard.DashboardStore(root).state() + + self.assertEqual(state["summary"]["infrastructure_errors"], 1) + self.assertEqual(state["summary"]["code_quality"], 80.0) + self.assertEqual(state["summary"]["coverage"], 0.5) + self.assertEqual(state["summary"]["protocol_yield"], 1.0) + self.assertEqual(state["summary"]["protocol_eligible"], 1) + self.assertEqual(state["summary"]["effective_quality"], 80.0) + self.assertEqual(state["summary"]["effective_eligible"], 1) + infra = next(task for task in state["tasks"] if task["task"] == "scorer-infra") + self.assertIsNone(infra["effective_quality_score"]) + self.assertFalse(any(infra["metric_eligibility"].values())) + + def make_queue_run( + self, + root: Path, + run_id: str, + statuses: list[str], + *, + rollout_slots: int, + reward_slots: int, + ) -> Path: + run = root / run_id + write_json( + run / "run-metadata.json", + {"run_id": run_id, "queue_mode": "continuous", "provider": "qwen"}, + ) + with sqlite3.connect(run / "queue.sqlite3") as connection: + connection.execute( + "CREATE TABLE cases (id INTEGER PRIMARY KEY, status TEXT, quality_score REAL)" + ) + connection.execute( + "CREATE TABLE worker_config (id INTEGER PRIMARY KEY, " + "rollout_concurrency INTEGER, reward_concurrency INTEGER, " + "max_rollout_concurrency INTEGER, max_reward_concurrency INTEGER, " + "updated_at TEXT)" + ) + connection.executemany( + "INSERT INTO cases(status, quality_score) VALUES (?, ?)", + [(status, 80.0 if status == "done" else None) for status in statuses], + ) + connection.execute( + "INSERT INTO worker_config VALUES (1, ?, ?, 64, 64, ?)", + (rollout_slots, reward_slots, datetime.now(timezone.utc).isoformat()), + ) + return run + + def test_registry_discovers_and_safely_switches_sibling_runs(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + first = root / "run-a" + second = root / "run-b" + write_json(first / "run-metadata.json", {"run_id": "run-a"}) + write_json( + second / "run-metadata.json", + {"run_id": "run-b", "queue_mode": "continuous"}, + ) + registry = dashboard.DashboardRegistry(first) + listing = registry.listing() + selected = registry.get("run-b") + + with self.assertRaises(KeyError): + registry.get("../outside") + + self.assertEqual(listing["default"], "run-a") + self.assertEqual([run["id"] for run in listing["runs"]], ["run-b", "run-a"]) + self.assertEqual(selected.run_root.name, "run-b") + + def test_global_state_groups_campaign_and_aggregates_slots(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + adaptive = self.make_queue_run( + root, + "20260717-qwen104-adaptive-team-v2-pool32-r2", + ["queued", "rollout", "done"], + rollout_slots=12, + reward_slots=2, + ) + self.make_queue_run( + root, + "20260717-qwen104-forced-team-fixed-pool32-r2", + ["queued", "reward_pending", "rewarding", "done"], + rollout_slots=20, + reward_slots=6, + ) + self.make_queue_run( + root, + "20260716-qwen104-adaptive-team-v2-pool32-r1", + ["queued"], + rollout_slots=32, + reward_slots=0, + ) + state = dashboard.DashboardRegistry(adaptive).global_state(adaptive.name) + + self.assertEqual(state["campaign"], "20260717-qwen104") + self.assertEqual(state["summary"]["runs"], 2) + self.assertEqual(state["summary"]["total"], 7) + self.assertEqual(state["summary"]["queued"], 2) + self.assertEqual(state["pool"]["allocated_rollout"], 32) + self.assertEqual(state["pool"]["allocated_reward"], 8) + self.assertEqual(state["summary"]["rollouts_completed"], 4) + self.assertEqual(state["summary"]["rewards_completed"], 2) + + def make_run(self, root: Path) -> Path: + started = datetime.now(timezone.utc) - timedelta(minutes=10) + write_json( + root / "run-metadata.json", + { + "run_id": "test-run", + "started_at": started.isoformat(), + "tasks": ["done", "active", "queued"], + "modes": ["adaptive"], + "provider": "qwen", + "model": "test-model", + "max_turns": 300, + "rollout_concurrency": 2, + "reward_concurrency": 1, + }, + ) + write_jsonl( + root / "scheduler.jsonl", + [ + {"event": "rollout.started", "task": "done", "mode": "adaptive", "elapsed_s": 0}, + {"event": "rollout.started", "task": "active", "mode": "adaptive", "elapsed_s": 3}, + {"event": "rollout.completed", "task": "done", "mode": "adaptive", "elapsed_s": 100}, + {"event": "reward.started", "task": "done", "mode": "adaptive", "elapsed_s": 101}, + {"event": "reward.completed", "task": "done", "mode": "adaptive", "elapsed_s": 110, "quality_score": 0}, + ], + ) + now = datetime.now(timezone.utc).isoformat() + write_jsonl( + root / "done" / "adaptive" / "progress.jsonl", + [ + {"kind": "model_response", "turn": 1, "duration_ms": 2000, "created_at": now}, + {"kind": "tool_use", "turn": 1, "tool_name": "Bash", "created_at": now}, + {"kind": "run_completed", "turn": 1, "created_at": now}, + ], + ) + write_jsonl( + root / "active" / "adaptive" / "progress.jsonl", + [{"kind": "model_response", "turn": 1, "duration_ms": 1200, "created_at": now}], + ) + write_json( + root / "done" / "adaptive" / "result.json", + { + "quality_score": 81.82, + "success": False, + "agent_ok": True, + "usage": {"lead_turns": 1}, + "hidden_tests": {"pytest": {"passed": 9, "failed": 2, "errors": 0, "expected": 11}}, + "rescored_at": now, + }, + ) + (root / "done" / "adaptive" / "hidden-tests.log").write_text( + "nine passed, two failed\n", encoding="utf-8" + ) + return root + + def test_state_tracks_configured_queue_and_corrected_reward(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + store = dashboard.DashboardStore(self.make_run(Path(tmp))) + first = store.state() + second = store.state() + + self.assertEqual(first["summary"]["total"], 3) + self.assertEqual(first["summary"]["queued"], 1) + self.assertEqual(first["summary"]["active"], 1) + self.assertEqual(first["summary"]["rewards_completed"], 1) + self.assertEqual(first["summary"]["code_quality"], 81.82) + self.assertEqual(first["summary"]["coverage"], 1.0) + self.assertEqual(first["summary"]["protocol_yield"], 1.0) + self.assertEqual(first["summary"]["effective_quality"], 81.82) + done = next(task for task in first["tasks"] if task["task"] == "done") + self.assertEqual(done["quality_score"], 81.82) + self.assertEqual(done["code_quality_score"], 81.82) + self.assertEqual(done["protocol_status"], "passed") + self.assertEqual(done["effective_quality_score"], 81.82) + self.assertEqual((done["passed"], done["expected"]), (9, 11)) + self.assertTrue(done["rescored"]) + self.assertEqual(second["summary"]["model_calls"], 2) + + def test_v2_summary_separates_code_protocol_and_effective_quality(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + write_json( + root / "run-metadata.json", + { + "run_id": "v2-run", + "tasks": ["protocol-pass", "protocol-fail", "legacy-skip"], + "modes": ["forced-team"], + }, + ) + common = { + "agent_ok": True, + "integrity_ok": True, + "hidden_tests": { + "pytest": { + "expected": 4, + "passed": 3, + "failed": 1, + "errors": 0, + "quality_score": 75.0, + "returncode": 1, + "all_passed": False, + } + }, + } + write_json( + root / "protocol-pass" / "forced-team" / "result.json", + { + **common, + "quality_score": 75.0, + "result_schema_version": 2, + "code_quality_score": 75.0, + "protocol_ok": True, + "protocol_status": "passed", + "protocol_credit": 1.0, + "delivery_valid": True, + "effective_quality_score": 75.0, + "reward_outcome": "scored", + "reward_score_valid": True, + "metric_eligibility": { + "code_quality": True, + "protocol_yield": True, + "effective_quality": True, + }, + }, + ) + write_json( + root / "protocol-fail" / "forced-team" / "result.json", + { + **common, + "quality_score": 75.0, + "result_schema_version": 2, + "code_quality_score": 75.0, + "protocol_ok": False, + "protocol_status": "failed", + "protocol_credit": 0.0, + "delivery_valid": True, + "effective_quality_score": 0.0, + "reward_outcome": "scored", + "reward_score_valid": True, + "metric_eligibility": { + "code_quality": True, + "protocol_yield": True, + "effective_quality": True, + }, + }, + ) + write_json( + root / "legacy-skip" / "forced-team" / "result.json", + { + **common, + "quality_score": 0.0, + "protocol_ok": False, + "reward_skipped": True, + "hidden_tests": { + "skipped": True, + "pytest": common["hidden_tests"]["pytest"], + }, + }, + ) + + state = dashboard.DashboardStore(root).state() + + self.assertEqual(state["summary"]["code_quality"], 75.0) + self.assertAlmostEqual(state["summary"]["coverage"], 2 / 3) + self.assertAlmostEqual(state["summary"]["protocol_yield"], 1 / 3) + self.assertEqual(state["summary"]["effective_quality"], 25.0) + legacy = next(task for task in state["tasks"] if task["task"] == "legacy-skip") + self.assertEqual(legacy["reward_outcome"], "protocol_skipped_legacy") + self.assertIsNone(legacy["code_quality_score"]) + self.assertEqual(legacy["effective_quality_score"], 0.0) + + def test_task_detail_and_infrastructure_error_are_separate(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = self.make_run(Path(tmp)) + result_path = root / "done" / "adaptive" / "result.json" + result = json.loads(result_path.read_text(encoding="utf-8")) + result["hidden_tests"]["error"] = "score image build failed" + write_json(result_path, result) + store = dashboard.DashboardStore(root) + detail = store.task_detail("done") + + self.assertEqual(detail["task"]["status"], "infra_error") + self.assertEqual(detail["task"]["infrastructure_error"], "score image build failed") + self.assertIn("nine passed", detail["hidden_log"]) + self.assertEqual(len(detail["recent_events"]), 3) + + def test_v2_candidate_failure_is_not_reclassified_by_stale_hidden_error(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = self.make_run(Path(tmp)) + result_path = root / "done" / "adaptive" / "result.json" + result = json.loads(result_path.read_text(encoding="utf-8")) + result.update( + { + "result_schema_version": 2, + "failure_domain": "candidate", + "failure_class": "rollout_failure", + "is_infrastructure": False, + "retryable": False, + "reward_outcome": "missing_artifact", + "reward_score_valid": False, + "metric_eligibility": { + "code_quality": False, + "protocol_yield": True, + "effective_quality": True, + }, + } + ) + result["hidden_tests"]["error"] = "legacy rollout error" + write_json(result_path, result) + + detail = dashboard.DashboardStore(root).task_detail("done") + + self.assertEqual(detail["task"]["status"], "scored") + self.assertEqual(detail["task"]["failure_domain"], "candidate") + self.assertFalse(detail["task"]["is_infrastructure"]) + self.assertIsNone(detail["task"]["infrastructure_error"]) + + def test_task_detail_exposes_actor_aware_team_trace(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = self.make_run(Path(tmp)) + team_root = ( + root + / "done" + / "adaptive" + / "workspace" + / ".clawd" + / "teams" + / "team-1" + ) + write_json( + team_root / "team.json", + {"team_id": "team-1", "team_name": "forced", "status": "running"}, + ) + write_json( + team_root / "tasks.json", + { + "task-1": { + "subject": "inspect parser", + "status": "completed", + "owner": "worker-1", + "output": "done", + } + }, + ) + now = datetime.now(timezone.utc).isoformat() + write_jsonl( + team_root / "events.jsonl", + [ + { + "type": "agent.created", + "created_at": now, + "data": { + "agent": { + "agent_id": "worker-1", + "name": "parser_worker", + "role": "parser", + } + }, + }, + { + "type": "model.started", + "created_at": now, + "data": {"actor_name": "parser_worker", "turn": 3}, + }, + { + "type": "model.response", + "created_at": now, + "data": { + "actor_name": "parser_worker", + "turn": 3, + "content": "checking parser", + "duration_ms": 1200, + }, + }, + { + "type": "tool.started", + "created_at": now, + "data": { + "actor_name": "parser_worker", + "tool_name": "Bash", + "tool_use_id": "call-1", + "tool_input": {"command": "pytest"}, + }, + }, + { + "type": "tool.failed", + "created_at": now, + "data": { + "actor_name": "parser_worker", + "tool_name": "Bash", + "tool_use_id": "call-1", + "error": "exit 1", + "duration_ms": 400, + }, + }, + ], + ) + detail = dashboard.DashboardStore(root).task_detail("done") + + self.assertEqual(detail["team"]["name"], "forced") + self.assertEqual(detail["team"]["tasks"][0]["owner"], "parser_worker") + worker_events = [ + event + for event in detail["trace_events"] + if event["actor"] == "parser_worker" + ] + self.assertTrue(worker_events) + self.assertEqual(worker_events[-1]["turn"], 3) + self.assertEqual(worker_events[-1]["tool_use_id"], "call-1") + self.assertTrue(worker_events[-1]["is_error"]) + + def test_continuous_queue_is_the_authoritative_task_manifest(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + write_json( + root / "run-metadata.json", + { + "run_id": "continuous", + "started_at": datetime.now(timezone.utc).isoformat(), + "queue_mode": "continuous", + "modes": ["adaptive"], + "rollout_concurrency": 8, + }, + ) + with sqlite3.connect(root / "queue.sqlite3") as connection: + connection.execute( + """ + CREATE TABLE cases ( + id INTEGER PRIMARY KEY, task TEXT, mode TEXT, priority INTEGER, + status TEXT, attempt INTEGER, enqueued_at TEXT, started_at TEXT, + error TEXT + ) + """ + ) + connection.executemany( + "INSERT INTO cases VALUES (?, ?, 'adaptive', 0, ?, 0, ?, ?, NULL)", + [ + (1, "waiting", "queued", datetime.now(timezone.utc).isoformat(), None), + (2, "working", "rollout", datetime.now(timezone.utc).isoformat(), datetime.now(timezone.utc).isoformat()), + ], + ) + state = dashboard.DashboardStore(root).state() + + statuses = {task["task"]: task["status"] for task in state["tasks"]} + self.assertEqual(state["summary"]["total"], 2) + self.assertEqual(state["summary"]["queue_depth"], 1) + self.assertTrue(state["summary"]["queue_low"]) + self.assertEqual(statuses, {"waiting": "queued", "working": "running"}) + + def test_dashboard_reads_dynamic_concurrency_but_rejects_per_run_updates(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + write_json( + root / "run-metadata.json", + { + "run_id": "scalable", + "queue_mode": "continuous", + "rollout_concurrency": 8, + "reward_concurrency": 4, + }, + ) + with sqlite3.connect(root / "queue.sqlite3") as connection: + connection.execute( + """ + CREATE TABLE cases ( + id INTEGER PRIMARY KEY, task TEXT, mode TEXT, priority INTEGER, + status TEXT, attempt INTEGER, enqueued_at TEXT, started_at TEXT, + error TEXT + ) + """ + ) + connection.execute( + """ + CREATE TABLE worker_config ( + id INTEGER PRIMARY KEY, + rollout_concurrency INTEGER, + reward_concurrency INTEGER, + max_rollout_concurrency INTEGER, + max_reward_concurrency INTEGER, + updated_at TEXT + ) + """ + ) + connection.execute( + "INSERT INTO worker_config VALUES (1, 32, 4, 64, 16, ?)", + (datetime.now(timezone.utc).isoformat(),), + ) + store = dashboard.DashboardStore(root) + before = store.state() + with self.assertRaisesRegex(RuntimeError, "global_pool_supervisor.py"): + store.set_concurrency(rollout=16, reward=0) + after = store.state() + with sqlite3.connect(root / "queue.sqlite3") as connection: + configured = connection.execute( + "SELECT rollout_concurrency, reward_concurrency " + "FROM worker_config WHERE id=1" + ).fetchone() + + self.assertEqual(before["run"]["rollout_concurrency"], 32) + self.assertEqual(before["run"]["max_rollout_concurrency"], 64) + self.assertEqual(configured, (32, 4)) + self.assertEqual(after["run"]["rollout_concurrency"], 32) + self.assertEqual(after["run"]["reward_concurrency"], 4) + + def test_concurrency_post_returns_global_pool_conflict(self) -> None: + handler = object.__new__(dashboard.DashboardHandler) + handler.path = "/api/concurrency" + response: dict[str, object] = {} + + def capture(value: object, status: object = dashboard.HTTPStatus.OK) -> None: + response.update({"value": value, "status": status}) + + handler._send_json = capture + handler.do_POST() + + self.assertEqual(response["status"], dashboard.HTTPStatus.CONFLICT) + payload = response["value"] + self.assertIsInstance(payload, dict) + assert isinstance(payload, dict) + self.assertEqual(payload["code"], "global_pool_managed") + self.assertEqual(payload["global_state_endpoint"], "/api/global") + self.assertIn("global_pool_supervisor.py", payload["error"]) + + def test_requeued_case_ignores_scheduler_events_from_previous_attempt(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + write_json( + root / "run-metadata.json", + { + "run_id": "retrying", + "started_at": datetime.now(timezone.utc).isoformat(), + "queue_mode": "continuous", + "rollout_concurrency": 8, + }, + ) + write_jsonl( + root / "scheduler.jsonl", + [ + { + "event": "rollout.started", + "task": "again", + "mode": "adaptive", + "elapsed_s": 1, + }, + { + "event": "rollout.completed", + "task": "again", + "mode": "adaptive", + "elapsed_s": 2, + }, + { + "event": "reward.failed", + "task": "again", + "mode": "adaptive", + "elapsed_s": 3, + }, + ], + ) + with sqlite3.connect(root / "queue.sqlite3") as connection: + connection.execute( + """ + CREATE TABLE cases ( + id INTEGER PRIMARY KEY, task TEXT, mode TEXT, priority INTEGER, + status TEXT, attempt INTEGER, enqueued_at TEXT, started_at TEXT, + rollout_finished_at TEXT, error TEXT + ) + """ + ) + connection.execute( + "INSERT INTO cases VALUES " + "(1, 'again', 'adaptive', 0, 'queued', 2, ?, NULL, NULL, NULL)", + (datetime.now(timezone.utc).isoformat(),), + ) + state = dashboard.DashboardStore(root).state() + + self.assertEqual(state["tasks"][0]["status"], "queued") + self.assertEqual(state["summary"]["queued"], 1) + self.assertEqual(state["summary"]["started"], 0) + self.assertEqual(state["summary"]["rollouts_completed"], 0) + self.assertEqual(state["summary"]["rewards_completed"], 0) + + def test_continuous_queue_keeps_modes_as_distinct_cases(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + write_json( + root / "run-metadata.json", + { + "run_id": "dual-mode", + "started_at": datetime.now(timezone.utc).isoformat(), + "queue_mode": "continuous", + "rollout_concurrency": 8, + }, + ) + with sqlite3.connect(root / "queue.sqlite3") as connection: + connection.execute( + """ + CREATE TABLE cases ( + id INTEGER PRIMARY KEY, task TEXT, mode TEXT, priority INTEGER, + status TEXT, attempt INTEGER, enqueued_at TEXT, started_at TEXT, + error TEXT + ) + """ + ) + now = datetime.now(timezone.utc).isoformat() + connection.executemany( + "INSERT INTO cases VALUES (?, 'same-task', ?, 0, 'queued', 0, ?, NULL, NULL)", + [(1, "adaptive", now), (2, "forced-team", now)], + ) + store = dashboard.DashboardStore(root) + state = store.state() + forced = store.task_detail("same-task", "forced-team") + + self.assertEqual(state["summary"]["total"], 2) + self.assertEqual( + [(task["task"], task["mode"]) for task in state["tasks"]], + [("same-task", "adaptive"), ("same-task", "forced-team")], + ) + self.assertEqual(forced["task"]["mode"], "forced-team") + + def test_comparison_merges_missing_mode_from_sibling_baseline(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + runs = Path(tmp) + current = runs / "current" + baseline = runs / "baseline-32" + write_json( + current / "run-metadata.json", + { + "run_id": "current", + "tasks": ["old-task", "new-task"], + "modes": ["adaptive", "forced-team"], + "comparison": { + "modes": ["adaptive", "forced-team"], + "baseline_runs": {"adaptive": "baseline-32"}, + }, + }, + ) + + def result(quality: float, runtime: float, tokens: int) -> dict[str, object]: + return { + "model": "same-model", + "quality_score": quality, + "agent_elapsed_s": runtime, + "calls": {"model": 10, "tools": 9}, + "usage": {"total_tokens": tokens}, + "success": False, + } + + write_json( + baseline / "old-task" / "adaptive" / "result.json", + result(10, 100, 0), + ) + write_json( + current / "old-task" / "forced-team" / "result.json", + result(15, 70, 500), + ) + write_json( + current / "new-task" / "adaptive" / "result.json", + result(20, 80, 400), + ) + write_json( + current / "new-task" / "forced-team" / "result.json", + result(18, 60, 600), + ) + + comparison = dashboard.DashboardStore(current).state()["comparison"] + + self.assertIsNotNone(comparison) + assert comparison is not None + self.assertEqual(comparison["paired_count"], 2) + self.assertEqual(comparison["cross_run_count"], 1) + self.assertEqual(comparison["deployment_mismatch_count"], 0) + self.assertEqual( + comparison["mode_summaries"]["adaptive"]["source_runs"], + {"baseline-32": 1, "current": 1}, + ) + self.assertEqual( + comparison["mode_summaries"]["adaptive"]["token_coverage"], 1 + ) + self.assertAlmostEqual( + comparison["paired"]["average_quality_delta"], 1.5 + ) + self.assertEqual(comparison["paired"]["right_faster"], 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_nl2repo_evaluation_queue.py b/tests/test_nl2repo_evaluation_queue.py new file mode 100644 index 0000000..543505e --- /dev/null +++ b/tests/test_nl2repo_evaluation_queue.py @@ -0,0 +1,1336 @@ +from __future__ import annotations + +import importlib.util +import json +import os +import sys +import tempfile +import threading +import time +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + + +MODULE_PATH = ( + Path(__file__).resolve().parents[1] + / "teammate-evals" + / "nl2repo-pilot" + / "evaluation_queue.py" +) +SPEC = importlib.util.spec_from_file_location("nl2repo_evaluation_queue", MODULE_PATH) +assert SPEC is not None and SPEC.loader is not None +queue = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = queue +SPEC.loader.exec_module(queue) + +SUPERVISOR_PATH = MODULE_PATH.with_name("global_pool_supervisor.py") +SUPERVISOR_SPEC = importlib.util.spec_from_file_location( + "nl2repo_global_pool_supervisor", SUPERVISOR_PATH +) +assert SUPERVISOR_SPEC is not None and SUPERVISOR_SPEC.loader is not None +supervisor = importlib.util.module_from_spec(SUPERVISOR_SPEC) +sys.modules[SUPERVISOR_SPEC.name] = supervisor +with patch.dict(sys.modules, {"evaluation_queue": queue}): + SUPERVISOR_SPEC.loader.exec_module(supervisor) + + +class TestEvaluationQueue(unittest.TestCase): + @staticmethod + def _rollout_artifact( + root: Path, + task: dict[str, object], + mode: str, + agent: dict[str, object], + ) -> SimpleNamespace: + case_root = root / str(task["id"]) / mode + workspace = case_root / "workspace" + workspace.mkdir(parents=True) + (workspace / "start.md").write_text("spec", encoding="utf-8") + (case_root / "agent-result.json").write_text( + json.dumps(agent), encoding="utf-8" + ) + return SimpleNamespace( + task=task, + mode=mode, + case_root=case_root, + workspace=workspace, + start_hash="hash", + agent=agent, + agent_elapsed_s=0.01, + agent_timed_out=False, + agent_returncode=0, + ) + + def test_reward_retries_only_infrastructure_failures(self) -> None: + calls = 0 + sleeps: list[float] = [] + retries: list[tuple[int, str]] = [] + + def score() -> dict[str, object]: + nonlocal calls + calls += 1 + if calls == 1: + return {"hidden_tests": {"error": "Docker build failed"}} + return {"quality_score": 75.0, "hidden_tests": {"pytest": {}}} + + result = queue.score_with_infrastructure_retries( + score, + attempts=3, + delay_s=0.25, + sleep_fn=sleeps.append, + on_retry=lambda attempt, error: retries.append((attempt, error)), + ) + + self.assertEqual(result["quality_score"], 75.0) + self.assertEqual(calls, 2) + self.assertEqual(sleeps, [0.25]) + self.assertEqual(retries, [(1, "Docker build failed")]) + + def test_reward_retries_transient_exceptions(self) -> None: + calls = 0 + + def score() -> dict[str, object]: + nonlocal calls + calls += 1 + if calls < 3: + raise TimeoutError("temporary scorer timeout") + return {"hidden_tests": {}} + + result = queue.score_with_infrastructure_retries( + score, attempts=3, delay_s=0, sleep_fn=lambda _: None + ) + + self.assertEqual(result, {"hidden_tests": {}}) + self.assertEqual(calls, 3) + + def test_remaining_qwen32_selects_the_other_tasks(self) -> None: + tasks = [{"id": f"task-{index:03d}"} for index in range(104)] + args = SimpleNamespace(task=None, task_set="remaining-qwen32") + with ( + patch.object(queue.benchmark, "list_tasks", return_value=tasks), + patch.object( + queue.benchmark, + "select_task_subset", + return_value=[task["id"] for task in tasks[:32]], + ), + patch.object(queue.benchmark, "load_task", return_value={}), + ): + selected = queue.resolve_task_names(args, Path("/tmp/upstream")) + + self.assertEqual(len(selected), 72) + self.assertEqual(selected[0], "task-032") + self.assertEqual(selected[-1], "task-103") + + def test_enqueue_is_persistent_deduplicated_and_priority_ordered(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + first = queue.QueueStore(root) + added, skipped = first.enqueue(["one", "two"], "adaptive") + more, duplicate = first.enqueue(["urgent", "one"], "adaptive", priority=10) + reopened = queue.QueueStore(root) + cases = reopened.cases() + database_exists = (root / queue.QUEUE_DB).is_file() + + self.assertEqual(added, ["one", "two"]) + self.assertEqual(skipped, []) + self.assertEqual(more, ["urgent"]) + self.assertEqual(duplicate, ["one"]) + self.assertEqual([case["task"] for case in cases], ["urgent", "one", "two"]) + self.assertTrue(database_exists) + + def test_done_persists_the_valid_canonical_reward_score(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + store = queue.QueueStore(Path(tmp)) + store.enqueue(["scored"], "adaptive-team-v2") + case = store.claim("queued", "rollout", 1)[0] + store.mark_rollout_complete(int(case["id"])) + case = store.claim("reward_pending", "rewarding", 1)[0] + store.mark_done( + int(case["id"]), + { + "code_quality_score": 87.5, + "quality_score": None, + "reward_outcome": "scored", + "reward_score_valid": True, + "success": False, + }, + ) + persisted = store.cases()[0] + + self.assertEqual(persisted["status"], "done") + self.assertEqual(persisted["quality_score"], 87.5) + + def test_concurrency_configuration_is_persistent_and_capacity_bounded(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + store = queue.QueueStore(Path(tmp)) + initial = store.initialize_concurrency( + 8, 4, max_rollout=64, max_reward=16 + ) + scaled = store.set_concurrency(rollout=32) + paused = store.set_concurrency(rollout=0, reward=0) + reopened = queue.QueueStore(Path(tmp)).concurrency() + with self.assertRaisesRegex(ValueError, "capacity 64"): + store.set_concurrency(rollout=65) + + self.assertEqual(initial["rollout_concurrency"], 8) + self.assertEqual(scaled["rollout_concurrency"], 32) + self.assertEqual(scaled["reward_concurrency"], 4) + self.assertEqual(paused["rollout_concurrency"], 0) + self.assertEqual(reopened["reward_concurrency"], 0) + + def test_worker_can_start_paused_for_global_pool_control(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + store = queue.QueueStore(Path(tmp)) + configured = store.initialize_concurrency( + 0, 0, max_rollout=64, max_reward=64 + ) + + self.assertEqual(configured["rollout_concurrency"], 0) + self.assertEqual(configured["reward_concurrency"], 0) + + def test_direct_serve_is_rejected_before_creating_queue_state(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + run_root = Path(tmp) / "direct-run" + with ( + patch.dict(os.environ, {}, clear=True), + self.assertRaisesRegex( + SystemExit, + r"direct .*serve.*disabled.*global_pool_supervisor\.py", + ), + ): + queue.main(["--run", str(run_root), "serve"]) + + self.assertFalse(run_root.exists()) + + def test_direct_scale_is_rejected_before_creating_queue_state(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + run_root = Path(tmp) / "direct-scale" + with self.assertRaisesRegex( + SystemExit, r"direct .*scale.*disabled.*global pool supervisor" + ): + queue.main( + [ + "--run", + str(run_root), + "scale", + "--rollout-concurrency", + "64", + ] + ) + + self.assertFalse(run_root.exists()) + + def test_scale_has_no_emergency_command_line_bypass(self) -> None: + with self.assertRaises(SystemExit): + queue.build_parser().parse_args( + [ + "--run", + "/tmp/run", + "scale", + "--rollout-concurrency", + "1", + "--emergency-allow-manual-scale", + ] + ) + + def test_serve_policy_requires_live_parent_lock_and_registered_run(self) -> None: + parser = queue.build_parser() + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + run_root = (root / "run").resolve() + lock_path = root / "global-pool.lock" + lock_path.write_text( + json.dumps({"pid": 4242, "runs": [str(run_root)]}), + encoding="utf-8", + ) + managed = parser.parse_args(["--run", str(run_root), "serve"]) + environment = { + queue.GLOBAL_POOL_WORKER_ENV: queue.GLOBAL_POOL_WORKER_MARKER + } + with patch.object(queue, "_global_pool_lock_is_held", return_value=True): + queue.enforce_serve_launch_policy( + managed, + environ=environment, + lock_path=lock_path, + parent_pid=4242, + ) + with self.assertRaisesRegex(SystemExit, "matching live supervisor"): + queue.enforce_serve_launch_policy( + managed, + environ=environment, + lock_path=lock_path, + parent_pid=9999, + ) + + lock_path.write_text( + json.dumps({"pid": 4242, "runs": [run_root.name]}), + encoding="utf-8", + ) + queue.enforce_serve_launch_policy( + managed, + environ=environment, + lock_path=lock_path, + parent_pid=4242, + ) + + def test_supervisor_marker_alone_cannot_start_worker(self) -> None: + args = queue.build_parser().parse_args(["--run", "/tmp/run", "serve"]) + with self.assertRaisesRegex(SystemExit, "matching live supervisor"): + queue.enforce_serve_launch_policy( + args, + environ={ + queue.GLOBAL_POOL_WORKER_ENV: queue.GLOBAL_POOL_WORKER_MARKER + }, + lock_path=Path("/definitely/missing/global-pool.lock"), + parent_pid=4242, + ) + + def test_worker_watchdog_terminates_after_persistent_lease_loss(self) -> None: + stop_event = threading.Event() + terminated = threading.Event() + codes: list[int] = [] + + def terminate(code: int) -> None: + codes.append(code) + terminated.set() + + with patch.object( + queue, "_is_authorized_global_pool_worker", return_value=False + ): + thread = queue.start_supervisor_lease_watchdog( + Path("/tmp/run"), + stop_event, + interval_s=0.001, + exit_fn=terminate, + ) + self.assertTrue(terminated.wait(1)) + stop_event.set() + thread.join(timeout=1) + + self.assertEqual(codes, [75]) + + def test_serve_has_no_emergency_command_line_bypass(self) -> None: + with self.assertRaises(SystemExit): + queue.build_parser().parse_args( + [ + "--run", + "/tmp/run", + "serve", + "--emergency-allow-standalone-serve", + ] + ) + + def test_supervisor_injects_worker_marker_without_emergency_override(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + args = supervisor.build_parser().parse_args( + [ + "--run", + str(root / "run"), + "--ags-env-file", + str(root / "ags.env"), + ] + ) + command = supervisor.build_worker_command(args, root / "run") + with patch.dict(os.environ, {"PRESERVED": "yes"}, clear=True): + environment = supervisor.build_worker_environment() + + self.assertEqual(environment["PRESERVED"], "yes") + self.assertEqual( + environment[queue.GLOBAL_POOL_WORKER_ENV], + queue.GLOBAL_POOL_WORKER_MARKER, + ) + self.assertNotIn("--emergency-allow-standalone-serve", command) + with self.assertRaises(SystemExit): + supervisor.build_parser().parse_args( + [ + "--run", + str(root / "run"), + "--ags-env-file", + str(root / "ags.env"), + "--stop-when-empty", + ] + ) + + def test_managed_worker_starts_in_an_isolated_process_group(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + process = SimpleNamespace(pid=1234, poll=lambda: None) + with patch.object( + supervisor.subprocess, "Popen", return_value=process + ) as popen: + worker = supervisor.ManagedWorker(Path(tmp), ["worker-command"]) + worker.start() + assert worker.log_handle is not None + worker.log_handle.close() + worker.log_handle = None + + self.assertTrue(popen.call_args.kwargs["start_new_session"]) + + def test_second_global_supervisor_is_rejected_before_starting_workers(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + pool_root = Path(tmp) / "pilot-wide-pool" + run_root = Path(tmp) / "some-other-directory" / "r1" + env_file = Path(tmp) / "ags.env" + env_file.write_text("AGS_ENDPOINT=https://example.invalid\n", encoding="utf-8") + lock = supervisor.GlobalPoolLock( + pool_root / "global-pool.lock", [run_root.resolve()] + ) + with patch.object(supervisor, "GLOBAL_POOL_ROOT", pool_root): + with lock: + metadata = json.loads( + (pool_root / "global-pool.lock").read_text(encoding="utf-8") + ) + self.assertEqual(metadata["runs"], [str(run_root.resolve())]) + self.assertEqual(metadata["schema_version"], 2) + self.assertEqual(metadata["worker_pids"], []) + with self.assertRaisesRegex( + SystemExit, "another global pool supervisor already owns" + ): + supervisor.main( + [ + "--run", + str(run_root), + "--ags-env-file", + str(env_file), + ] + ) + + self.assertFalse((run_root / queue.QUEUE_DB).exists()) + + def test_dead_worker_is_frozen_before_restart_and_allocation_is_restored( + self, + ) -> None: + with tempfile.TemporaryDirectory() as tmp: + store = queue.QueueStore(Path(tmp)) + store.initialize_concurrency(8, 4, max_rollout=64, max_reward=64) + observed_at_restart: list[tuple[int, int]] = [] + + class DeadWorker: + run_root = Path(tmp) + + @staticmethod + def needs_restart() -> bool: + return True + + @staticmethod + def restart() -> None: + configured = store.concurrency() + assert configured is not None + observed_at_restart.append( + ( + int(configured["rollout_concurrency"]), + int(configured["reward_concurrency"]), + ) + ) + + restarted = supervisor.restart_worker_safely(DeadWorker(), store) + corrected = supervisor.reconcile_worker_concurrency( + [store], ((8, 4),) + ) + restored = store.concurrency() + + self.assertTrue(restarted) + self.assertEqual(observed_at_restart, [(0, 0)]) + self.assertEqual(corrected, [0]) + self.assertIsNotNone(restored) + self.assertEqual(restored["rollout_concurrency"], 8) + self.assertEqual(restored["reward_concurrency"], 4) + + def test_supervisor_reconciles_out_of_band_scale_even_when_allocation_is_same( + self, + ) -> None: + with tempfile.TemporaryDirectory() as tmp: + store = queue.QueueStore(Path(tmp)) + store.initialize_concurrency(8, 4, max_rollout=64, max_reward=64) + # Simulate a direct evaluation_queue.py scale while the calculated + # global allocation tuple itself remains unchanged. + store.set_concurrency(rollout=32, reward=32) + + corrected = supervisor.reconcile_worker_concurrency( + [store], ((8, 4),) + ) + restored = store.concurrency() + + self.assertEqual(corrected, [0]) + self.assertIsNotNone(restored) + self.assertEqual(restored["rollout_concurrency"], 8) + self.assertEqual(restored["reward_concurrency"], 4) + + def test_global_supervisor_allows_exactly_one_disabled_pool(self) -> None: + supervisor.validate_pool_capacities(0, 64, 64) + supervisor.validate_pool_capacities(32, 0, 64) + + with self.assertRaisesRegex( + ValueError, "at least one global capacity must be positive" + ): + supervisor.validate_pool_capacities(0, 0, 64) + with self.assertRaisesRegex(ValueError, "must be non-negative"): + supervisor.validate_pool_capacities(-1, 32, 64) + + def test_one_sided_pool_completion_ignores_the_disabled_stage(self) -> None: + reward_backlog = [ + {"queued": 0, "rollout": 0, "reward_pending": 2, "rewarding": 1} + ] + rollout_backlog = [ + {"queued": 2, "rollout": 1, "reward_pending": 0, "rewarding": 0} + ] + + self.assertFalse( + supervisor.enabled_pool_has_work( + reward_backlog, rollout_capacity=32, reward_capacity=0 + ) + ) + self.assertTrue( + supervisor.enabled_pool_has_work( + reward_backlog, rollout_capacity=0, reward_capacity=64 + ) + ) + self.assertFalse( + supervisor.enabled_pool_has_work( + rollout_backlog, rollout_capacity=0, reward_capacity=64 + ) + ) + self.assertTrue( + supervisor.enabled_pool_has_work( + rollout_backlog, rollout_capacity=32, reward_capacity=0 + ) + ) + + def test_global_supervisor_rejects_both_pools_disabled_before_queue_creation( + self, + ) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + run_root = root / "run" + with self.assertRaisesRegex( + SystemExit, "at least one global capacity must be positive" + ): + supervisor.main( + [ + "--run", + str(run_root), + "--ags-env-file", + str(root / "ags.env"), + "--rollout-capacity", + "0", + "--reward-capacity", + "0", + ] + ) + + self.assertFalse(run_root.exists()) + + def test_global_slots_pipeline_into_the_next_run(self) -> None: + snapshots = [ + {"rollout": 17, "queued": 0}, + {"rollout": 0, "queued": 104}, + ] + + allocated = queue.allocate_global_slots( + snapshots, + 32, + active_key="rollout", + pending_key="queued", + ) + + self.assertEqual(allocated, [17, 15]) + + def test_global_slots_fair_share_multiple_pending_runs(self) -> None: + snapshots = [ + {"rollout": 0, "queued": 104}, + {"rollout": 0, "queued": 104}, + {"rollout": 0, "queued": 104}, + ] + + allocated = queue.allocate_global_slots( + snapshots, + 32, + active_key="rollout", + pending_key="queued", + ) + + self.assertEqual(allocated, [11, 11, 10]) + + def test_global_slots_preserve_active_work_before_fair_sharing(self) -> None: + snapshots = [ + {"rollout": 17, "queued": 100}, + {"rollout": 0, "queued": 100}, + ] + + allocated = queue.allocate_global_slots( + snapshots, + 32, + active_key="rollout", + pending_key="queued", + ) + + self.assertEqual(allocated, [17, 15]) + + def test_live_loop_expands_and_shrinks_without_preempting_active_work(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + store = queue.QueueStore(root) + store.initialize_concurrency(2, 1, max_rollout=4, max_reward=2) + store.enqueue([f"task-{index}" for index in range(6)], "adaptive") + stop = threading.Event() + first_release = threading.Event() + second_release = threading.Event() + lock = threading.Lock() + started: list[str] = [] + active = 0 + max_active = 0 + resized: list[dict[str, int]] = [] + + def load_task(name: str) -> dict[str, object]: + return {"id": name} + + def rollout(task: dict[str, object], mode: str) -> SimpleNamespace: + nonlocal active, max_active + task_name = str(task["id"]) + with lock: + started.append(task_name) + active += 1 + max_active = max(max_active, active) + start_number = len(started) + release = first_release if start_number <= 4 else second_release + self.assertTrue(release.wait(timeout=4)) + case_root = root / task_name / mode + workspace = case_root / "workspace" + workspace.mkdir(parents=True) + (case_root / "agent-result.json").write_text( + json.dumps({"ok": True}), encoding="utf-8" + ) + with lock: + active -= 1 + return SimpleNamespace( + task=task, + mode=mode, + case_root=case_root, + workspace=workspace, + start_hash="hash", + agent={"ok": True}, + agent_elapsed_s=0.01, + agent_timed_out=False, + agent_returncode=0, + ) + + def reward(artifact: object) -> dict[str, object]: + return {"quality_score": 100.0, "success": True} + + thread = threading.Thread( + target=queue.run_queue_loop, + args=(store, load_task, rollout, reward), + kwargs={ + "rollout_concurrency": 2, + "reward_concurrency": 1, + "max_rollout_concurrency": 4, + "max_reward_concurrency": 2, + "concurrency_loader": store.concurrency, + "stop_event": stop, + "poll_interval_s": 0.01, + "on_resize": resized.append, + }, + ) + thread.start() + deadline = time.monotonic() + 2 + while time.monotonic() < deadline and len(started) < 2: + time.sleep(0.01) + store.set_concurrency(rollout=4) + deadline = time.monotonic() + 2 + while time.monotonic() < deadline and len(started) < 4: + time.sleep(0.01) + store.set_concurrency(rollout=1) + first_release.set() + deadline = time.monotonic() + 2 + while time.monotonic() < deadline and len(started) < 5: + time.sleep(0.01) + time.sleep(0.08) + started_while_fifth_blocked = len(started) + second_release.set() + deadline = time.monotonic() + 3 + while time.monotonic() < deadline and store.counts()["done"] < 6: + time.sleep(0.01) + stop.set() + thread.join(timeout=3) + + self.assertFalse(thread.is_alive()) + self.assertEqual(max_active, 4) + self.assertEqual(started_while_fifth_blocked, 5) + self.assertEqual(len(started), 6) + self.assertEqual( + [item["rollout_concurrency"] for item in resized], [4, 1] + ) + + def test_live_loop_can_restart_while_both_pools_are_paused(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + store = queue.QueueStore(root) + store.initialize_concurrency(1, 1, max_rollout=2, max_reward=2) + store.set_concurrency(rollout=0, reward=0) + stop = threading.Event() + started = threading.Event() + + def rollout(task: dict[str, object], mode: str) -> object: + started.set() + raise AssertionError("paused queue must not start a rollout") + + thread = threading.Thread( + target=queue.run_queue_loop, + args=(store, lambda name: {"id": name}, rollout, lambda artifact: {}), + kwargs={ + "rollout_concurrency": 0, + "reward_concurrency": 0, + "max_rollout_concurrency": 2, + "max_reward_concurrency": 2, + "concurrency_loader": store.concurrency, + "stop_event": stop, + "poll_interval_s": 0.01, + }, + ) + thread.start() + time.sleep(0.05) + stop.set() + thread.join(timeout=2) + + self.assertFalse(thread.is_alive()) + self.assertFalse(started.is_set()) + + def test_recover_returns_interrupted_work_to_the_correct_stage(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + store = queue.QueueStore(Path(tmp)) + store.enqueue(["rollout", "reward"], "adaptive") + rollout_case = store.claim("queued", "rollout", 1)[0] + partial = Path(tmp) / "rollout" / "adaptive" + partial.mkdir(parents=True) + (partial / "progress.jsonl").write_text("partial\n", encoding="utf-8") + reward_case = store.claim("queued", "rollout", 1)[0] + store.mark_rollout_complete(reward_case["id"]) + store.claim("reward_pending", "rewarding", 1) + + recovered = store.recover_interrupted() + statuses = {case["task"]: case["status"] for case in store.cases()} + archives = list( + (Path(tmp) / "_attempts" / "rollout" / "adaptive").glob( + "attempt-1-interrupted-*" + ) + ) + archived_partial = ( + len(archives) == 1 and (archives[0] / "progress.jsonl").is_file() + ) + + self.assertEqual(recovered, {"rollout": 1, "reward": 1}) + self.assertEqual(statuses["rollout"], "queued") + self.assertEqual(statuses["reward"], "reward_pending") + self.assertGreaterEqual(rollout_case["id"], 1) + self.assertEqual(len(archives), 1) + self.assertTrue(archived_partial) + + def test_completed_inflight_rollout_can_be_salvaged_during_handoff(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + store = queue.QueueStore(root) + store.enqueue(["finished", "still-running"], "adaptive") + finished = store.claim("queued", "rollout", 1)[0] + running = store.claim("queued", "rollout", 1)[0] + case_root = root / "finished" / "adaptive" + workspace = case_root / "workspace" + workspace.mkdir(parents=True) + (workspace / "start.md").write_text("spec", encoding="utf-8") + (case_root / "agent-result.json").write_text( + json.dumps({"ok": True}), encoding="utf-8" + ) + + salvaged = store.salvage_completed_rollouts( + exclude_case_ids={int(running["id"])} + ) + statuses = {case["task"]: case["status"] for case in store.cases()} + artifact = json.loads( + (case_root / "rollout-artifact.json").read_text(encoding="utf-8") + ) + + self.assertEqual([case["id"] for case in salvaged], [finished["id"]]) + self.assertEqual(statuses["finished"], "reward_pending") + self.assertEqual(statuses["still-running"], "rollout") + self.assertTrue(artifact["salvaged"]) + + def test_add_cli_can_update_a_queue_owned_by_another_process(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + with ( + patch.object(queue.benchmark, "resolve_upstream", return_value=root), + patch.object(queue.benchmark, "load_task", return_value={"id": "new-task"}), + ): + returncode = queue.main( + ["--run", str(root / "run"), "add", "--task", "new-task"] + ) + cases = queue.QueueStore(root / "run").cases() + + self.assertEqual(returncode, 0) + self.assertEqual( + [(case["task"], case["status"]) for case in cases], + [("new-task", "queued")], + ) + + def test_retry_archives_the_previous_attempt(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + store = queue.QueueStore(root) + store.enqueue(["again"], "adaptive") + case = store.claim("queued", "rollout", 1)[0] + store.mark_failed(case["id"], "broken") + case_root = root / "again" / "adaptive" + case_root.mkdir(parents=True) + (case_root / "result.json").write_text("{}", encoding="utf-8") + + retried, missing = store.retry(["again"], "adaptive") + archives = list((root / "_attempts" / "again" / "adaptive").glob("attempt-1-*")) + archived_result = len(archives) == 1 and (archives[0] / "result.json").is_file() + + self.assertEqual(retried, ["again"]) + self.assertEqual(missing, []) + self.assertEqual(len(archives), 1) + self.assertTrue(archived_result) + + def test_retry_does_not_expose_queued_case_before_archive_finishes(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + store = queue.QueueStore(root) + store.enqueue(["atomic"], "adaptive") + case = store.claim("queued", "rollout", 1)[0] + store.mark_failed(case["id"], "try again") + case_root = root / "atomic" / "adaptive" + case_root.mkdir(parents=True) + (case_root / "result.json").write_text("{}", encoding="utf-8") + + archive_started = threading.Event() + release_archive = threading.Event() + claim_finished = threading.Event() + retry_result: list[tuple[list[str], list[str]]] = [] + claim_result: list[list[dict[str, object]]] = [] + errors: list[BaseException] = [] + real_move = queue.shutil.move + + def blocking_move(source: str, destination: str) -> object: + archive_started.set() + if not release_archive.wait(timeout=3): + raise TimeoutError("test did not release archive") + return real_move(source, destination) + + def retry_case() -> None: + try: + retry_result.append(store.retry(["atomic"], "adaptive")) + except BaseException as exc: + errors.append(exc) + + def claim_case() -> None: + try: + claim_result.append(store.claim("queued", "rollout", 1)) + except BaseException as exc: + errors.append(exc) + finally: + claim_finished.set() + + with patch.object(queue.shutil, "move", side_effect=blocking_move): + retry_thread = threading.Thread(target=retry_case) + retry_thread.start() + self.assertTrue(archive_started.wait(timeout=2)) + claim_thread = threading.Thread(target=claim_case) + claim_thread.start() + self.assertFalse(claim_finished.wait(timeout=0.1)) + release_archive.set() + retry_thread.join(timeout=3) + claim_thread.join(timeout=3) + + archives = list( + (root / "_attempts" / "atomic" / "adaptive").glob( + "attempt-1-manual-retry-*" + ) + ) + archived_result = bool( + archives and (archives[0] / "result.json").is_file() + ) + + self.assertEqual(errors, []) + self.assertEqual(retry_result, [(["atomic"], [])]) + self.assertEqual(len(claim_result), 1) + self.assertEqual(len(claim_result[0]), 1) + self.assertEqual(claim_result[0][0]["attempt"], 2) + self.assertTrue(archived_result) + + def test_retry_restores_case_root_when_archive_step_fails(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + store = queue.QueueStore(root) + store.enqueue(["restore"], "adaptive") + case = store.claim("queued", "rollout", 1)[0] + store.mark_failed(case["id"], "try again") + case_root = root / "restore" / "adaptive" + case_root.mkdir(parents=True) + (case_root / "result.json").write_text("old", encoding="utf-8") + real_move = queue.shutil.move + moves = 0 + + def fail_after_first_move(source: str, destination: str) -> object: + nonlocal moves + moves += 1 + result = real_move(source, destination) + if moves == 1: + raise OSError("simulated post-rename failure") + return result + + with ( + patch.object( + queue.shutil, "move", side_effect=fail_after_first_move + ), + self.assertRaisesRegex(OSError, "post-rename failure"), + ): + store.retry(["restore"], "adaptive") + restored_case = store.cases()[0] + restored_result = (case_root / "result.json").read_text(encoding="utf-8") + + self.assertEqual(moves, 2) + self.assertEqual(restored_case["status"], "failed") + self.assertEqual(restored_case["attempt"], 1) + self.assertEqual(restored_result, "old") + + def test_retry_of_reward_failure_reuses_the_completed_rollout(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + store = queue.QueueStore(root) + store.enqueue(["rescore"], "adaptive") + case = store.claim("queued", "rollout", 1)[0] + case_root = root / "rescore" / "adaptive" + workspace = case_root / "workspace" + workspace.mkdir(parents=True) + (workspace / "start.md").write_text("spec", encoding="utf-8") + (case_root / "agent-result.json").write_text( + json.dumps({"ok": True, "rollout_outcome": "completed"}), + encoding="utf-8", + ) + (case_root / "rollout-artifact.json").write_text( + json.dumps( + { + "start_hash": "hash", + "agent_elapsed_s": 1.0, + "agent_timed_out": False, + "agent_returncode": 0, + } + ), + encoding="utf-8", + ) + (case_root / "result.json").write_text( + json.dumps({"reward_outcome": "infra_error"}), encoding="utf-8" + ) + store.mark_rollout_complete(case["id"]) + store.claim("reward_pending", "rewarding", 1) + store.mark_failed(case["id"], "scorer unavailable") + + retried, missing = store.retry(["rescore"], "adaptive") + retried_case = store.cases()[0] + artifact_preserved = (case_root / "rollout-artifact.json").is_file() + result_preserved = (case_root / "result.json").is_file() + + self.assertEqual(retried, ["rescore"]) + self.assertEqual(missing, []) + self.assertEqual(retried_case["status"], "reward_pending") + self.assertEqual(retried_case["attempt"], 1) + self.assertTrue(artifact_preserved) + self.assertTrue(result_preserved) + + def test_incomplete_rollout_cannot_enter_reward_queue(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + case_root = Path(tmp) / "broken" / "adaptive" + case_root.mkdir(parents=True) + artifact = SimpleNamespace( + task={"id": "broken"}, + mode="adaptive", + case_root=case_root, + start_hash="hash", + agent_elapsed_s=0.1, + agent_timed_out=False, + agent_returncode=1, + ) + + with self.assertRaisesRegex( + FileNotFoundError, + "refusing to persist incomplete rollout", + ): + queue.persist_artifact(artifact) + + self.assertFalse((case_root / "rollout-artifact.json").exists()) + + def test_retryable_rollout_infrastructure_error_requeues_with_attempt_budget( + self, + ) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + store = queue.QueueStore(root) + store.enqueue(["retry-me"], "adaptive") + rollout_calls = 0 + reward_calls = 0 + events: list[dict[str, object]] = [] + + def rollout(task: dict[str, object], mode: str) -> SimpleNamespace: + nonlocal rollout_calls + rollout_calls += 1 + agent: dict[str, object] + if rollout_calls == 1: + agent = { + "ok": False, + "rollout_outcome": "infra_error", + "rollout_infrastructure": True, + "rollout_retryable": True, + "workspace_download_error": "temporary transfer failure", + } + else: + agent = {"ok": True, "rollout_outcome": "completed"} + return self._rollout_artifact(root, task, mode, agent) + + def reward(artifact: object) -> dict[str, object]: + nonlocal reward_calls + reward_calls += 1 + return {"quality_score": 75.0, "success": False} + + queue.run_queue_loop( + store, + lambda name: {"id": name}, + rollout, + reward, + rollout_concurrency=1, + reward_concurrency=1, + rollout_attempts=2, + stop_event=threading.Event(), + poll_interval_s=0.001, + stop_when_empty=True, + on_event=events.append, + ) + case = store.cases()[0] + archives = list( + (root / "_attempts" / "retry-me" / "adaptive").glob( + "attempt-1-infra-retry-*" + ) + ) + archived_agent_exists = bool( + archives and (archives[0] / "agent-result.json").is_file() + ) + + self.assertEqual(case["status"], "done") + self.assertEqual(case["attempt"], 2) + self.assertEqual(rollout_calls, 2) + self.assertEqual(reward_calls, 1) + self.assertEqual(len(archives), 1) + self.assertTrue(archived_agent_exists) + self.assertEqual( + [event["event"] for event in events].count("rollout.requeued"), 1 + ) + + def test_retryable_infrastructure_exception_from_rollout_is_requeued(self) -> None: + class RetryableInfrastructureError(RuntimeError): + retryable = True + failure_domain = "infrastructure" + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + store = queue.QueueStore(root) + store.enqueue(["exception"], "adaptive") + rollout_calls = 0 + + def rollout(task: dict[str, object], mode: str) -> SimpleNamespace: + nonlocal rollout_calls + rollout_calls += 1 + if rollout_calls == 1: + raise RetryableInfrastructureError("gateway wrapper") + return self._rollout_artifact( + root, + task, + mode, + {"ok": True, "rollout_outcome": "completed"}, + ) + + queue.run_queue_loop( + store, + lambda name: {"id": name}, + rollout, + lambda artifact: {"quality_score": 100.0, "success": True}, + rollout_concurrency=1, + reward_concurrency=1, + rollout_attempts=2, + stop_event=threading.Event(), + poll_interval_s=0.001, + stop_when_empty=True, + ) + case = store.cases()[0] + + self.assertEqual(case["status"], "done") + self.assertEqual(case["attempt"], 2) + self.assertEqual(rollout_calls, 2) + + def test_retryable_infrastructure_exception_from_persist_is_requeued(self) -> None: + class RetryableInfrastructureError(RuntimeError): + retryable = True + infrastructure = True + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + store = queue.QueueStore(root) + store.enqueue(["persist"], "adaptive") + persist_calls = 0 + rollout_calls = 0 + real_persist = queue.persist_artifact + + def rollout(task: dict[str, object], mode: str) -> SimpleNamespace: + nonlocal rollout_calls + rollout_calls += 1 + return self._rollout_artifact( + root, + task, + mode, + {"ok": True, "rollout_outcome": "completed"}, + ) + + def persist(artifact: object) -> None: + nonlocal persist_calls + persist_calls += 1 + if persist_calls == 1: + raise RetryableInfrastructureError("storage unavailable") + real_persist(artifact) + + with patch.object(queue, "persist_artifact", side_effect=persist): + queue.run_queue_loop( + store, + lambda name: {"id": name}, + rollout, + lambda artifact: {"quality_score": 100.0, "success": True}, + rollout_concurrency=1, + reward_concurrency=1, + rollout_attempts=2, + stop_event=threading.Event(), + poll_interval_s=0.001, + stop_when_empty=True, + ) + case = store.cases()[0] + + self.assertEqual(case["status"], "done") + self.assertEqual(case["attempt"], 2) + self.assertEqual(rollout_calls, 2) + self.assertEqual(persist_calls, 2) + + def test_programming_and_missing_file_exceptions_are_not_retryable(self) -> None: + self.assertFalse(queue.retryable_rollout_exception(TypeError("bad call"))) + self.assertFalse( + queue.retryable_rollout_exception( + FileNotFoundError("agent-result.json is missing") + ) + ) + try: + raise RuntimeError("wrapped") from TimeoutError("gateway timeout") + except RuntimeError as error: + self.assertTrue(queue.retryable_rollout_exception(error)) + + def test_harness_error_is_failed_without_entering_reward(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + store = queue.QueueStore(root) + store.enqueue(["harness-broken"], "forced-team") + reward_calls = 0 + + def rollout(task: dict[str, object], mode: str) -> SimpleNamespace: + return self._rollout_artifact( + root, + task, + mode, + { + "ok": False, + "rollout_outcome": "harness_error", + # Older agent results incorrectly set this on harness + # failures; the explicit outcome must still win. + "rollout_infrastructure": True, + "rollout_retryable": False, + "error": "model returned an empty response", + }, + ) + + def reward(artifact: object) -> dict[str, object]: + nonlocal reward_calls + reward_calls += 1 + return {"quality_score": 100.0} + + queue.run_queue_loop( + store, + lambda name: {"id": name}, + rollout, + reward, + rollout_concurrency=1, + reward_concurrency=1, + rollout_attempts=3, + stop_event=threading.Event(), + poll_interval_s=0.001, + stop_when_empty=True, + ) + case = store.cases()[0] + + self.assertEqual(case["status"], "failed") + self.assertEqual(case["attempt"], 1) + self.assertIn("empty response", case["error"]) + self.assertEqual(reward_calls, 0) + + def test_pending_or_skipped_reward_is_failed_and_keeps_rollout(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + store = queue.QueueStore(root) + store.enqueue(["unscored"], "adaptive-team-v2") + + def rollout(task: dict[str, object], mode: str) -> SimpleNamespace: + return self._rollout_artifact( + root, + task, + mode, + {"ok": True, "rollout_outcome": "completed"}, + ) + + def reward(artifact: object) -> dict[str, object]: + return { + "quality_score": 0.0, + "success": False, + "reward_outcome": "pending", + "reward_score_valid": False, + "retryable": True, + "hidden_tests": { + "skipped": True, + "skip_reason": "scorer unavailable", + }, + } + + queue.run_queue_loop( + store, + lambda name: {"id": name}, + rollout, + reward, + rollout_concurrency=1, + reward_concurrency=1, + stop_event=threading.Event(), + poll_interval_s=0.001, + stop_when_empty=True, + ) + case = store.cases()[0] + case_root = root / "unscored" / "adaptive-team-v2" + artifact_preserved = (case_root / "rollout-artifact.json").is_file() + was_not_archived = not (root / "_attempts" / "unscored").exists() + + self.assertEqual(case["status"], "failed") + self.assertIsNone(case["quality_score"]) + self.assertIn("reward_outcome=pending", case["error"]) + self.assertTrue(artifact_preserved) + self.assertTrue(was_not_archived) + + def test_live_loop_accepts_cases_added_after_workers_start(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + store = queue.QueueStore(root) + store.enqueue(["first", "second"], "adaptive") + stop = threading.Event() + release = threading.Event() + two_started = threading.Event() + third_started = threading.Event() + reward_started = threading.Event() + release_reward = threading.Event() + lock = threading.Lock() + active = 0 + max_active = 0 + started: list[str] = [] + reward_calls = 0 + events: list[dict[str, object]] = [] + + def load_task(name: str) -> dict[str, object]: + return {"id": name} + + def rollout(task: dict[str, object], mode: str) -> SimpleNamespace: + nonlocal active, max_active + task_name = str(task["id"]) + with lock: + active += 1 + max_active = max(max_active, active) + started.append(task_name) + if len(started) >= 2: + two_started.set() + if task_name == "third": + third_started.set() + self.assertTrue(release.wait(timeout=3)) + case_root = root / task_name / mode + workspace = case_root / "workspace" + workspace.mkdir(parents=True) + (workspace / "start.md").write_text("spec", encoding="utf-8") + (case_root / "agent-result.json").write_text( + json.dumps({"ok": True}), encoding="utf-8" + ) + with lock: + active -= 1 + return SimpleNamespace( + task=task, + mode=mode, + case_root=case_root, + workspace=workspace, + start_hash="hash", + agent={"ok": True}, + agent_elapsed_s=0.01, + agent_timed_out=False, + agent_returncode=0, + ) + + def reward(artifact: object) -> dict[str, object]: + nonlocal reward_calls + with lock: + reward_calls += 1 + call_number = reward_calls + if call_number == 1: + reward_started.set() + self.assertTrue(release_reward.wait(timeout=3)) + return { + "task": artifact.task["id"], + "quality_score": 100.0, + "success": True, + } + + thread = threading.Thread( + target=queue.run_queue_loop, + args=(store, load_task, rollout, reward), + kwargs={ + "rollout_concurrency": 2, + "reward_concurrency": 1, + "stop_event": stop, + "poll_interval_s": 0.01, + "on_event": events.append, + }, + ) + thread.start() + self.assertTrue(two_started.wait(timeout=2)) + added, _ = store.enqueue(["third"], "adaptive") + release.set() + self.assertTrue(reward_started.wait(timeout=2)) + self.assertTrue(third_started.wait(timeout=2)) + release_reward.set() + + deadline = time.monotonic() + 4 + while time.monotonic() < deadline and store.counts()["done"] < 3: + time.sleep(0.02) + stop.set() + thread.join(timeout=3) + + counts = store.counts() + + self.assertFalse(thread.is_alive()) + self.assertEqual(added, ["third"]) + self.assertEqual(counts["done"], 3) + self.assertEqual(max_active, 2) + self.assertIn("third", started) + third_started = next( + event for event in events + if event["event"] == "rollout.started" and event["task"] == "third" + ) + self.assertIsNotNone(third_started) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_nl2repo_latency_probe.py b/tests/test_nl2repo_latency_probe.py new file mode 100644 index 0000000..d75ab4e --- /dev/null +++ b/tests/test_nl2repo_latency_probe.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +import importlib.util +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + + +MODULE_PATH = ( + Path(__file__).resolve().parents[1] + / "teammate-evals" + / "nl2repo-pilot" + / "latency_probe.py" +) +SPEC = importlib.util.spec_from_file_location("nl2repo_latency_probe", MODULE_PATH) +assert SPEC is not None and SPEC.loader is not None +probe = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = probe +SPEC.loader.exec_module(probe) + + +class TestNL2RepoLatencyProbe(unittest.TestCase): + def test_live_probe_is_disabled_even_when_global_pool_is_idle(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + with self.assertRaisesRegex(SystemExit, "global-pool-only"): + probe.main(["--upstream-root", str(root)]) + + def test_dry_run_does_not_claim_model_capacity(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + with patch.object(probe, "find_upstream_root", return_value=root), patch.object( + probe, "load_tasks", return_value=[] + ), patch.object(probe, "reject_if_global_pool_active") as guard: + with self.assertRaisesRegex(ValueError, "cannot select"): + probe.main(["--dry-run", "--subset-size", "1"]) + guard.assert_not_called() + + def test_parse_concurrency_sweep(self) -> None: + self.assertEqual(probe.parse_concurrency_sweep("1,2,4,8"), [1, 2, 4, 8]) + with self.assertRaisesRegex(Exception, "positive"): + probe.parse_concurrency_sweep("1,0,4") + with self.assertRaisesRegex(Exception, "unique"): + probe.parse_concurrency_sweep("1,2,2") + + def test_task_selection_is_deterministic_and_respects_ceiling(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + tasks = [ + probe.TaskPrompt( + name=f"task-{index:02d}", + path=root / f"task-{index:02d}.md", + prompt_bytes=index * 100, + difficulty="Easy", + ) + for index in range(1, 41) + ] + selected_a, eligible_a = probe.select_tasks(tasks, 8, 1234, 2_000) + selected_b, eligible_b = probe.select_tasks(reversed(tasks), 8, 1234, 2_000) + + self.assertEqual(eligible_a, 20) + self.assertEqual(eligible_b, 20) + self.assertEqual( + [task.name for task in selected_a], + [task.name for task in selected_b], + ) + self.assertTrue(all(task.prompt_bytes <= 2_000 for task in selected_a)) + + def test_summary_reports_latency_tokens_and_errors(self) -> None: + results = [ + probe.RequestResult( + task="one", + prompt_bytes=100, + difficulty="Easy", + success=True, + ttft_seconds=1.0, + latency_seconds=2.0, + input_tokens=10, + output_tokens=2, + output_chars=5, + finish_reason="stop", + ), + probe.RequestResult( + task="two", + prompt_bytes=200, + difficulty="Medium", + success=True, + ttft_seconds=3.0, + latency_seconds=4.0, + input_tokens=20, + output_tokens=3, + output_chars=6, + finish_reason="stop", + ), + probe.RequestResult( + task="three", + prompt_bytes=300, + difficulty="Hard", + success=False, + ttft_seconds=None, + latency_seconds=0.5, + input_tokens=0, + output_tokens=0, + output_chars=0, + finish_reason=None, + error_type="RateLimitError", + error_status=429, + ), + ] + + summary = probe.summarize(results, duration_seconds=5.0) + + self.assertEqual(summary["successes"], 2) + self.assertEqual(summary["errors"], 1) + self.assertEqual(summary["error_breakdown"], {"RateLimitError:429": 1}) + self.assertEqual(summary["input_tokens"], 30) + self.assertEqual(summary["output_tokens"], 5) + self.assertAlmostEqual(summary["requests_per_second"], 0.4) + self.assertAlmostEqual(summary["ttft_seconds"]["p50"], 2.0) + self.assertAlmostEqual(summary["latency_seconds"]["p95"], 3.9) + + def test_comparison_includes_scaling_efficiency_and_paired_ratios(self) -> None: + baseline_results = [ + probe.RequestResult("one", 1, "", True, 1.0, 2.0, 1, 1, 1, "stop"), + probe.RequestResult("two", 1, "", True, 1.0, 4.0, 1, 1, 1, "stop"), + ] + concurrent_results = [ + probe.RequestResult("one", 1, "", True, 2.0, 4.0, 1, 1, 1, "stop"), + probe.RequestResult("two", 1, "", True, 2.0, 12.0, 1, 1, 1, "stop"), + ] + baseline = probe.summarize(baseline_results, 8.0) + concurrent = probe.summarize(concurrent_results, 2.0) + + comparison = probe.compare_runs( + baseline, + concurrent, + baseline_concurrency=1, + concurrency=4, + baseline_results=baseline_results, + concurrent_results=concurrent_results, + ) + + self.assertAlmostEqual(comparison["throughput_scale"], 4.0) + self.assertAlmostEqual(comparison["scaling_efficiency"], 1.0) + self.assertAlmostEqual(comparison["ttft_p50_ratio"], 2.0) + self.assertAlmostEqual(comparison["paired_latency_ratio"]["p50"], 2.5) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_nl2repo_pilot.py b/tests/test_nl2repo_pilot.py new file mode 100644 index 0000000..d467730 --- /dev/null +++ b/tests/test_nl2repo_pilot.py @@ -0,0 +1,1695 @@ +from __future__ import annotations + +import copy +import importlib.util +import fcntl +import json +import os +import subprocess +import sys +import tempfile +import threading +import unittest +from pathlib import Path +from unittest.mock import patch + +from src.execution.backend import CommandOutcome +from src.teammate.models import TeamTask +from src.tool_system.agent_loop import AgentLoopResult, ToolEvent +from src.tool_system.context import ToolContext +from src.tool_system.tools import TeamCreateTool, TeamPlanTool + + +MODULE_PATH = ( + Path(__file__).resolve().parents[1] + / "teammate-evals" + / "nl2repo-pilot" + / "benchmark.py" +) +SPEC = importlib.util.spec_from_file_location("nl2repo_pilot_benchmark", MODULE_PATH) +assert SPEC is not None and SPEC.loader is not None +benchmark = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = benchmark +SPEC.loader.exec_module(benchmark) + + +class TestNL2RepoPilot(unittest.TestCase): + def make_upstream(self, root: Path, task_name: str = "sample-task") -> Path: + task_dir = root / "test_files" / task_name + task_dir.mkdir(parents=True) + (root / "test_files" / "task_difficulty.csv").write_text( + f"task-name,Level\n{task_name},Medium\n", encoding="utf-8" + ) + (task_dir / "start.md").write_text("Build a sample package.\n", encoding="utf-8") + (task_dir / "test_case_count.txt").write_text("12\n", encoding="utf-8") + (task_dir / "test_commands.json").write_text( + json.dumps(["pip install -e .", "pytest tests"]), encoding="utf-8" + ) + (task_dir / "test_files.json").write_text(json.dumps(["tests"]), encoding="utf-8") + return root + + def test_legacy_pool_cli_is_disabled_before_upstream_resolution(self) -> None: + completed = subprocess.run( + [sys.executable, str(MODULE_PATH)], + cwd=MODULE_PATH.parents[2], + capture_output=True, + text=True, + ) + + self.assertNotEqual(completed.returncode, 0) + self.assertIn("direct benchmark.py rollout/reward pools are disabled", completed.stderr) + self.assertNotIn("NL2Repo checkout", completed.stderr) + + child = subprocess.run( + [sys.executable, str(MODULE_PATH), "_run-one"], + cwd=MODULE_PATH.parents[2], + capture_output=True, + text=True, + env={ + key: value + for key, value in os.environ.items() + if key != benchmark.GLOBAL_POOL_WORKER_ENV + }, + ) + self.assertNotEqual(child.returncode, 0) + self.assertIn("direct 'benchmark.py _run-one' is disabled", child.stderr) + + def test_private_child_requires_supervisor_marker_and_live_lock(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + lock_path = Path(tmp) / "global-pool.lock" + lock_path.write_text(json.dumps({"pid": 31337}), encoding="utf-8") + with lock_path.open("r+", encoding="utf-8") as owner: + fcntl.flock(owner.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + with self.assertRaisesRegex(SystemExit, "must be launched"): + benchmark.enforce_child_launch_policy( + ["_run-one"], environ={}, lock_path=lock_path + ) + with patch.object( + benchmark, "_process_parent_pid", return_value=31337 + ): + benchmark.enforce_child_launch_policy( + ["_run-one"], + environ={ + benchmark.GLOBAL_POOL_WORKER_ENV: + benchmark.GLOBAL_POOL_WORKER_MARKER + }, + lock_path=lock_path, + parent_pid=4242, + ) + with patch.object( + benchmark, "_process_parent_pid", return_value=99999 + ): + with self.assertRaisesRegex(SystemExit, "must be launched"): + benchmark.enforce_child_launch_policy( + ["_run-one"], + environ={ + benchmark.GLOBAL_POOL_WORKER_ENV: + benchmark.GLOBAL_POOL_WORKER_MARKER + }, + lock_path=lock_path, + parent_pid=4242, + ) + fcntl.flock(owner.fileno(), fcntl.LOCK_UN) + + with self.assertRaisesRegex(SystemExit, "must be launched"): + benchmark.enforce_child_launch_policy( + ["_run-one"], + environ={ + benchmark.GLOBAL_POOL_WORKER_ENV: + benchmark.GLOBAL_POOL_WORKER_MARKER + }, + lock_path=lock_path, + ) + + def test_agent_child_watchdog_signals_when_queue_parent_disappears(self) -> None: + stop_event = threading.Event() + orphaned = threading.Event() + thread = benchmark.start_parent_watchdog( + 4242, + stop_event, + interval_s=0.001, + on_orphan=orphaned.set, + parent_pid_loader=lambda: 9999, + ) + self.assertTrue(orphaned.wait(1)) + stop_event.set() + thread.join(timeout=1) + + def test_private_child_parent_must_be_a_registered_worker(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + lock_path = Path(tmp) / "global-pool.lock" + lock_path.write_text( + json.dumps( + { + "schema_version": 2, + "pid": 100, + "runs": ["/tmp/run"], + "worker_pids": [4242], + } + ), + encoding="utf-8", + ) + with lock_path.open("r+", encoding="utf-8") as owner: + fcntl.flock(owner.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + environment = { + benchmark.GLOBAL_POOL_WORKER_ENV: + benchmark.GLOBAL_POOL_WORKER_MARKER + } + benchmark.enforce_child_launch_policy( + ["_run-one"], + environ=environment, + lock_path=lock_path, + parent_pid=4242, + ) + with self.assertRaisesRegex(SystemExit, "must be launched"): + benchmark.enforce_child_launch_policy( + ["_run-one"], + environ=environment, + lock_path=lock_path, + parent_pid=9999, + ) + + def test_metadata_only_cli_actions_do_not_use_legacy_pool(self) -> None: + for action in ("list", "plan", "validate"): + args = type( + "Args", + (), + { + "list": action == "list", + "plan": action == "plan", + "validate": action == "validate", + "rescore": False, + }, + )() + benchmark.enforce_top_level_pool_policy(args) + + rescore = type( + "Args", + (), + {"list": False, "plan": False, "validate": False, "rescore": True}, + )() + with self.assertRaisesRegex(SystemExit, "global_pool_supervisor.py"): + benchmark.enforce_top_level_pool_policy(rescore) + + def test_load_task_reads_external_metadata(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + upstream = self.make_upstream(Path(tmp)) + task = benchmark.load_task(upstream, "sample-task") + + self.assertEqual(task["difficulty"], "Medium") + self.assertEqual(task["expected_tests"], 12) + self.assertEqual(task["hidden_paths"], ["tests"]) + self.assertEqual( + task["image"], + "ghcr.io/multimodal-art-projection/nl2repobench/sample-task:1.0", + ) + + def test_image_references_normalize_mixed_case_task_names(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + upstream = self.make_upstream(Path(tmp), "more-Itertools") + task = benchmark.load_task(upstream, "more-Itertools") + + self.assertEqual( + task["image"], + "ghcr.io/multimodal-art-projection/nl2repobench/more-itertools:1.0", + ) + self.assertEqual( + benchmark.format_ags_image( + benchmark.AGS_IMAGE_TEMPLATE, "more-Itertools" + ), + "swebenchdocker.tencentcloudcr.com/swebench/nl2repo:more-itertools-1.0", + ) + + def test_forced_team_prompt_uses_the_harness_precreated_team(self) -> None: + prompt = benchmark.build_prompt("forced-team") + + self.assertIn("harness has already created the active strict", prompt) + self.assertIn("protocol-v2 Team", prompt) + self.assertIn("Do not call TeamCreate", prompt) + self.assertIn("one atomic TeamPlan", prompt) + self.assertIn("TeamRun", prompt) + self.assertIn("exactly two real implementation workers", prompt) + self.assertIn("acceptance_checks", prompt) + self.assertIn("verification automatically", prompt) + self.assertIn("TeamAbort", prompt) + + def test_failure_classifier_distinguishes_dependency_and_team_contract_failures(self) -> None: + base = { + "agent_ok": True, + "integrity_ok": True, + "protocol_ok": True, + "hidden": { + "pytest": { + "returncode": 1, + "errors": 1, + "failed": 0, + "all_passed": False, + } + }, + } + dependency = benchmark.classify_failure( + **base, + team={"agents": [{}], "peer_messages": 0}, + hidden_log="ModuleNotFoundError: No module named 'ujson'", + ) + contract = benchmark.classify_failure( + **{ + **base, + "hidden": { + "pytest": { + "returncode": 1, + "errors": 0, + "failed": 3, + "all_passed": False, + } + }, + }, + team={"agents": [{}, {}], "peer_messages": 0}, + hidden_log="AttributeError: public key has no attribute 'BASE'", + ) + + self.assertEqual(dependency, "dependency_environment") + self.assertEqual(contract, "cross_module_contract") + + def test_rollout32_selection_matches_fixed_bounded_subset(self) -> None: + metadata = [ + {"id": f"task-{index:02d}", "prompt_bytes": index * 1_000} + for index in range(1, 71) + ] + + first = benchmark.select_task_subset(metadata, count=32, seed=20260715) + second = benchmark.select_task_subset( + list(reversed(metadata)), count=32, seed=20260715 + ) + + self.assertEqual(first, second) + self.assertEqual(len(first), 32) + self.assertTrue(all(int(name.removeprefix("task-")) <= 65 for name in first)) + + def test_reward_pool_does_not_occupy_rollout_slots(self) -> None: + tasks = [ + {"id": f"task-{index}", "difficulty": "Easy", "expected_tests": 1} + for index in range(3) + ] + lock = threading.Lock() + reward_started = threading.Event() + third_rollout_started = threading.Event() + release_reward = threading.Event() + active_rollouts = 0 + max_active_rollouts = 0 + events: list[dict[str, object]] = [] + + def rollout(task: dict[str, object], mode: str) -> object: + nonlocal active_rollouts, max_active_rollouts + with lock: + active_rollouts += 1 + max_active_rollouts = max(max_active_rollouts, active_rollouts) + if task["id"] == "task-1": + self.assertTrue(third_rollout_started.wait(timeout=2)) + elif task["id"] == "task-2": + self.assertTrue(reward_started.wait(timeout=2)) + third_rollout_started.set() + release_reward.set() + with lock: + active_rollouts -= 1 + return task + + def reward(artifact: dict[str, object]) -> dict[str, object]: + if artifact["id"] == "task-0": + reward_started.set() + self.assertTrue(release_reward.wait(timeout=2)) + return {"task": artifact["id"], "quality_score": 100.0, "success": True} + + results = benchmark.run_evaluation_pool( + [(task, "adaptive") for task in tasks], + rollout, + reward, + rollout_concurrency=2, + reward_concurrency=1, + on_event=events.append, + ) + + self.assertEqual([result["task"] for result in results], ["task-0", "task-1", "task-2"]) + self.assertEqual(max_active_rollouts, 2) + started_third = next( + index + for index, event in enumerate(events) + if event["event"] == "rollout.started" and event["task"] == "task-2" + ) + completed_reward = next( + index + for index, event in enumerate(events) + if event["event"] == "reward.completed" and event["task"] == "task-0" + ) + self.assertLess(started_third, completed_reward) + + def test_ags_image_preparation_is_retried(self) -> None: + attempts = 0 + sleeps: list[float] = [] + + class FakeBackend: + def start(self) -> "FakeBackend": + nonlocal attempts + attempts += 1 + if attempts < 3: + raise RuntimeError( + "[TencentCloudSDKException] code:ResourceUnavailable " + "message:image is still preparing, please retry later" + ) + return self + + backend = benchmark.start_ags_backend_with_retry( + FakeBackend, + attempts=4, + delay_s=0.25, + sleep_fn=sleeps.append, + ) + + self.assertIsInstance(backend, FakeBackend) + self.assertEqual(attempts, 3) + self.assertEqual(sleeps, [0.25, 0.25]) + + def test_ags_non_preparation_error_is_not_retried(self) -> None: + attempts = 0 + + class BrokenBackend: + def start(self) -> "BrokenBackend": + nonlocal attempts + attempts += 1 + raise RuntimeError("permission denied") + + with self.assertRaisesRegex(RuntimeError, "permission denied"): + benchmark.start_ags_backend_with_retry( + BrokenBackend, + attempts=4, + delay_s=0, + ) + + self.assertEqual(attempts, 1) + + def test_prompt_leaves_topology_to_the_lead(self) -> None: + adaptive = benchmark.build_prompt("adaptive") + adaptive_v2 = benchmark.build_prompt("adaptive-team-v2") + forced = benchmark.build_prompt( + "forced-team", + teammate_max_turns=40, + max_output_tokens=4_096, + team_timeout_s=1_800, + ) + + self.assertIn("valid to remain solo", adaptive) + self.assertIn("at least two substantially independent", adaptive_v2) + self.assertIn("exactly two real", adaptive_v2) + self.assertIn("TeamPlan -> TeamRun", adaptive_v2) + self.assertIn("valid to complete", adaptive_v2) + self.assertIn("exactly two real implementation workers", forced) + self.assertIn("timeout_s=1800", forced) + self.assertIn("token_budget=1310720", forced) + self.assertIn("turn_budget=80", forced) + self.assertIn("rollout-wide caps", forced) + self.assertIn("must be your runtime decision", adaptive) + self.assertNotIn("planner", adaptive.lower()) + + def test_adaptive_team_v2_allows_a_valid_solo_route(self) -> None: + self.assertTrue( + benchmark._protocol_ok( + "adaptive-team-v2", + {"present": False}, + ) + ) + + def test_adaptive_team_v2_rejects_a_legacy_incremental_team(self) -> None: + self.assertFalse( + benchmark._protocol_ok( + "adaptive-team-v2", + { + "present": True, + "protocol_version": 1, + "status": "completed", + "agents": [{}, {}], + "tasks": 2, + "completed_tasks": 2, + "quality_gates": { + "strict": True, + "configured": True, + "plan_accepted": True, + "validation_status": "passed", + }, + }, + ) + ) + + def test_protocol_v2_requires_every_task_to_be_harness_accepted(self) -> None: + team = { + "present": True, + "protocol_version": 2, + "status": "completed", + "lifecycle_state": "completed", + "agents": [{}, {}], + "tasks": 3, + "completed_tasks": 3, + "accepted_tasks": 2, + "attempted_tasks": 3, + "produced_tasks": 3, + "plan_revision": 1, + "plan_hash": "sha256", + "plan_hash_valid": True, + "manifest_valid": True, + "quality_gates": { + "strict": True, + "configured": True, + "plan_accepted": True, + "validation_status": "passed", + }, + } + + self.assertFalse(benchmark._protocol_ok("forced-team", team)) + team["accepted_tasks"] = 3 + self.assertTrue(benchmark._protocol_ok("forced-team", team)) + + def test_team_metrics_counts_only_accepted_completed_tasks(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + workspace = Path(tmp) + team_id = "team-v2" + team = { + "team_id": team_id, + "lead_agent_id": "lead", + "status": "completed", + "protocol_version": 2, + "lifecycle_state": "completed", + "settings": { + "quality_gates": { + "strict": True, + "validation": {"status": "passed"}, + } + }, + } + team_dir = workspace / ".clawd" / "teams" / team_id + (team_dir / "agents").mkdir(parents=True) + (team_dir / "messages").mkdir() + (workspace / ".clawd").mkdir(exist_ok=True) + (workspace / ".clawd" / "team.json").write_text( + json.dumps(team), encoding="utf-8" + ) + (team_dir / "team.json").write_text(json.dumps(team), encoding="utf-8") + (team_dir / "tasks.json").write_text( + json.dumps( + { + "accepted": { + "status": "completed", + "lifecycle_state": "accepted", + }, + "produced": { + "status": "completed", + "lifecycle_state": "produced", + }, + } + ), + encoding="utf-8", + ) + + metrics = benchmark._team_metrics(workspace) + + self.assertEqual(metrics["tasks"], 2) + self.assertEqual(metrics["completed_tasks"], 2) + self.assertEqual(metrics["accepted_tasks"], 1) + + def test_missing_agent_result_is_rejected_before_reward(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + result_path = Path(tmp) / "agent-result.json" + with self.assertRaisesRegex( + RuntimeError, + "exited with code 1 without producing agent-result.json", + ): + benchmark._require_agent_result( + result_path, + returncode=1, + timed_out=False, + stderr="ImportError: circular import", + ) + + def test_explicit_agent_failure_result_remains_scoreable(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + result_path = Path(tmp) / "agent-result.json" + result_path.write_text( + json.dumps({"ok": False, "error": "model failed after editing"}), + encoding="utf-8", + ) + result = benchmark._require_agent_result( + result_path, + returncode=1, + timed_out=False, + stderr="", + ) + + self.assertFalse(result["ok"]) + self.assertEqual(result["error"], "model failed after editing") + + def test_prepare_workspace_creates_only_spec_and_git_metadata(self) -> None: + task = {"document": "Build it.\n"} + with tempfile.TemporaryDirectory() as tmp: + workspace = Path(tmp) / "workspace" + digest = benchmark.prepare_workspace(task, workspace) + + self.assertTrue((workspace / ".git").is_dir()) + self.assertEqual((workspace / "start.md").read_text(encoding="utf-8"), "Build it.\n") + self.assertEqual(digest, benchmark._hash_file(workspace / "start.md")) + self.assertEqual( + sorted(path.name for path in workspace.iterdir()), + [".git", "start.md"], + ) + + def test_stage_score_context_removes_agent_tests_and_packaging(self) -> None: + task = { + "image": "example.invalid/sample:1.0", + "hidden_paths": ["tests"], + "test_commands": ["pip install -e .", "pytest tests"], + } + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + workspace = root / "workspace" + workspace.mkdir() + (workspace / "pyproject.toml").write_text("[project]\n", encoding="utf-8") + (workspace / "package.py").write_text("VALUE = 1\n", encoding="utf-8") + (workspace / "tests").mkdir() + (workspace / "tests" / "test_fake.py").write_text("pass\n", encoding="utf-8") + + metadata = benchmark.stage_score_context(task, workspace, root / "score") + staged = root / "score" / "workspace" + + self.assertEqual(metadata["package_files_present"], ["pyproject.toml"]) + self.assertEqual(metadata["generated_hidden_paths"], ["tests"]) + self.assertFalse((staged / "pyproject.toml").exists()) + self.assertFalse((staged / "tests").exists()) + self.assertTrue((staged / "package.py").exists()) + dockerfile = (root / "score" / "Dockerfile").read_text(encoding="utf-8") + self.assertNotIn("pip install -e .", dockerfile) + self.assertEqual(metadata["setup_commands"], ["pip install -e ."]) + self.assertEqual(metadata["test_commands"], ["pytest tests"]) + stats = metadata["score_context_stats"] + self.assertEqual(stats["source"]["file_count"], 3) + self.assertEqual(stats["copied"]["file_count"], 3) + self.assertEqual(stats["staged"]["file_count"], 1) + self.assertEqual(stats["staged"]["directory_count"], 1) + self.assertGreater(stats["limits"]["max_total_bytes"], 0) + + def test_stage_score_context_rejects_symlink_before_touching_destination(self) -> None: + task = { + "image": "example.invalid/sample:1.0", + "hidden_paths": [], + "test_commands": ["pytest"], + } + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + workspace = root / "workspace" + workspace.mkdir() + outside = root / "outside-secret.txt" + outside.write_text("secret\n", encoding="utf-8") + (workspace / "leak.txt").symlink_to(outside) + destination = root / "score" + destination.mkdir() + sentinel = destination / "keep.txt" + sentinel.write_text("untouched\n", encoding="utf-8") + + with self.assertRaisesRegex(ValueError, "symbolic link"): + benchmark.stage_score_context(task, workspace, destination) + + self.assertEqual(sentinel.read_text(encoding="utf-8"), "untouched\n") + + def test_stage_score_context_ignores_cache_directories(self) -> None: + task = { + "image": "example.invalid/sample:1.0", + "hidden_paths": [], + "test_commands": ["pytest"], + } + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + workspace = root / "workspace" + workspace.mkdir() + (workspace / "package.py").write_text("VALUE = 1\n", encoding="utf-8") + outside = root / "outside" + outside.mkdir() + (workspace / ".git").symlink_to(outside, target_is_directory=True) + + metadata = benchmark.stage_score_context(task, workspace, root / "score") + + self.assertEqual(metadata["score_context_stats"]["source"]["file_count"], 1) + self.assertFalse((root / "score" / "workspace" / ".git").exists()) + + @unittest.skipUnless(hasattr(os, "mkfifo"), "FIFO unsupported") + def test_stage_score_context_rejects_non_regular_file(self) -> None: + task = { + "image": "example.invalid/sample:1.0", + "hidden_paths": [], + "test_commands": ["pytest"], + } + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + workspace = root / "workspace" + workspace.mkdir() + os.mkfifo(workspace / "unsafe.pipe") + + with self.assertRaisesRegex(ValueError, "non-regular file"): + benchmark.stage_score_context(task, workspace, root / "score") + + def test_stage_score_context_enforces_size_limits(self) -> None: + task = { + "image": "example.invalid/sample:1.0", + "hidden_paths": [], + "test_commands": ["pytest"], + } + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + workspace = root / "workspace" + workspace.mkdir() + (workspace / "one.txt").write_bytes(b"abc") + (workspace / "two.txt").write_bytes(b"def") + + with patch.object(benchmark, "SCORE_CONTEXT_MAX_FILES", 1): + with self.assertRaisesRegex(ValueError, "file-count limit"): + benchmark.stage_score_context(task, workspace, root / "score-files") + with patch.object(benchmark, "SCORE_CONTEXT_MAX_FILE_BYTES", 2): + with self.assertRaisesRegex(ValueError, "file exceeds size limit"): + benchmark.stage_score_context(task, workspace, root / "score-file-size") + with patch.object(benchmark, "SCORE_CONTEXT_MAX_TOTAL_BYTES", 5): + with self.assertRaisesRegex(ValueError, "total-size limit"): + benchmark.stage_score_context(task, workspace, root / "score-total") + + def test_score_commands_continue_to_pytest_when_setup_fails(self) -> None: + command = benchmark._score_shell_command( + ["pip install -e ."], + ["pytest tests"], + ) + + self.assertEqual(command, "(pip install -e .); (pytest tests)") + self.assertNotIn("&&", command) + + def test_protocol_failure_still_scores_a_complete_workspace(self) -> None: + hidden = { + "pytest": { + "expected": 3, + "passed": 2, + "failed": 1, + "errors": 0, + "skipped": 0, + "returncode": 1, + "quality_score": 66.67, + "all_passed": False, + } + } + with tempfile.TemporaryDirectory() as tmp: + case_root = Path(tmp) / "sample" / "forced-team" + workspace = case_root / "workspace" + workspace.mkdir(parents=True) + start = workspace / "start.md" + start.write_text("spec\n", encoding="utf-8") + rollout = benchmark.RolloutArtifact( + task={"id": "sample", "difficulty": "Easy", "expected_tests": 3}, + mode="forced-team", + case_root=case_root, + workspace=workspace, + start_hash=benchmark._hash_file(start), + agent={"ok": True, "lead_usage": {}, "lead_turns": 1}, + agent_elapsed_s=1.0, + agent_timed_out=False, + agent_returncode=0, + ) + with patch.object(benchmark, "run_hidden_tests", return_value=hidden) as scorer: + result = benchmark.score_rollout( + rollout, + provider="qwen", + model="test", + score_timeout_s=60, + keep_image=False, + ) + + scorer.assert_called_once() + self.assertFalse(result["reward_skipped"]) + self.assertEqual(result["failure_class"], "team_protocol") + self.assertFalse(result["protocol_ok"]) + self.assertEqual(result["result_schema_version"], 2) + self.assertEqual(result["code_quality_score"], 66.67) + self.assertEqual(result["quality_score"], 66.67) + self.assertEqual(result["protocol_status"], "failed") + self.assertEqual(result["protocol_credit"], 0.0) + self.assertTrue(result["delivery_valid"]) + self.assertEqual(result["effective_quality_score"], 0.0) + self.assertEqual(result["reward_outcome"], "scored") + self.assertTrue(result["reward_score_valid"]) + self.assertEqual(result["failure_domain"], "protocol") + self.assertFalse(result["is_infrastructure"]) + self.assertFalse(result["retryable"]) + self.assertIsNone(result["timeout_scope"]) + + def test_protocol_v2_requires_exact_manifest_and_real_task_evidence(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + workspace = Path(tmp) + context = ToolContext(workspace_root=workspace) + TeamCreateTool().run( + {"team_name": "metric-v2", "quality_gates": True}, context + ) + planned = TeamPlanTool().run( + { + "contract": {"summary": "two independent modules", "interfaces": []}, + "workers": [ + {"name": "worker-a", "instructions": "Implement module A."}, + {"name": "worker-b", "instructions": "Implement module B."}, + ], + "tasks": [ + { + "key": "module-a", + "owner": "worker-a", + "instructions": "Implement a.py.", + "owned_files": ["a.py"], + "acceptance_checks": ["python -m py_compile a.py"], + }, + { + "key": "module-b", + "owner": "worker-b", + "instructions": "Implement b.py.", + "owned_files": ["b.py"], + "acceptance_checks": ["python -m py_compile b.py"], + }, + ], + "validation": { + "profile": "generic", + "integration_command": "python -m compileall -q .", + }, + "execution": {"timeout_s": 300}, + }, + context, + ) + self.assertFalse(planned.is_error, planned.output) + team = context.team_store.load_active_team() + assert team is not None + for raw in context.team_store.load_tasks(team.team_id).values(): + task = TeamTask.from_dict(raw) + task.transition_to("in_progress") + task.attempt = 1 + task.transition_to("completed") + task.set_lifecycle_state("accepted") + context.team_store.update_task(team.team_id, task) + context.team_store.append_event( + team.team_id, "task.produced", {"task_id": task.id} + ) + quality = dict(team.settings["quality_gates"]) + quality["plan_accepted"] = True + quality["validation"] = {"status": "passed"} + team.settings["quality_gates"] = quality + # Protocol v2 keeps frozen and effective execution values in one + # immutable manifest. A runtime-enforced minimum is valid only when + # the same manifest records its exact requested/effective adjustment. + manifest = dict(team.settings["execution_manifest"]) + manifest["status"] = "accepted" + manifest["effective_execution"] = dict(manifest["execution"]) + manifest["effective_execution"]["timeout_s"] = 900.0 + manifest["runtime_adjustments"] = { + "timeout_s": { + "requested": 300, + "effective": 900.0, + "reason": "runtime minimum", + } + } + team.settings["execution_manifest"] = manifest + team.transition_to("running") + team.transition_to("completed") + context.team_store.save_team(team) + context.team_store.append_event( + team.team_id, + "team.options_adjusted", + { + "timeout_s": { + "requested": 300, + "effective": 900.0, + "reason": "runtime minimum", + } + }, + ) + + metrics = benchmark._team_metrics(workspace) + + self.assertTrue(metrics["plan_hash_valid"]) + self.assertTrue(metrics["execution_manifest_valid"]) + self.assertTrue(metrics["manifest_valid"]) + self.assertEqual(metrics["execution_manifest_mismatches"], []) + self.assertEqual(metrics["manifest_errors"], []) + self.assertEqual(metrics["attempted_tasks"], metrics["tasks"]) + self.assertEqual(metrics["produced_tasks"], metrics["tasks"]) + self.assertTrue(benchmark._protocol_ok("forced-team", metrics)) + + missing_evidence = dict(metrics) + missing_evidence["produced_tasks"] -= 1 + self.assertFalse(benchmark._protocol_ok("forced-team", missing_evidence)) + + manifest = dict(team.settings["execution_manifest"]) + manifest["effective_execution"] = dict( + manifest["effective_execution"] + ) + manifest["effective_execution"]["timeout_s"] = 901 + team.settings["execution_manifest"] = manifest + context.team_store.save_team(team) + undocumented_execution = benchmark._team_metrics(workspace) + self.assertFalse(undocumented_execution["execution_manifest_valid"]) + self.assertFalse(undocumented_execution["manifest_valid"]) + self.assertIn( + "execution.timeout_s:effective_value_mismatch", + undocumented_execution["manifest_errors"], + ) + self.assertEqual( + undocumented_execution["execution_manifest_mismatches"][0]["field"], + "timeout_s", + ) + manifest["effective_execution"]["timeout_s"] = 900.0 + team.settings["execution_manifest"] = manifest + context.team_store.save_team(team) + + tampered_budget_manifest = copy.deepcopy(manifest) + tampered_budget_manifest["budget_window"]["hard_ceiling"]["turns"] = 1 + team.settings["execution_manifest"] = tampered_budget_manifest + context.team_store.save_team(team) + tampered_budget = benchmark._team_metrics(workspace) + self.assertFalse(tampered_budget["execution_manifest_valid"]) + self.assertIn( + "execution.budget_window.hard_ceiling.turns:derived_value_mismatch", + tampered_budget["manifest_errors"], + ) + team.settings["execution_manifest"] = manifest + context.team_store.save_team(team) + + tasks = context.team_store.load_tasks(team.team_id) + first = next(iter(tasks.values())) + first["acceptance_checks"] = ["python -m compileall -q ."] + context.team_store.save_tasks(team.team_id, tasks) + tampered = benchmark._team_metrics(workspace) + self.assertFalse(tampered["manifest_valid"]) + self.assertTrue( + any( + reason.startswith("task_spec_mismatch:") + for reason in tampered["manifest_errors"] + ) + ) + self.assertFalse(benchmark._protocol_ok("forced-team", tampered)) + + def test_invalid_delivery_skips_reward_and_is_not_code_quality_eligible(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + case_root = Path(tmp) / "sample" / "adaptive" + workspace = case_root / "workspace" + workspace.mkdir(parents=True) + start = workspace / "start.md" + start.write_text("mutated spec\n", encoding="utf-8") + rollout = benchmark.RolloutArtifact( + task={"id": "sample", "difficulty": "Easy", "expected_tests": 1}, + mode="adaptive", + case_root=case_root, + workspace=workspace, + start_hash="different", + agent={"ok": True, "lead_usage": {}, "lead_turns": 1}, + agent_elapsed_s=1.0, + agent_timed_out=False, + agent_returncode=0, + ) + with patch.object(benchmark, "run_hidden_tests") as scorer: + result = benchmark.score_rollout( + rollout, + provider="qwen", + model="test", + score_timeout_s=60, + keep_image=False, + ) + + scorer.assert_not_called() + self.assertEqual(result["reward_outcome"], "missing_artifact") + self.assertFalse(result["reward_score_valid"]) + self.assertIsNone(result["code_quality_score"]) + self.assertFalse(result["metric_eligibility"]["code_quality"]) + + def test_agent_failure_keeps_valid_code_score_but_zeroes_effective_quality(self) -> None: + hidden = { + "pytest": { + "quality_score": 75.0, + "returncode": 1, + "all_passed": False, + } + } + + metrics = benchmark._result_metrics_v2( + agent_ok=False, + agent_timed_out=False, + integrity_ok=True, + protocol_ok=True, + hidden=hidden, + failure_class="rollout_failure", + ) + + self.assertFalse(metrics["delivery_valid"]) + self.assertTrue(metrics["reward_score_valid"]) + self.assertEqual(metrics["code_quality_score"], 75.0) + self.assertEqual(metrics["effective_quality_score"], 0.0) + self.assertEqual(metrics["failure_domain"], "candidate") + + def test_rollout_infrastructure_is_excluded_from_all_quality_metrics(self) -> None: + metrics = benchmark._result_metrics_v2( + agent_ok=False, + agent_timed_out=False, + integrity_ok=True, + protocol_ok=False, + hidden={ + "skipped": True, + "pytest": {"quality_score": 0.0, "returncode": 1}, + }, + failure_class="rollout_infrastructure", + rollout_infrastructure=True, + rollout_retryable=True, + rollout_outcome="infra_error", + ) + + self.assertEqual(metrics["rollout_outcome"], "infra_error") + self.assertEqual(metrics["protocol_status"], "not_evaluated") + self.assertIsNone(metrics["protocol_credit"]) + self.assertIsNone(metrics["code_quality_score"]) + self.assertIsNone(metrics["effective_quality_score"]) + self.assertFalse(any(metrics["metric_eligibility"].values())) + self.assertTrue(metrics["is_infrastructure"]) + self.assertTrue(metrics["retryable"]) + + def test_rollout_infrastructure_skips_reward_for_stale_workspace(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + case_root = Path(tmp) / "sample" / "adaptive" + workspace = case_root / "workspace" + workspace.mkdir(parents=True) + start = workspace / "start.md" + start.write_text("spec\n", encoding="utf-8") + rollout = benchmark.RolloutArtifact( + task={"id": "sample", "difficulty": "Easy", "expected_tests": 1}, + mode="adaptive", + case_root=case_root, + workspace=workspace, + start_hash=benchmark._hash_file(start), + agent={ + "ok": False, + "error": "AGS upload failed", + "rollout_outcome": "infra_error", + "rollout_infrastructure": True, + "rollout_retryable": True, + "lead_usage": {}, + }, + agent_elapsed_s=1.0, + agent_timed_out=False, + agent_returncode=1, + ) + with patch.object(benchmark, "run_hidden_tests") as scorer: + result = benchmark.score_rollout( + rollout, + provider="qwen", + model="test", + score_timeout_s=60, + keep_image=False, + ) + + scorer.assert_not_called() + self.assertEqual(result["failure_domain"], "infrastructure") + self.assertEqual(result["reward_outcome"], "pending") + self.assertFalse(result["metric_eligibility"]["protocol_yield"]) + + def test_hidden_test_timeout_is_candidate_not_infrastructure(self) -> None: + metrics = benchmark._result_metrics_v2( + agent_ok=True, + agent_timed_out=False, + integrity_ok=True, + protocol_ok=True, + hidden={ + "timed_out": True, + "pytest": {"quality_score": 0.0, "returncode": 124, "all_passed": False}, + }, + failure_class="reward_timeout", + ) + + self.assertEqual(metrics["reward_outcome"], "candidate_timeout") + self.assertEqual(metrics["timeout_scope"], "reward") + self.assertFalse(metrics["is_infrastructure"]) + self.assertFalse(metrics["retryable"]) + self.assertEqual(metrics["failure_domain"], "candidate") + + def test_reward_infrastructure_timeout_is_retryable_and_not_scored(self) -> None: + metrics = benchmark._result_metrics_v2( + agent_ok=True, + agent_timed_out=False, + integrity_ok=True, + protocol_ok=True, + hidden={ + "error": "sandbox provisioning timed out", + "infrastructure_timed_out": True, + "pytest": {"quality_score": 0.0, "returncode": 124, "all_passed": False}, + }, + failure_class="scorer_infrastructure", + ) + + self.assertEqual(metrics["reward_outcome"], "infra_timeout") + self.assertFalse(metrics["reward_score_valid"]) + self.assertIsNone(metrics["code_quality_score"]) + self.assertIsNone(metrics["effective_quality_score"]) + self.assertFalse(any(metrics["metric_eligibility"].values())) + self.assertTrue(metrics["is_infrastructure"]) + self.assertTrue(metrics["retryable"]) + self.assertEqual(metrics["timeout_scope"], "reward") + + def test_reward_infrastructure_excludes_failed_protocol_from_qpe(self) -> None: + metrics = benchmark._result_metrics_v2( + agent_ok=True, + agent_timed_out=False, + integrity_ok=True, + protocol_ok=False, + hidden={ + "error": "scorer sandbox unavailable", + "pytest": { + "quality_score": 0.0, + "returncode": 1, + "all_passed": False, + }, + }, + failure_class="scorer_infrastructure", + ) + + # The final Team state remains useful diagnosis, but this scorer attempt + # is not an observation of any Q/P/E metric. + self.assertEqual(metrics["protocol_status"], "failed") + self.assertEqual(metrics["protocol_credit"], 0.0) + self.assertEqual(metrics["reward_outcome"], "infra_error") + self.assertIsNone(metrics["code_quality_score"]) + self.assertIsNone(metrics["effective_quality_score"]) + self.assertFalse(any(metrics["metric_eligibility"].values())) + self.assertEqual(metrics["failure_domain"], "infrastructure") + self.assertTrue(metrics["retryable"]) + + def test_scheduler_failure_domain_depends_on_phase(self) -> None: + task = {"id": "sample", "difficulty": "Easy", "expected_tests": 1} + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + rollout = benchmark._failed_case_result( + task, + "adaptive", + "rollout", + RuntimeError("agent child failed"), + output_root=root, + provider="qwen", + model="test", + execution_backend="ags", + score_backend="ags", + ) + reward = benchmark._failed_case_result( + task, + "forced-team", + "reward", + TimeoutError("sandbox unavailable"), + output_root=root, + provider="qwen", + model="test", + execution_backend="ags", + score_backend="ags", + ) + + self.assertEqual(rollout["failure_domain"], "candidate") + self.assertFalse(rollout["is_infrastructure"]) + self.assertEqual(rollout["reward_outcome"], "missing_artifact") + self.assertNotIn("error", rollout["hidden_tests"]) + self.assertTrue(rollout["hidden_tests"]["skipped"]) + self.assertEqual(reward["failure_domain"], "infrastructure") + self.assertTrue(reward["is_infrastructure"]) + self.assertEqual(reward["reward_outcome"], "infra_timeout") + self.assertEqual(reward["timeout_scope"], "reward") + self.assertIn("error", reward["hidden_tests"]) + + def test_rescore_reuses_workspace_and_preserves_reward_history(self) -> None: + task = {"id": "sample-task", "expected_tests": 2} + hidden = { + "pytest": { + "expected": 2, + "passed": 2, + "failed": 0, + "errors": 0, + "skipped": 0, + "returncode": 0, + "quality_score": 100.0, + "all_passed": True, + } + } + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + case_root = root / "sample-task" / "adaptive" + workspace = case_root / "workspace" + workspace.mkdir(parents=True) + (workspace / "sample.py").write_text("VALUE = 1\n", encoding="utf-8") + (case_root / "result.json").write_text( + json.dumps( + { + "agent_ok": True, + "integrity_ok": True, + # Protocol accounting is deliberately stale; a rescore must + # derive it again from the final persisted team manifest. + "protocol_ok": False, + "protocol_status": "failed", + "quality_score": 0.0, + "success": False, + "hidden_tests": {"error": "old scorer failure"}, + } + ), + encoding="utf-8", + ) + with patch.object(benchmark, "run_hidden_tests", return_value=hidden): + result = benchmark.rescore_existing_case( + task, + "adaptive", + root, + score_backend="docker", + score_timeout_s=60, + keep_image=False, + ) + + persisted = json.loads((case_root / "result.json").read_text()) + + self.assertEqual(result["quality_score"], 100.0) + self.assertTrue(result["protocol_ok"]) + self.assertEqual(result["protocol_status"], "passed") + self.assertTrue(result["success"]) + self.assertEqual(result["reward_history"][0]["quality_score"], 0.0) + self.assertEqual(persisted["hidden_tests"], hidden) + + def test_parse_pytest_output_uses_hidden_expected_total(self) -> None: + result = benchmark.parse_pytest_output( + "================ 9 passed, 2 failed, 1 skipped in 1.2s ================", + 12, + 1, + ) + + self.assertEqual(result["passed"], 9) + self.assertEqual(result["failed"], 2) + self.assertEqual(result["skipped"], 1) + self.assertEqual(result["quality_score"], 75.0) + self.assertFalse(result["all_passed"]) + + def test_combined_usage_separates_components_and_marks_coverage(self) -> None: + complete = benchmark._combined_usage( + {"input_tokens": 100, "output_tokens": 20}, + {"input_tokens": 50, "output_tokens": 10, "turns": 3}, + used_team=True, + lead_turns=4, + ) + missing_lead = benchmark._combined_usage( + {}, + {"input_tokens": 50, "output_tokens": 10, "turns": 3}, + used_team=True, + lead_turns=4, + ) + solo = benchmark._combined_usage( + {"input_tokens": 100, "output_tokens": 20}, + {}, + used_team=False, + lead_turns=4, + ) + + self.assertEqual(complete["total_tokens"], 180) + self.assertEqual(complete["lead_input_tokens"], 100) + self.assertEqual(complete["worker_output_tokens"], 10) + self.assertTrue(complete["complete"]) + self.assertFalse(missing_lead["complete"]) + self.assertTrue(solo["complete"]) + + def test_score_command_split_keeps_pytest_install_in_build_phase(self) -> None: + setup, tests = benchmark._split_score_commands([ + "pip install pytest", + "pip install -e .", + "python -m pytest tests", + ]) + + self.assertEqual(setup, ["pip install pytest", "pip install -e ."]) + self.assertEqual(tests, ["python -m pytest tests"]) + + def test_unsafe_hidden_test_paths_are_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, "unsafe"): + benchmark._safe_relative_path("../tests") + + def test_agent_child_streams_and_persists_progress(self) -> None: + received: dict[str, object] = {} + + def fake_run_prompt(prompt: str, **kwargs: object) -> AgentLoopResult: + received.update(kwargs) + on_event = kwargs["on_event"] + on_text_chunk = kwargs["on_text_chunk"] + assert callable(on_event) + assert callable(on_text_chunk) + on_event(ToolEvent(kind="model_started", model="test-model", turn=1)) + on_text_chunk("working") + return AgentLoopResult( + response_text="done", + usage={"input_tokens": 3, "output_tokens": 2}, + num_turns=1, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + prompt_path = root / "PROMPT.md" + result_path = root / "agent-result.json" + progress_path = root / "progress.jsonl" + prompt_path.write_text("Build it.", encoding="utf-8") + with patch("src.runner.run_prompt", side_effect=fake_run_prompt): + returncode = benchmark._run_agent_child( + root, + prompt_path, + result_path, + "anthropic", + "test-model", + 5, + 3, + 8192, + True, + progress_path, + ) + progress = [json.loads(line) for line in progress_path.read_text().splitlines()] + result = json.loads(result_path.read_text()) + + self.assertEqual(returncode, 0) + self.assertTrue(received["stream"]) + self.assertEqual(received["max_output_tokens"], 8192) + self.assertEqual([event["kind"] for event in progress], ["model_started", "text_chunk"]) + self.assertEqual(result["lead_model_calls"], 0) + self.assertEqual(result["lead_usage"]["input_tokens"], 3) + self.assertTrue(result["ok"]) + self.assertFalse(result["failed"]) + + def test_agent_child_persists_explicit_lifecycle_failure(self) -> None: + def fake_run_prompt(prompt: str, **kwargs: object) -> AgentLoopResult: + return AgentLoopResult( + response_text="Team aborted after unrecoverable validation.", + usage={"input_tokens": 3, "output_tokens": 2}, + num_turns=2, + failed=True, + failure_reason="team_aborted", + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + prompt_path = root / "PROMPT.md" + result_path = root / "agent-result.json" + progress_path = root / "progress.jsonl" + prompt_path.write_text("Build it.", encoding="utf-8") + with patch("src.runner.run_prompt", side_effect=fake_run_prompt): + returncode = benchmark._run_agent_child( + root, + prompt_path, + result_path, + "anthropic", + "test-model", + 5, + 3, + 8192, + False, + progress_path, + ) + result = json.loads(result_path.read_text()) + + self.assertEqual(returncode, 0) + self.assertFalse(result["ok"]) + self.assertTrue(result["failed"]) + self.assertEqual(result["failure_reason"], "team_aborted") + + def test_agent_child_classifies_terminal_team_budget_exhaustion(self) -> None: + def fake_run_prompt(prompt: str, **kwargs: object) -> AgentLoopResult: + return AgentLoopResult( + response_text="Team execution budget exhausted.", + usage={"input_tokens": 30, "output_tokens": 2}, + num_turns=3, + failed=True, + failure_reason="team_budget_exhausted", + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + prompt_path = root / "PROMPT.md" + result_path = root / "agent-result.json" + progress_path = root / "progress.jsonl" + prompt_path.write_text("Build it.", encoding="utf-8") + with patch("src.runner.run_prompt", side_effect=fake_run_prompt): + returncode = benchmark._run_agent_child( + root, + prompt_path, + result_path, + "anthropic", + "test-model", + 5, + 3, + 8192, + False, + progress_path, + ) + result = json.loads(result_path.read_text()) + + self.assertEqual(returncode, 0) + self.assertFalse(result["ok"]) + self.assertEqual(result["rollout_outcome"], "budget_exhausted") + self.assertEqual(result["failure_reason"], "team_budget_exhausted") + + def test_forced_team_agent_child_precreates_active_team(self) -> None: + observed: dict[str, object] = {} + + def fake_run_prompt(prompt: str, **kwargs: object) -> AgentLoopResult: + from src.teammate.store import TeamStore + + active = TeamStore(Path(kwargs["workspace"])).load_active_team() + observed["team"] = active + return AgentLoopResult("done", {}, 1) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + prompt_path = root / "PROMPT.md" + result_path = root / "agent-result.json" + progress_path = root / "progress.jsonl" + prompt_path.write_text(benchmark.build_prompt("forced-team"), encoding="utf-8") + with patch("src.runner.run_prompt", side_effect=fake_run_prompt): + returncode = benchmark._run_agent_child( + root, + prompt_path, + result_path, + "anthropic", + "test-model", + 5, + 3, + 8192, + False, + progress_path, + mode="forced-team", + ) + progress = [json.loads(line) for line in progress_path.read_text().splitlines()] + + self.assertEqual(returncode, 0) + self.assertIsNotNone(observed["team"]) + self.assertEqual(observed["team"].team_name, "nl2repo-forced-team") + self.assertEqual(observed["team"].protocol_version, 2) + self.assertEqual(observed["team"].lifecycle_state, "draft") + self.assertEqual(observed["team"].settings["protocol_version"], 2) + self.assertTrue(observed["team"].settings["quality_gates"]["strict"]) + self.assertEqual( + observed["team"].settings["quality_gates"]["protocol_version"], 2 + ) + self.assertEqual(progress[0]["kind"], "forced_team_precreated") + self.assertEqual(progress[0]["protocol_version"], 2) + + def test_agent_child_preserves_terminal_usage_when_provider_raises(self) -> None: + def fake_run_prompt(prompt: str, **kwargs: object) -> AgentLoopResult: + on_event = kwargs["on_event"] + assert callable(on_event) + on_event(ToolEvent( + kind="model_response", + model="test-model", + usage={"input_tokens": 7, "output_tokens": 5}, + turn=4, + )) + on_event(ToolEvent( + kind="run_failed", + model="test-model", + usage={"input_tokens": 31, "output_tokens": 19}, + turn=4, + is_error=True, + error="provider rejected history", + )) + raise RuntimeError("provider rejected history") + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + prompt_path = root / "PROMPT.md" + result_path = root / "agent-result.json" + progress_path = root / "progress.jsonl" + prompt_path.write_text("Build it.", encoding="utf-8") + with patch("src.runner.run_prompt", side_effect=fake_run_prompt): + returncode = benchmark._run_agent_child( + root, + prompt_path, + result_path, + "anthropic", + "test-model", + 5, + 3, + 8192, + False, + progress_path, + ) + result = json.loads(result_path.read_text()) + + self.assertEqual(returncode, 1) + self.assertEqual(result["lead_turns"], 4) + self.assertEqual(result["lead_usage"], {"input_tokens": 31, "output_tokens": 19}) + + def test_agent_child_uses_and_cleans_up_ags_workspace(self) -> None: + calls: list[object] = [] + received: dict[str, object] = {} + + class FakeAGSBackend: + workspace_root = "/workspace" + sandbox_id = "ags-test-1" + + def __init__(self, settings: object) -> None: + calls.append(("init", settings)) + + def start(self): + calls.append("start") + return self + + def reset_workspace(self) -> None: + calls.append("reset") + + def upload_tree(self, local: Path, remote: str) -> None: + calls.append(("upload", local, remote)) + + def download_tree(self, remote: str, local: Path) -> None: + calls.append(("download", remote, local)) + (local / "generated.py").write_text("VALUE = 1\n", encoding="utf-8") + + def close(self) -> None: + calls.append("close") + + def fake_run_prompt(prompt: str, **kwargs: object) -> AgentLoopResult: + received.update(kwargs) + return AgentLoopResult("done", {"input_tokens": 2, "output_tokens": 1}, 1) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + workspace = root / "workspace" + workspace.mkdir() + (workspace / "start.md").write_text("Build it.\n", encoding="utf-8") + prompt_path = root / "PROMPT.md" + result_path = root / "agent-result.json" + progress_path = root / "progress.jsonl" + prompt_path.write_text("Build it.", encoding="utf-8") + with ( + patch("src.runner.run_prompt", side_effect=fake_run_prompt), + patch("src.execution.ags.AGSSettings.from_env", return_value="settings"), + patch("src.execution.ags.AGSWorkspaceBackend", FakeAGSBackend), + ): + returncode = benchmark._run_agent_child( + workspace, + prompt_path, + result_path, + "anthropic", + "test-model", + 5, + 3, + 8192, + True, + progress_path, + execution_backend="ags", + ags_image="registry.invalid/nl2repo:sample-1.0", + ) + + result = json.loads(result_path.read_text()) + progress = [json.loads(line)["kind"] for line in progress_path.read_text().splitlines()] + self.assertTrue((workspace / "generated.py").is_file()) + + self.assertEqual(returncode, 0) + self.assertEqual(result["sandbox_id"], "ags-test-1") + self.assertIsInstance(received["workspace_backend"], FakeAGSBackend) + self.assertIn("sandbox_started", progress) + self.assertIn("sandbox_workspace_downloaded", progress) + self.assertEqual(calls[-1], "close") + + def test_ags_scorer_uses_fresh_image_without_resetting_workspace(self) -> None: + calls: list[object] = [] + + class FakeAGSBackend: + workspace_root = "/workspace" + sandbox_id = "ags-score-1" + + def __init__(self, settings: object) -> None: + calls.append(("init", settings)) + + def start(self): + calls.append("start") + return self + + def upload_tree(self, local: Path, remote: str) -> None: + calls.append(("upload", local, remote)) + + def exec(self, command: str, *, cwd: str, timeout_s: int) -> CommandOutcome: + calls.append(("exec", command, cwd, timeout_s)) + return CommandOutcome(0, "12 passed in 0.1s\n", "") + + def close(self) -> None: + calls.append("close") + + task = { + "id": "sample-task", + "image": "ghcr.invalid/sample:1.0", + "expected_tests": 12, + "hidden_paths": ["tests"], + "test_commands": ["pip install -e .", "pytest tests"], + } + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + workspace = root / "workspace" + workspace.mkdir() + (workspace / "sample.py").write_text("VALUE = 1\n", encoding="utf-8") + with ( + patch("src.execution.ags.AGSSettings.from_env", return_value=type("S", (), {"runtime_timeout": 60.0})()), + patch("src.execution.ags.AGSWorkspaceBackend", FakeAGSBackend), + ): + result = benchmark.run_hidden_tests_ags( + task, + workspace, + root / "case", + timeout_s=120, + ags_image="registry.invalid/sample:1.0", + ags_env_file=None, + ags_timeout="3h", + ags_cpu="2", + ags_memory="4Gi", + ags_score_tool_id="sdt-no-egress", + ) + + self.assertEqual(result["pytest"]["passed"], 12) + self.assertTrue(result["pytest"]["all_passed"]) + self.assertFalse(any(call == "reset" for call in calls)) + executed = next( + call + for call in calls + if isinstance(call, tuple) and call[0] == "exec" and "pytest tests" in call[1] + ) + self.assertIn("pip install -e .", executed[1]) + self.assertIn("pytest tests", executed[1]) + + def test_ags_scorer_fails_closed_when_tool_has_egress(self) -> None: + calls: list[str] = [] + + class PublicAGSBackend: + workspace_root = "/workspace" + sandbox_id = "ags-public-score" + + def __init__(self, settings: object) -> None: + pass + + def start(self): + return self + + def exec(self, command: str, *, cwd: str, timeout_s: int) -> CommandOutcome: + calls.append(command) + return CommandOutcome(86, "", "") + + def upload_tree(self, local: Path, remote: str) -> None: + raise AssertionError("candidate must not be uploaded before isolation passes") + + def close(self) -> None: + calls.append("closed") + + task = { + "id": "sample-task", + "image": "ghcr.invalid/sample:1.0", + "expected_tests": 1, + "hidden_paths": ["tests"], + "test_commands": ["pytest tests"], + } + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + workspace = root / "workspace" + workspace.mkdir() + (workspace / "sample.py").write_text("VALUE = 1\n", encoding="utf-8") + with ( + patch( + "src.execution.ags.AGSSettings.from_env", + return_value=type("S", (), {"runtime_timeout": 60.0})(), + ), + patch("src.execution.ags.AGSWorkspaceBackend", PublicAGSBackend), + ): + result = benchmark.run_hidden_tests_ags( + task, + workspace, + root / "case", + timeout_s=120, + ags_image="registry.invalid/sample:1.0", + ags_env_file=None, + ags_timeout="3h", + ags_cpu="2", + ags_memory="4Gi", + ags_score_tool_id="sdt-claimed-no-egress", + ) + + log = (root / "case" / "hidden-tests.log").read_text(encoding="utf-8") + + self.assertEqual(result["pytest"]["returncode"], 1) + self.assertIn("outbound network access", log) + self.assertEqual(calls[-1], "closed") + + def test_ags_scorer_preserves_result_when_sandbox_cleanup_times_out(self) -> None: + class SlowCleanupAGSBackend: + workspace_root = "/workspace" + sandbox_id = "ags-slow-cleanup" + + def __init__(self, settings: object) -> None: + pass + + def start(self): + return self + + def upload_tree(self, local: Path, remote: str) -> None: + pass + + def exec(self, command: str, *, cwd: str, timeout_s: int) -> CommandOutcome: + return CommandOutcome(0, "3 passed in 0.1s\n", "") + + def close(self) -> None: + raise TimeoutError("AGS sandbox cleanup timed out after 600s") + + task = { + "id": "sample-task", + "image": "ghcr.invalid/sample:1.0", + "expected_tests": 3, + "hidden_paths": ["tests"], + "test_commands": ["pytest tests"], + } + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + workspace = root / "workspace" + workspace.mkdir() + (workspace / "sample.py").write_text("VALUE = 1\n", encoding="utf-8") + with ( + patch( + "src.execution.ags.AGSSettings.from_env", + return_value=type("S", (), {"runtime_timeout": 60.0})(), + ), + patch("src.execution.ags.AGSWorkspaceBackend", SlowCleanupAGSBackend), + ): + result = benchmark.run_hidden_tests_ags( + task, + workspace, + root / "case", + timeout_s=1200, + ags_image="registry.invalid/sample:1.0", + ags_env_file=None, + ags_timeout="3h", + ags_cpu="2", + ags_memory="4Gi", + ags_score_tool_id="sdt-no-egress", + ) + + self.assertTrue(result["pytest"]["all_passed"]) + self.assertIn("cleanup timed out", result["cleanup_error"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_nl2repo_reward_repair.py b/tests/test_nl2repo_reward_repair.py new file mode 100644 index 0000000..ce5a5c8 --- /dev/null +++ b/tests/test_nl2repo_reward_repair.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import importlib.util +import json +import sys +import tempfile +import unittest +from pathlib import Path + + +MODULE_PATH = ( + Path(__file__).resolve().parents[1] + / "teammate-evals" + / "nl2repo-pilot" + / "reward_repair.py" +) +SPEC = importlib.util.spec_from_file_location("nl2repo_reward_repair", MODULE_PATH) +assert SPEC is not None and SPEC.loader is not None +repair = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = repair +SPEC.loader.exec_module(repair) + + +def write_json(path: Path, value: object) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value), encoding="utf-8") + + +def case_result(task: str, quality: float, *, error: str | None = None) -> dict[str, object]: + hidden: dict[str, object] = { + "pytest": { + "expected": 10, + "passed": int(quality / 10), + "failed": 0, + "errors": 0, + "skipped": 0, + "returncode": 1, + "quality_score": quality, + "all_passed": False, + } + } + if error: + hidden["error"] = error + return { + "task": task, + "difficulty": "Easy", + "mode": "adaptive", + "quality_score": quality, + "success": False, + "agent_elapsed_s": 1.0, + "usage": {"total_tokens": 0}, + "team": {"agents": [], "peer_messages": 0}, + "protocol_ok": True, + "hidden_tests": hidden, + } + + +class TestRewardRepair(unittest.TestCase): + def test_main_always_routes_reward_repairs_through_global_pool(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + with self.assertRaisesRegex(SystemExit, "global_pool_supervisor.py"): + repair.main(["--run", str(root)]) + + def test_discovery_selects_only_explicit_infrastructure_errors(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + write_json( + root / "broken" / "adaptive" / "result.json", + case_result("broken", 0, error="Docker build failed"), + ) + write_json( + root / "valid-zero" / "adaptive" / "result.json", + case_result("valid-zero", 0), + ) + failures = repair.discover_infrastructure_failures(root) + + self.assertEqual(failures, [("broken", "adaptive", "Docker build failed")]) + + def test_refresh_aggregate_prefers_repaired_case_results(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + old = case_result("sample", 0, error="old failure") + current = case_result("sample", 70) + write_json(root / "sample" / "adaptive" / "result.json", current) + write_json( + root / "results.json", + { + "run_id": "run", + "upstream_ref": "ref", + "results": [old], + }, + ) + + changed = repair.refresh_aggregate(root) + aggregate = json.loads((root / "results.json").read_text(encoding="utf-8")) + report = (root / "REPORT.md").read_text(encoding="utf-8") + + self.assertTrue(changed) + self.assertEqual(aggregate["results"][0]["quality_score"], 70) + self.assertIn("70", report) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_peer_benchmark.py b/tests/test_peer_benchmark.py new file mode 100644 index 0000000..1587126 --- /dev/null +++ b/tests/test_peer_benchmark.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import importlib.util +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +BENCHMARK = ROOT / "teammate-evals" / "peer-collaboration" + + +def load_module(name: str, path: Path): + spec = importlib.util.spec_from_file_location(name, path) + if spec is None or spec.loader is None: + raise RuntimeError(f"cannot load {path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class PeerBenchmarkTests(unittest.TestCase): + def test_scripted_coupled_smoke_and_schemas(self) -> None: + smoke = load_module("peer_scripted_smoke", BENCHMARK / "scripted_smoke.py") + validator = load_module( + "peer_schema_validation", BENCHMARK / "schema_validation.py" + ) + with tempfile.TemporaryDirectory() as temporary: + result = smoke.run_smoke(Path(temporary)) + run_dir = Path(result["result_path"]).parent + validator.validate_run(run_dir) + self.assertEqual(result["status"], "completed") + self.assertEqual(result["acceptance"]["exit_code"], 0) + self.assertTrue(result["acceptance"]["stderr"]) + self.assertEqual(len(result["messages"]), 1) + self.assertEqual(result["messages"][0]["status"], "consumed") + self.assertEqual(result["orphan_threads"], []) + + def test_real_runner_requires_explicit_arguments(self) -> None: + source = (BENCHMARK / "runner.py").read_text(encoding="utf-8") + self.assertIn("--provider", source) + self.assertIn("--model", source) + self.assertNotIn("run_smoke(", source) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_peer_cli.py b/tests/test_peer_cli.py new file mode 100644 index 0000000..1b832f9 --- /dev/null +++ b/tests/test_peer_cli.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import io +import tempfile +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from unittest.mock import patch + +from src.cli import main +from src.peer.models import PeerRunConfig + + +class PeerCliTests(unittest.TestCase): + def test_peer_cli_routes_all_reproducibility_options(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repo = Path(temporary) + prompt = repo / "TASK.md" + prompt.write_text("same mission\n", encoding="utf-8") + completed = { + "status": "completed", + "acceptance": {"exit_code": 0}, + "run_id": "run", + } + argv = [ + "clawd", + "peer", + "run", + "--repo", + str(repo), + "--prompt-file", + "TASK.md", + "--peers", + "3", + "--communication", + "star", + "--workspace-mode", + "worktree", + "--provider", + "anthropic", + "--model", + "glm-5.2", + "--timeout-seconds", + "42", + "--max-turns", + "9", + "--max-output-tokens", + "2048", + "--token-budget", + "50000", + "--turn-budget", + "25", + "--output-dir", + str(repo / "outputs"), + "--coordinator-peer", + "peer-2", + "--acceptance-command", + "python -m pytest -q", + ] + with ( + patch("sys.argv", argv), + patch( + "src.peer.runner.run_peer_collaboration", return_value=completed + ) as run, + redirect_stdout(io.StringIO()), + ): + exit_code = main() + self.assertEqual(exit_code, 0) + kwargs = run.call_args.kwargs + self.assertEqual(run.call_args.args, ("same mission\n",)) + self.assertEqual(kwargs["peers"], 3) + self.assertEqual(kwargs["communication"], "star") + self.assertEqual(kwargs["workspace_mode"], "worktree") + self.assertEqual(kwargs["model"], "glm-5.2") + self.assertEqual(kwargs["coordinator_peer"], "peer-2") + self.assertEqual(kwargs["acceptance_command"], ["python", "-m", "pytest", "-q"]) + + def test_peer_config_rejects_invalid_protocol_combinations(self) -> None: + base = { + "repo_path": ".", + "mission": "mission", + "peers": 2, + "communication": "p2p", + "workspace_mode": "shared", + } + invalid = [ + {**base, "peers": 0}, + {**base, "communication": "solo"}, + {**base, "workspace_mode": "unknown"}, + {**base, "timeout_seconds": 0}, + {**base, "token_budget": 0}, + {**base, "mission": ""}, + ] + for values in invalid: + with self.subTest(values=values), self.assertRaises(ValueError): + PeerRunConfig(**values).validate() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_peer_protocol.py b/tests/test_peer_protocol.py new file mode 100644 index 0000000..099d402 --- /dev/null +++ b/tests/test_peer_protocol.py @@ -0,0 +1,228 @@ +from __future__ import annotations + +import json +import subprocess +import tempfile +import unittest +from pathlib import Path + +from src.peer.backend import PeerBoundaryResult, ScriptedPeerBackend +from src.peer.models import PeerRunConfig +from src.peer.runtime import PeerRuntime +from src.tool_system.defaults import build_default_registry +from src.tool_system.protocol import ToolCall + + +def git(repo: Path, *args: str) -> str: + return subprocess.run( + ["git", *args], cwd=repo, check=True, capture_output=True, text=True + ).stdout.strip() + + +class PeerProtocolTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) + self.repo = self.root / "repo" + self.repo.mkdir() + git(self.repo, "init", "-q") + git(self.repo, "config", "user.name", "Protocol Tests") + git(self.repo, "config", "user.email", "protocol@example.invalid") + (self.repo / "TASK.md").write_text("fixture\n", encoding="utf-8") + git(self.repo, "add", "TASK.md") + git(self.repo, "commit", "-qm", "initial") + self.registry = build_default_registry(include_user_tools=False) + + def tearDown(self) -> None: + self.temp.cleanup() + + def test_all_conditions_keep_noncommunication_tools_and_budget_comparable(self) -> None: + observed: dict[str, set[str]] = {} + manifests: dict[str, dict] = {} + + for condition in ("solo", "independent", "artifact-only", "star", "p2p"): + peers = 1 if condition == "solo" else 2 + + def handler(session, prompt, registry, context, condition=condition): + observed.setdefault(condition, {spec.name for spec in registry.list_specs()}) + if session.spec.peer_name == "peer-1": + head = git(Path(session.spec.workspace_path), "rev-parse", "HEAD") + registry.dispatch( + ToolCall( + "PeerSubmit", + {"revision": head, "summary": f"{condition} result"}, + ), + context, + ) + return PeerBoundaryResult(response_text="done", num_turns=1) + + runtime = PeerRuntime(ScriptedPeerBackend(handler), self.registry) + output = self.root / f"runs-{condition}" + result = runtime.run( + PeerRunConfig( + repo_path=str(self.repo), + mission="identical mission", + peers=peers, + communication=condition, + workspace_mode="shared", + provider="scripted", + model="same-model", + timeout_seconds=2, + max_turns=7, + token_budget=100, + output_dir=str(output), + ) + ) + manifests[condition] = json.loads(Path(result["manifest_path"]).read_text()) + self.assertEqual(result["status"], "completed") + + for condition in ("independent", "artifact-only", "solo"): + self.assertNotIn("SendMessage", observed[condition]) + self.assertNotIn("ReadMessages", observed[condition]) + self.assertNotIn("Broadcast", observed[condition]) + for condition in ("star", "p2p"): + self.assertIn("SendMessage", observed[condition]) + self.assertIn("ReadMessages", observed[condition]) + self.assertIn("Broadcast", observed[condition]) + stripped = { + condition: tools - {"SendMessage", "ReadMessages", "Broadcast"} + for condition, tools in observed.items() + } + self.assertEqual(len({frozenset(value) for value in stripped.values()}), 1) + self.assertTrue(all(item["config"]["max_turns"] == 7 for item in manifests.values())) + self.assertTrue(all(item["config"]["token_budget"] == 100 for item in manifests.values())) + self.assertTrue(all(item["config"]["model"] == "same-model" for item in manifests.values())) + + def test_peer_prompt_is_neutral_and_has_no_task_dag_or_fixed_professions(self) -> None: + from src.peer.models import PeerParticipant + + participant = PeerParticipant( + peer_id="run-p1", + run_id="run", + name="peer-1", + session_id="session", + workspace_mode="shared", + workspace_path=str(self.repo), + ) + prompt = PeerRuntime.peer_system_context(participant) + lowered = prompt.casefold() + for forbidden in ("planner", "coder", "reviewer", "owned task", "task dag"): + self.assertNotIn(forbidden, lowered) + self.assertIn("equal coding peer", lowered) + self.assertIn("no participant has supervisory authority", lowered) + self.assertNotIn("lead_agent_id", lowered) + + def test_peer_tool_search_indexes_peer_submit_not_legacy_team_tools(self) -> None: + observed: list[dict] = [] + + def handler(session, prompt, registry, context): + if session.spec.peer_name == "peer-1": + searched = registry.dispatch( + ToolCall("ToolSearch", {"query": "PeerSubmit"}), context + ) + observed.append(searched.output) + head = git(Path(session.spec.workspace_path), "rev-parse", "HEAD") + registry.dispatch( + ToolCall("PeerSubmit", {"revision": head, "summary": "valid"}), context + ) + return PeerBoundaryResult(response_text="done", num_turns=1) + + result = PeerRuntime(ScriptedPeerBackend(handler), self.registry).run( + PeerRunConfig( + repo_path=str(self.repo), + mission="mission", + peers=2, + communication="p2p", + workspace_mode="shared", + provider="scripted", + timeout_seconds=2, + max_turns=3, + output_dir=str(self.root / "search-runs"), + ) + ) + self.assertEqual(result["status"], "completed") + self.assertEqual(observed[0]["matches"], ["PeerSubmit"]) + self.assertNotIn("TeamRun", observed[0]["matches"]) + + def test_invalid_revision_is_rejected_then_another_peer_can_submit(self) -> None: + attempts: list[str] = [] + + def handler(session, prompt, registry, context): + if session.spec.peer_name == "peer-1": + bad = registry.dispatch( + ToolCall( + "PeerSubmit", + {"revision": "definitely-not-a-commit", "summary": "invalid"}, + ), + context, + ) + attempts.append(bad.output["status"]) + else: + head = git(Path(session.spec.workspace_path), "rev-parse", "HEAD") + good = registry.dispatch( + ToolCall("PeerSubmit", {"revision": head, "summary": "valid"}), context + ) + attempts.append(good.output["status"]) + return PeerBoundaryResult(response_text="done", num_turns=1) + + result = PeerRuntime(ScriptedPeerBackend(handler), self.registry).run( + PeerRunConfig( + repo_path=str(self.repo), + mission="mission", + peers=2, + communication="p2p", + workspace_mode="shared", + provider="scripted", + timeout_seconds=2, + max_turns=3, + output_dir=str(self.root / "invalid-runs"), + ) + ) + self.assertIn("rejected", attempts) + self.assertIn("accepted", attempts) + self.assertEqual(result["status"], "completed") + self.assertEqual( + [item["status"] for item in result["submissions"]].count("rejected"), 1 + ) + + def test_message_call_in_independent_condition_is_rejected_and_traced(self) -> None: + rejection_outputs: list[dict] = [] + + def handler(session, prompt, registry, context): + if session.spec.peer_name == "peer-1": + rejected = registry.dispatch( + ToolCall( + "SendMessage", + {"to": "peer-2", "message": "forbidden"}, + ), + context, + ) + rejection_outputs.append(rejected.output) + head = git(Path(session.spec.workspace_path), "rev-parse", "HEAD") + registry.dispatch( + ToolCall("PeerSubmit", {"revision": head, "summary": "done"}), context + ) + return PeerBoundaryResult(response_text="done", num_turns=1) + + result = PeerRuntime(ScriptedPeerBackend(handler), self.registry).run( + PeerRunConfig( + repo_path=str(self.repo), + mission="mission", + peers=2, + communication="independent", + workspace_mode="shared", + provider="scripted", + timeout_seconds=2, + max_turns=3, + output_dir=str(self.root / "independent-rejection"), + ) + ) + self.assertIn("unavailable under independent", rejection_outputs[0]["error"]) + events = [json.loads(line) for line in Path(result["events_path"]).read_text().splitlines()] + self.assertEqual( + [event["type"] for event in events].count("policy.rejected"), 1 + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_peer_runtime.py b/tests/test_peer_runtime.py new file mode 100644 index 0000000..2ab24d4 --- /dev/null +++ b/tests/test_peer_runtime.py @@ -0,0 +1,337 @@ +from __future__ import annotations + +import json +import subprocess +import tempfile +import threading +import time +import unittest +from pathlib import Path + +from src.peer.backend import PeerBoundaryResult, ScriptedPeerBackend +from src.peer.models import PeerRunConfig +from src.peer.runtime import PeerRuntime +from src.tool_system.defaults import build_default_registry +from src.tool_system.protocol import ToolCall + + +def git(repo: Path, *args: str) -> str: + completed = subprocess.run( + ["git", *args], cwd=repo, capture_output=True, text=True, check=True + ) + return completed.stdout.strip() + + +class PeerRuntimeTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory() + self.repo = Path(self.temp.name) / "repo" + self.repo.mkdir() + git(self.repo, "init", "-q") + git(self.repo, "config", "user.name", "Peer Tests") + git(self.repo, "config", "user.email", "peer-tests@example.invalid") + (self.repo / "README.md").write_text("fixture\n", encoding="utf-8") + git(self.repo, "add", "README.md") + git(self.repo, "commit", "-qm", "initial") + self.output = Path(self.temp.name) / "runs" + + def tearDown(self) -> None: + self.temp.cleanup() + + def config(self, **overrides: object) -> PeerRunConfig: + values: dict[str, object] = { + "repo_path": str(self.repo), + "mission": "Implement the repository and submit a verified revision.", + "peers": 2, + "communication": "p2p", + "workspace_mode": "shared", + "provider": "scripted", + "model": "deterministic", + "timeout_seconds": 3.0, + "max_turns": 10, + "output_dir": str(self.output), + } + values.update(overrides) + return PeerRunConfig(**values) # type: ignore[arg-type] + + def runtime(self, handler: object) -> PeerRuntime: + return PeerRuntime( + ScriptedPeerBackend(handler), # type: ignore[arg-type] + build_default_registry(include_user_tools=False), + ) + + def test_peers_overlap_and_sessions_are_independent(self) -> None: + starts: dict[str, float] = {} + initial_prompts: dict[str, str] = {} + lock = threading.Lock() + + def handler(session, prompt, registry, context): + with lock: + starts[session.spec.peer_id] = time.monotonic() + initial_prompts[session.spec.peer_id] = prompt + time.sleep(0.15) + if session.spec.peer_name == "peer-1": + head = git(Path(session.spec.workspace_path), "rev-parse", "HEAD") + registry.dispatch( + ToolCall( + "PeerSubmit", + {"revision": head, "summary": "base fixture is valid"}, + ), + context, + ) + return PeerBoundaryResult(response_text="done", num_turns=1) + + result = self.runtime(handler).run(self.config()) + self.assertEqual(result["status"], "completed") + self.assertLess(max(starts.values()) - min(starts.values()), 0.1) + sessions = {peer["session_id"] for peer in result["participants"]} + self.assertEqual(len(sessions), 2) + self.assertEqual(set(initial_prompts.values()), {self.config().mission}) + self.assertEqual(result["orphan_threads"], []) + + def test_idle_peer_is_event_driven_woken_and_sees_message(self) -> None: + peer_two_ready = threading.Event() + wake_prompts: list[str] = [] + + def handler(session, prompt, registry, context): + if session.spec.peer_name == "peer-2" and session.boundary_index == 1: + peer_two_ready.set() + return PeerBoundaryResult(response_text="available", num_turns=1) + if session.spec.peer_name == "peer-1" and session.boundary_index == 1: + self.assertTrue(peer_two_ready.wait(1)) + sent = registry.dispatch( + ToolCall( + "SendMessage", + { + "to": "peer-2", + "summary": "contract", + "message": {"endpoint": "/v1/items"}, + }, + ), + context, + ) + self.assertFalse(sent.is_error) + return PeerBoundaryResult(response_text="sent", num_turns=1) + if session.spec.peer_name == "peer-2" and session.boundary_index == 2: + wake_prompts.append(prompt) + head = git(Path(session.spec.workspace_path), "rev-parse", "HEAD") + registry.dispatch( + ToolCall( + "PeerSubmit", + {"revision": head, "summary": "contract received"}, + ), + context, + ) + return PeerBoundaryResult(response_text="idle", num_turns=1) + + result = self.runtime(handler).run(self.config()) + self.assertEqual(result["status"], "completed") + self.assertEqual(len(wake_prompts), 1) + self.assertIn("/v1/items", wake_prompts[0]) + events = [json.loads(line) for line in Path(result["events_path"]).read_text().splitlines()] + types = [event["type"] for event in events] + self.assertIn("peer.idle", types) + self.assertIn("peer.woken", types) + consumed = [event for event in events if event["type"] == "message.consumed"] + self.assertEqual(len(consumed), 1) + + def test_first_valid_concurrent_submit_wins_atomically(self) -> None: + submit_barrier = threading.Barrier(2) + + def handler(session, prompt, registry, context): + head = git(Path(session.spec.workspace_path), "rev-parse", "HEAD") + submit_barrier.wait(timeout=1) + registry.dispatch( + ToolCall( + "PeerSubmit", + {"revision": head, "summary": session.spec.peer_name}, + ), + context, + ) + return PeerBoundaryResult(response_text="submitted", num_turns=1) + + result = self.runtime(handler).run(self.config()) + statuses = [submission["status"] for submission in result["submissions"]] + self.assertEqual(statuses.count("accepted"), 1) + self.assertEqual(statuses.count("already_submitted"), 1) + self.assertIn( + result["accepted_submission"]["peer_id"], + {peer["peer_id"] for peer in result["participants"]}, + ) + + def test_submit_stops_later_tool_dispatch(self) -> None: + after_submit: list[dict[str, object]] = [] + + def handler(session, prompt, registry, context): + if session.spec.peer_name == "peer-1": + head = git(Path(session.spec.workspace_path), "rev-parse", "HEAD") + accepted = registry.dispatch( + ToolCall("PeerSubmit", {"revision": head, "summary": "done"}), context + ) + blocked = registry.dispatch(ToolCall("PeerList", {}), context) + after_submit.append( + {"accepted": accepted.output["status"], "blocked": blocked.is_error} + ) + return PeerBoundaryResult(response_text="done", num_turns=1) + + result = self.runtime(handler).run(self.config()) + self.assertEqual(result["status"], "completed") + self.assertEqual(after_submit, [{"accepted": "accepted", "blocked": True}]) + + def test_timeout_budget_and_peer_crash_leave_no_orphans(self) -> None: + def idle_handler(session, prompt, registry, context): + return PeerBoundaryResult(response_text="idle", num_turns=1) + + timeout = self.runtime(idle_handler).run( + self.config(timeout_seconds=0.25, max_turns=5) + ) + self.assertEqual(timeout["status"], "timed_out") + self.assertEqual(timeout["orphan_threads"], []) + + def budget_handler(session, prompt, registry, context): + return PeerBoundaryResult( + response_text="spent", + usage={"input_tokens": 3, "output_tokens": 2}, + num_turns=1, + ) + + budget = self.runtime(budget_handler).run( + self.config(token_budget=5, timeout_seconds=1) + ) + self.assertEqual(budget["status"], "budget_exhausted") + self.assertGreaterEqual(budget["usage"]["total_tokens"], 5) + self.assertEqual(budget["orphan_threads"], []) + + def crash_handler(session, prompt, registry, context): + if session.spec.peer_name == "peer-1": + raise RuntimeError("scripted crash") + head = git(Path(session.spec.workspace_path), "rev-parse", "HEAD") + registry.dispatch( + ToolCall("PeerSubmit", {"revision": head, "summary": "survived"}), context + ) + return PeerBoundaryResult(response_text="done", num_turns=1) + + crash = self.runtime(crash_handler).run(self.config()) + self.assertEqual(crash["status"], "completed") + self.assertIn("failed", {peer["status"] for peer in crash["participants"]}) + self.assertEqual(crash["orphan_threads"], []) + + def test_worktree_peer_commit_can_be_integrated_and_submitted_by_other_peer(self) -> None: + commit_ready = threading.Event() + commit_holder: list[str] = [] + + def handler(session, prompt, registry, context): + workspace = Path(session.spec.workspace_path) + if session.spec.peer_name == "peer-1": + (workspace / "peer_one.py").write_text("VALUE = 1\n", encoding="utf-8") + git(workspace, "add", "peer_one.py") + git(workspace, "commit", "-qm", "peer one") + commit_holder.append(git(workspace, "rev-parse", "HEAD")) + commit_ready.set() + else: + self.assertTrue(commit_ready.wait(1)) + git(workspace, "cherry-pick", commit_holder[0]) + (workspace / "peer_two.py").write_text("VALUE = 2\n", encoding="utf-8") + git(workspace, "add", "peer_two.py") + git(workspace, "commit", "-qm", "peer two integrates peer one") + head = git(workspace, "rev-parse", "HEAD") + registry.dispatch( + ToolCall( + "PeerSubmit", + {"revision": head, "summary": "integrated both commits"}, + ), + context, + ) + return PeerBoundaryResult(response_text="done", num_turns=1) + + result = self.runtime(handler).run( + self.config(workspace_mode="worktree", cleanup_worktrees=True) + ) + self.assertEqual(result["status"], "completed") + self.assertEqual(result["retained_worktrees"], []) + accepted = result["accepted_submission"]["revision"] + names = git(self.repo, "show", "--format=", "--name-only", accepted) + self.assertIn("peer_two.py", names) + parent_names = git(self.repo, "show", "--format=", "--name-only", f"{accepted}^") + self.assertIn("peer_one.py", parent_names) + + def test_external_cancel_wakes_idle_peers_and_stops_cleanly(self) -> None: + idle = threading.Barrier(3) + + def handler(session, prompt, registry, context): + idle.wait(timeout=2) + return PeerBoundaryResult(response_text="available", num_turns=1) + + runtime = self.runtime(handler) + holder: list[dict[str, object]] = [] + + def execute() -> None: + holder.append(runtime.run(self.config(timeout_seconds=5), run_id="cancel-run")) + + thread = threading.Thread(target=execute) + thread.start() + idle.wait(timeout=2) + time.sleep(0.05) + self.assertTrue(runtime.cancel("cancel-run", "user_cancelled")) + thread.join(timeout=3) + self.assertFalse(thread.is_alive()) + self.assertEqual(holder[0]["status"], "cancelled") + self.assertEqual(holder[0]["orphan_threads"], []) + + def test_orphan_threads_take_precedence_over_accepted_submission_status(self) -> None: + from src.peer.models import PeerRunRecord + + run = PeerRunRecord( + run_id="run", + mission="mission", + repo_path=str(self.repo), + base_revision=git(self.repo, "rev-parse", "HEAD"), + peer_count=1, + communication="solo", + workspace_mode="shared", + provider="scripted", + model=None, + timeout_seconds=1, + max_turns=1, + max_output_tokens=1, + token_budget=None, + turn_budget=None, + output_dir=str(self.output), + accepted_submission={"revision": git(self.repo, "rev-parse", "HEAD")}, + ) + self.assertEqual( + PeerRuntime._terminal_status(run, "submitted", ["stuck-worker"]), + "failed", + ) + + def test_default_control_state_is_git_excluded_from_shared_commits(self) -> None: + def handler(session, prompt, registry, context): + if session.spec.peer_name == "peer-1": + workspace = Path(session.spec.workspace_path) + (workspace / "solution.py").write_text("VALUE = 1\n", encoding="utf-8") + git(workspace, "add", "-A") + git(workspace, "commit", "-qm", "solution without control state") + head = git(workspace, "rev-parse", "HEAD") + registry.dispatch( + ToolCall("PeerSubmit", {"revision": head, "summary": "done"}), context + ) + return PeerBoundaryResult(response_text="done", num_turns=1) + + config = self.config() + config = PeerRunConfig( + **{**config.to_dict(), "output_dir": None} + ) + result = self.runtime(handler).run(config) + names = git( + self.repo, + "show", + "--format=", + "--name-only", + result["accepted_submission"]["revision"], + ).splitlines() + self.assertIn("solution.py", names) + self.assertFalse(any(name.startswith(".clawd/") for name in names)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_peer_store.py b/tests/test_peer_store.py new file mode 100644 index 0000000..49ee231 --- /dev/null +++ b/tests/test_peer_store.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +import json +import tempfile +import threading +import unittest +from pathlib import Path + +from src.peer.models import PeerParticipant, PeerRunRecord +from src.peer.policy import CommunicationPolicy, PolicyRejected +from src.peer.store import PeerStore + + +class PeerStoreTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) + self.store = PeerStore(self.root / "runs") + self.run_id = "run-one" + self.peer_ids = ("run-one-p1", "run-one-p2", "run-one-p3") + run = PeerRunRecord( + run_id=self.run_id, + mission="same mission", + repo_path=str(self.root), + base_revision="0" * 40, + peer_count=3, + communication="p2p", + workspace_mode="shared", + provider="scripted", + model=None, + timeout_seconds=30, + max_turns=10, + max_output_tokens=1024, + token_budget=None, + turn_budget=None, + output_dir=str(self.root / "runs"), + ) + self.store.create_run(run, {"schema_version": 1, "run_id": self.run_id}) + for index, peer_id in enumerate(self.peer_ids, start=1): + self.store.save_participant( + PeerParticipant( + peer_id=peer_id, + run_id=self.run_id, + name=f"peer-{index}", + session_id=f"session-{index}", + workspace_mode="shared", + workspace_path=str(self.root), + ) + ) + self.policy = CommunicationPolicy("p2p", self.peer_ids) + + def tearDown(self) -> None: + self.temp.cleanup() + + def test_roster_is_stable_and_contains_no_lead(self) -> None: + first = [peer.to_dict() for peer in self.store.list_participants(self.run_id)] + second = [peer.to_dict() for peer in PeerStore(self.root / "runs").list_participants(self.run_id)] + self.assertEqual(first, second) + self.assertEqual({peer["name"] for peer in first}, {"peer-1", "peer-2", "peer-3"}) + self.assertNotIn("lead_agent_id", json.dumps(first)) + + def test_direct_message_persists_and_consumes_exactly_once(self) -> None: + message = self.store.send_message( + self.run_id, + self.peer_ids[0], + "peer-2", + {"contract": "v1"}, + summary="interface", + policy=self.policy, + ) + reloaded = PeerStore(self.root / "runs") + unread = reloaded.list_messages( + self.run_id, recipient_id=self.peer_ids[1], status="delivered" + ) + self.assertEqual([item.message_id for item in unread], [message.message_id]) + consumed = reloaded.consume_messages(self.run_id, self.peer_ids[1]) + self.assertEqual([item.payload for item in consumed], [{"contract": "v1"}]) + self.assertEqual(reloaded.consume_messages(self.run_id, self.peer_ids[1]), []) + persisted = reloaded.list_messages(self.run_id)[0] + self.assertEqual(persisted.status, "consumed") + self.assertIsNotNone(persisted.consumed_at) + + def test_unknown_self_cross_run_and_illegal_payload_are_rejected(self) -> None: + with self.assertRaises(PolicyRejected): + self.store.send_message( + self.run_id, + self.peer_ids[0], + self.peer_ids[0], + "self", + policy=self.policy, + ) + with self.assertRaises(PolicyRejected): + self.store.send_message( + self.run_id, + self.peer_ids[0], + "unknown-peer", + "unknown", + policy=self.policy, + ) + foreign = CommunicationPolicy("p2p", ("other-p1", "other-p2")) + with self.assertRaises(PolicyRejected): + self.store.send_message( + self.run_id, "other-p1", "other-p2", "cross", policy=foreign + ) + with self.assertRaises(ValueError): + self.store.send_message( + self.run_id, + self.peer_ids[0], + self.peer_ids[1], + object(), + policy=self.policy, + ) + rejected = [event for event in self.store.list_events(self.run_id) if event["type"] == "policy.rejected"] + self.assertEqual(len(rejected), 3) + + def test_broadcast_exactly_once_excludes_sender_and_is_idempotent(self) -> None: + first = self.store.broadcast( + self.run_id, + self.peer_ids[0], + {"decision": 7}, + policy=self.policy, + idempotency_key="decision-7", + ) + retry = self.store.broadcast( + self.run_id, + self.peer_ids[0], + {"decision": 7}, + policy=self.policy, + idempotency_key="decision-7", + ) + self.assertEqual(first.broadcast_id, retry.broadcast_id) + self.assertEqual(set(first.recipients), set(self.peer_ids[1:])) + self.assertNotIn(self.peer_ids[0], first.recipients) + messages = self.store.list_messages(self.run_id) + self.assertEqual(len(messages), 2) + self.assertEqual(len({item.recipient_id for item in messages}), 2) + + def test_star_acl_allows_only_edges_touching_coordinator(self) -> None: + star = CommunicationPolicy("star", self.peer_ids, self.peer_ids[0]) + self.assertTrue(star.can_send(self.peer_ids[1], self.peer_ids[0])) + self.assertTrue(star.can_send(self.peer_ids[0], self.peer_ids[2])) + self.assertFalse(star.can_send(self.peer_ids[1], self.peer_ids[2])) + with self.assertRaises(PolicyRejected): + self.store.send_message( + self.run_id, + self.peer_ids[1], + self.peer_ids[2], + "forbidden", + policy=star, + ) + worker_broadcast = self.store.broadcast( + self.run_id, + self.peer_ids[1], + "to hub", + policy=star, + ) + self.assertEqual(worker_broadcast.recipients, [self.peer_ids[0]]) + + def test_independent_and_artifact_only_have_no_edges(self) -> None: + for condition in ("solo", "independent", "artifact-only"): + policy = CommunicationPolicy(condition, self.peer_ids) + self.assertFalse(policy.exposes_message_tools()) + self.assertFalse(policy.can_send(self.peer_ids[0], self.peer_ids[1])) + + def test_concurrent_consumers_do_not_duplicate_delivery(self) -> None: + count = 40 + for index in range(count): + self.store.send_message( + self.run_id, + self.peer_ids[0], + self.peer_ids[1], + {"index": index}, + policy=self.policy, + ) + outputs: list[str] = [] + lock = threading.Lock() + + def consume() -> None: + items = PeerStore(self.root / "runs").consume_messages( + self.run_id, self.peer_ids[1] + ) + with lock: + outputs.extend(item.message_id for item in items) + + threads = [threading.Thread(target=consume) for _ in range(8)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=5) + self.assertEqual(len(outputs), count) + self.assertEqual(len(set(outputs)), count) + self.assertTrue(all(not thread.is_alive() for thread in threads)) + + def test_concurrent_events_remain_valid_jsonl(self) -> None: + def append(worker: int) -> None: + for index in range(25): + self.store.append_event( + self.run_id, "test.concurrent", {"worker": worker, "index": index} + ) + + threads = [threading.Thread(target=append, args=(index,)) for index in range(6)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=5) + path = self.store.run_dir(self.run_id) / "events.jsonl" + parsed = [json.loads(line) for line in path.read_text().splitlines()] + concurrent = [item for item in parsed if item["type"] == "test.concurrent"] + self.assertEqual(len(concurrent), 150) + self.assertTrue(all("created_at" in item and "monotonic_ns" in item for item in concurrent)) + + def test_simultaneous_broadcasts_have_exact_delivery_counts(self) -> None: + broadcasts: list[str] = [] + lock = threading.Lock() + + def send(sender_id: str) -> None: + item = self.store.broadcast( + self.run_id, + sender_id, + {"sender": sender_id}, + policy=self.policy, + idempotency_key=f"broadcast-{sender_id}", + ) + with lock: + broadcasts.append(item.broadcast_id) + + threads = [threading.Thread(target=send, args=(peer_id,)) for peer_id in self.peer_ids] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=5) + self.assertEqual(len(set(broadcasts)), 3) + self.assertEqual(len(self.store.list_messages(self.run_id)), 6) + self.assertEqual(len(self.store.list_broadcasts(self.run_id)), 3) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_peer_tools.py b/tests/test_peer_tools.py new file mode 100644 index 0000000..e7484cd --- /dev/null +++ b/tests/test_peer_tools.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import tempfile +import threading +import time +import unittest +from pathlib import Path +from types import SimpleNamespace + +from src.peer.models import PeerParticipant, PeerRunRecord +from src.peer.policy import CommunicationPolicy +from src.peer.store import PeerStore +from src.peer.tools import ( + PeerBroadcastTool, + PeerListTool, + PeerReadMessagesTool, + PeerSendMessageTool, +) +from src.tool_system.context import ToolContext + + +class PeerToolTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) + self.store = PeerStore(self.root / "runs") + self.run_id = "tool-run" + self.ids = ("tool-run-p1", "tool-run-p2", "tool-run-p3") + self.store.create_run( + PeerRunRecord( + run_id=self.run_id, + mission="mission", + repo_path=str(self.root), + base_revision="0" * 40, + peer_count=3, + communication="p2p", + workspace_mode="shared", + provider="scripted", + model=None, + timeout_seconds=10, + max_turns=5, + max_output_tokens=1024, + token_budget=None, + turn_budget=None, + output_dir=str(self.root / "runs"), + ), + {"schema_version": 1}, + ) + for index, peer_id in enumerate(self.ids, start=1): + self.store.save_participant( + PeerParticipant( + peer_id=peer_id, + run_id=self.run_id, + name=f"peer-{index}", + session_id=f"session-{index}", + workspace_mode="shared", + workspace_path=str(self.root), + ) + ) + policy = CommunicationPolicy("p2p", self.ids) + self.control = SimpleNamespace(policy=policy, stop_event=threading.Event()) + + def tearDown(self) -> None: + self.temp.cleanup() + + def context(self, peer_id: str) -> ToolContext: + return ToolContext( + workspace_root=self.root, + actor_id=peer_id, + peer_store=self.store, + peer_run_id=self.run_id, + peer_id=peer_id, + peer_control=self.control, + ) + + def test_all_peers_see_identical_roster_without_privilege_fields(self) -> None: + rosters = [ + PeerListTool().run({}, self.context(peer_id)).output["peers"] + for peer_id in self.ids + ] + self.assertEqual(rosters[0], rosters[1]) + self.assertEqual(rosters[1], rosters[2]) + self.assertTrue(all(set(item) == {"peer_id", "name", "status", "session_id"} for item in rosters[0])) + + def test_read_messages_waits_for_notification_without_polling(self) -> None: + receiver = self.context(self.ids[1]) + sender = self.context(self.ids[0]) + + def delayed_send() -> None: + time.sleep(0.08) + PeerSendMessageTool().run( + {"to": "peer-2", "message": {"ready": True}}, sender + ) + + thread = threading.Thread(target=delayed_send) + thread.start() + started = time.monotonic() + output = PeerReadMessagesTool().run( + {"wait_seconds": 1}, receiver + ).output + elapsed = time.monotonic() - started + thread.join(timeout=1) + self.assertGreaterEqual(elapsed, 0.06) + self.assertLess(elapsed, 0.5) + self.assertEqual(output["messages"][0]["message"], {"ready": True}) + self.assertEqual( + PeerReadMessagesTool().run({}, receiver).output["messages"], [] + ) + + def test_broadcast_tool_returns_per_recipient_deliveries(self) -> None: + output = PeerBroadcastTool().run( + {"message": "hello", "idempotency_key": "hello-1"}, + self.context(self.ids[0]), + ).output + self.assertEqual(set(output["recipients"]), set(self.ids[1:])) + self.assertEqual(len(output["message_ids"]), 2) + self.assertEqual(len(self.store.list_messages(self.run_id)), 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_providers.py b/tests/test_providers.py index 7019817..f8e77a5 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -9,6 +9,7 @@ from src.providers.anthropic_provider import AnthropicProvider from src.providers.glm_provider import GLMProvider from src.providers.openai_provider import OpenAIProvider +from src.providers.qwen_provider import QwenProvider from src.providers.base import ChatMessage, ChatResponse @@ -43,6 +44,7 @@ def test_create_response(self): self.assertEqual(response.model, "gpt-4") self.assertIsNone(response.reasoning_content) + def test_response_with_reasoning(self): """Test response with reasoning content.""" response = ChatResponse( @@ -55,6 +57,21 @@ def test_response_with_reasoning(self): self.assertEqual(response.reasoning_content, "Reasoning process...") +class TestOpenAICompatibleUsage(unittest.TestCase): + def test_cached_prompt_tokens_are_preserved(self): + provider = OpenAIProvider(api_key="test-key") + usage = MagicMock( + prompt_tokens=100, + completion_tokens=20, + total_tokens=120, + prompt_tokens_details={"cached_tokens": 80}, + ) + + actual = provider._build_usage_dict(usage) + + self.assertEqual(actual["cache_read_input_tokens"], 80) + + class TestAnthropicProvider(unittest.TestCase): """Test Anthropic provider.""" @@ -359,6 +376,169 @@ def test_chat_with_reasoning(self, mock_zhipu): self.assertEqual(response.reasoning_content, "Thinking...") +class TestQwenProvider(unittest.TestCase): + def test_tencent_defaults(self): + provider = QwenProvider(api_key="test-token") + self.assertEqual(provider.model, "ms-mnhdj86z") + self.assertTrue(provider.base_url.endswith("/ms-mnhdj86z/v1")) + + @patch("src.providers.qwen_provider.OpenAI") + def test_client_uses_tione_authorization_value(self, mock_openai): + provider = QwenProvider(api_key="test-token", routing_key="agent-123") + _ = provider.client + + mock_openai.assert_called_once_with( + api_key="test-token", + base_url=provider.base_url, + default_headers={ + "Authorization": "test-token", + "X-Clawd-Route-Key": "agent-123", + }, + ) + + @patch("src.providers.qwen_provider.OpenAI") + def test_routing_key_is_stable_per_provider_and_distinct_between_agents( + self, mock_openai + ): + first = QwenProvider(api_key="test-token") + second = QwenProvider(api_key="test-token") + + _ = first.client + _ = first.client + _ = second.client + + self.assertNotEqual(first.routing_key, second.routing_key) + self.assertEqual(len(first.routing_key), 32) + self.assertEqual(mock_openai.call_count, 2) + self.assertEqual( + mock_openai.call_args_list[0].kwargs["default_headers"][ + "X-Clawd-Route-Key" + ], + first.routing_key, + ) + + @patch.dict("os.environ", {"QWEN_ROUTING_KEY": "fixed-rollout"}) + def test_routing_key_can_be_overridden_from_environment(self): + provider = QwenProvider(api_key="test-token") + + self.assertEqual(provider.routing_key, "fixed-rollout") + + @patch("src.providers.qwen_provider.OpenAI") + def test_chat_disables_thinking_by_default(self, mock_openai): + mock_client = MagicMock() + response = MagicMock() + response.choices = [MagicMock()] + response.choices[0].message.content = "QWEN_OK" + response.choices[0].message.reasoning_content = None + response.choices[0].message.tool_calls = None + response.choices[0].finish_reason = "stop" + response.model = "ms-mnhdj86z" + response.usage = None + mock_client.chat.completions.create.return_value = response + mock_openai.return_value = mock_client + + provider = QwenProvider(api_key="test-token") + actual = provider.chat([ChatMessage(role="user", content="Hi")]) + + self.assertEqual(actual.content, "QWEN_OK") + request = mock_client.chat.completions.create.call_args.kwargs + self.assertEqual( + request["extra_body"], + {"chat_template_kwargs": {"enable_thinking": False}}, + ) + + @patch.dict("os.environ", {"QWEN_ENABLE_THINKING": "1"}) + @patch("src.providers.qwen_provider.OpenAI") + def test_chat_can_enable_thinking_from_environment(self, mock_openai): + mock_client = MagicMock() + response = MagicMock() + response.choices = [MagicMock()] + response.choices[0].message.content = "QWEN_OK" + response.choices[0].message.reasoning_content = "thinking" + response.choices[0].message.tool_calls = None + response.choices[0].finish_reason = "stop" + response.model = "ms-rns547kc" + response.usage = None + mock_client.chat.completions.create.return_value = response + mock_openai.return_value = mock_client + + provider = QwenProvider(api_key="test-token") + provider.chat([ChatMessage(role="user", content="Hi")]) + + request = mock_client.chat.completions.create.call_args.kwargs + self.assertEqual( + request["extra_body"], + {"chat_template_kwargs": {"enable_thinking": True}}, + ) + + @patch.dict("os.environ", {"QWEN_ENABLE_THINKING": "0"}) + @patch("src.providers.qwen_provider.OpenAI") + def test_explicit_thinking_setting_is_isolated_from_process_environment( + self, mock_openai + ): + mock_client = MagicMock() + response = MagicMock() + response.choices = [MagicMock()] + response.choices[0].message.content = "QWEN_OK" + response.choices[0].message.reasoning_content = "thinking" + response.choices[0].message.tool_calls = None + response.choices[0].finish_reason = "stop" + response.model = "ms-rns547kc" + response.usage = None + mock_client.chat.completions.create.return_value = response + mock_openai.return_value = mock_client + + provider = QwenProvider(api_key="test-token", enable_thinking=True) + provider.chat([ChatMessage(role="user", content="Hi")]) + + request = mock_client.chat.completions.create.call_args.kwargs + self.assertEqual( + request["extra_body"], + {"chat_template_kwargs": {"enable_thinking": True}}, + ) + + @patch("src.providers.qwen_provider.OpenAI") + def test_stream_requests_and_collects_terminal_usage_chunk(self, mock_openai): + mock_client = MagicMock() + + content_chunk = MagicMock() + content_chunk.model = "ms-rns547kc" + content_chunk.usage = None + content_chunk.choices = [MagicMock()] + content_chunk.choices[0].finish_reason = "stop" + content_chunk.choices[0].delta.content = "QWEN_OK" + content_chunk.choices[0].delta.reasoning_content = None + content_chunk.choices[0].delta.tool_calls = [] + + usage_chunk = MagicMock() + usage_chunk.model = "ms-rns547kc" + usage_chunk.usage = MagicMock( + prompt_tokens=120, + completion_tokens=8, + total_tokens=128, + prompt_tokens_details={"cached_tokens": 100}, + ) + usage_chunk.choices = [] + + mock_client.chat.completions.create.return_value = iter( + [content_chunk, usage_chunk] + ) + mock_openai.return_value = mock_client + + provider = QwenProvider(api_key="test-token") + actual = provider.chat_stream_response( + [ChatMessage(role="user", content="Hi")] + ) + + request = mock_client.chat.completions.create.call_args.kwargs + self.assertEqual(request["stream_options"], {"include_usage": True}) + self.assertEqual(actual.content, "QWEN_OK") + self.assertEqual(actual.usage["input_tokens"], 120) + self.assertEqual(actual.usage["output_tokens"], 8) + self.assertEqual(actual.usage["total_tokens"], 128) + self.assertEqual(actual.usage["cache_read_input_tokens"], 100) + + class TestGetProviderClass(unittest.TestCase): """Test get_provider_class function.""" @@ -377,6 +557,10 @@ def test_get_glm_provider(self): cls = get_provider_class("glm") self.assertEqual(cls, GLMProvider) + def test_get_qwen_provider_and_alias(self): + self.assertEqual(get_provider_class("qwen"), QwenProvider) + self.assertEqual(get_provider_class("qwen3.5"), QwenProvider) + def test_get_unknown_provider(self): """Test getting unknown provider.""" with self.assertRaises(ValueError) as context: diff --git a/tests/test_remote_workspace.py b/tests/test_remote_workspace.py new file mode 100644 index 0000000..fe5bf8a --- /dev/null +++ b/tests/test_remote_workspace.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +import base64 +import json +import os +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from typing import Any + +from src.execution.backend import CommandOutcome, RemoteStat +from src.tool_system.context import ToolContext +from src.tool_system.defaults import build_default_registry +from src.tool_system.protocol import ToolCall + + +class FakeRemoteBackend: + workspace_root = "/workspace" + sandbox_id = "fake-sandbox" + + def __init__(self, root: Path) -> None: + self.root = root + + def _local(self, path: str) -> Path: + if path == self.workspace_root: + return self.root + if not path.startswith(self.workspace_root + "/"): + raise ValueError(f"outside workspace: {path}") + return self.root / path[len(self.workspace_root) + 1 :] + + def _remote(self, path: str) -> str: + local = str(self.root) + return self.workspace_root + path[len(local) :].replace(os.sep, "/") + + def resolve_path(self, path: str, *, cwd: str, local_root: Path) -> str: + local_alias = str(local_root.resolve()) + if path.startswith("/") and not path.startswith(self.workspace_root): + path = str(Path(path).resolve()) + if path == local_alias or path.startswith(local_alias + os.sep): + path = self.workspace_root + path[len(local_alias) :].replace(os.sep, "/") + if not path.startswith("/"): + path = str(Path(cwd) / path) + normalized = os.path.normpath(path) + if normalized != self.workspace_root and not normalized.startswith(self.workspace_root + "/"): + raise ValueError(f"path is outside the remote workspace: {path}") + return normalized + + def exec( + self, + command: str, + *, + cwd: str, + timeout_s: int, + env: dict[str, str] | None = None, + ) -> CommandOutcome: + completed = subprocess.run( + ["bash", "-lc", command], + cwd=self._local(cwd), + env={**os.environ, **(env or {})}, + capture_output=True, + text=True, + timeout=timeout_s, + ) + return CommandOutcome(completed.returncode, completed.stdout, completed.stderr) + + def stat(self, path: str) -> RemoteStat: + local = self._local(path) + if not local.exists(): + return RemoteStat(path=path, exists=False) + value = local.stat() + return RemoteStat( + path=path, + exists=True, + is_file=local.is_file(), + is_dir=local.is_dir(), + size=value.st_size, + mtime_ns=value.st_mtime_ns, + ) + + def read_text(self, path: str) -> str: + return self._local(path).read_text(encoding="utf-8", errors="replace") + + def read_bytes(self, path: str) -> bytes: + return self._local(path).read_bytes() + + def write_text(self, path: str, content: str) -> None: + local = self._local(path) + local.parent.mkdir(parents=True, exist_ok=True) + local.write_text(content, encoding="utf-8") + + def run_json_helper( + self, script: str, payload: dict[str, Any], *, timeout_s: int = 120 + ) -> Any: + translated = dict(payload) + if isinstance(translated.get("root"), str): + translated["root"] = str(self._local(translated["root"])) + encoded = base64.b64encode(json.dumps(translated).encode()).decode() + completed = subprocess.run( + [sys.executable, "-c", script, encoded], + capture_output=True, + text=True, + timeout=timeout_s, + check=True, + ) + output = json.loads(completed.stdout) + + def restore(value: Any) -> Any: + if isinstance(value, str) and value.startswith(str(self.root)): + return self._remote(value) + if isinstance(value, list): + return [restore(item) for item in value] + if isinstance(value, dict): + return {key: restore(item) for key, item in value.items()} + return value + + return restore(output) + + def upload_tree(self, local_path: Path, remote_path: str) -> None: + shutil.copytree(local_path, self._local(remote_path), dirs_exist_ok=True) + + def download_tree(self, remote_path: str, local_path: Path) -> None: + shutil.copytree(self._local(remote_path), local_path, dirs_exist_ok=True) + + def close(self) -> None: + pass + + +class TestRemoteWorkspaceTools(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory() + self.control = Path(self.temp.name) / "control" + self.remote = Path(self.temp.name) / "remote" + self.control.mkdir() + self.remote.mkdir() + self.backend = FakeRemoteBackend(self.remote) + self.context = ToolContext(workspace_root=self.control, workspace_backend=self.backend) + self.registry = build_default_registry( + include_user_tools=False, workspace_backend=self.backend + ) + + def tearDown(self) -> None: + self.temp.cleanup() + + def call(self, name: str, payload: dict[str, Any]): + return self.registry.dispatch(ToolCall(name=name, input=payload), self.context) + + def test_remote_read_write_edit_and_host_alias_mapping(self) -> None: + created = self.call( + "Write", + {"file_path": str(self.control / "pkg" / "mod.py"), "content": "VALUE = 1\n"}, + ) + self.assertFalse(created.is_error) + self.assertEqual((self.remote / "pkg" / "mod.py").read_text(), "VALUE = 1\n") + + read = self.call("Read", {"file_path": "/workspace/pkg/mod.py", "limit": 2000}) + self.assertIn("1\tVALUE = 1", read.output["file"]["content"]) + edited = self.call( + "Edit", + { + "file_path": "/workspace/pkg/mod.py", + "old_string": "VALUE = 1", + "new_string": "VALUE = 2", + }, + ) + self.assertFalse(edited.is_error) + self.assertEqual((self.remote / "pkg" / "mod.py").read_text(), "VALUE = 2\n") + + def test_remote_bash_glob_and_grep(self) -> None: + (self.remote / "a.py").write_text("needle = 1\n") + (self.remote / "b.txt").write_text("nothing\n") + bash = self.call("Bash", {"command": "pwd"}) + self.assertEqual(bash.output["stdout"].strip(), str(self.remote.resolve())) + + globbed = self.call("Glob", {"pattern": "**/*.py"}) + self.assertEqual(globbed.output["filenames"], ["/workspace/a.py"]) + grepped = self.call( + "Grep", + {"pattern": "needle", "output_mode": "content", "-n": True}, + ) + self.assertIn("/workspace/a.py:1:needle = 1", grepped.output["content"]) + + def test_remote_paths_cannot_escape_workspace(self) -> None: + with self.assertRaisesRegex(Exception, "outside the remote workspace"): + self.registry.dispatch( + ToolCall(name="Read", input={"file_path": "/etc/passwd"}), self.context + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_repl.py b/tests/test_repl.py index a952c96..9196ec9 100644 --- a/tests/test_repl.py +++ b/tests/test_repl.py @@ -451,6 +451,9 @@ def test_load_nonexistent_session(self): class TestConversation(unittest.TestCase): """Test conversation management.""" + def test_default_max_history(self): + self.assertEqual(Conversation().max_history, 300) + def test_add_message(self): """Test adding messages to conversation.""" conv = Conversation() diff --git a/tests/test_runner.py b/tests/test_runner.py new file mode 100644 index 0000000..e711f29 --- /dev/null +++ b/tests/test_runner.py @@ -0,0 +1,289 @@ +from __future__ import annotations + +import io +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import Mock, patch + +from src.cli import _read_run_prompt, main +from src.runner import run_prompt +from src.tool_system.agent_loop import AgentLoopResult + + +class TestRunner(unittest.TestCase): + def test_run_prompt_builds_isolated_runtime(self) -> None: + provider = Mock(model="configured-model") + provider_class = Mock(return_value=provider) + expected = AgentLoopResult("done", {"input_tokens": 2, "output_tokens": 1}, 1) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp).resolve() + with ( + patch("src.runner.get_default_provider", return_value="anthropic"), + patch( + "src.runner.get_provider_config", + return_value={ + "api_key": "test-key", + "base_url": "https://example.invalid", + "default_model": "configured-model", + }, + ), + patch("src.runner.get_provider_class", return_value=provider_class), + patch("src.runner.run_agent_loop", return_value=expected) as agent_loop, + ): + actual = run_prompt( + " inspect this workspace ", + workspace=root, + teammate_max_turns=77, + max_output_tokens=8192, + ) + + self.assertIs(actual, expected) + provider_class.assert_called_once_with( + api_key="test-key", + base_url="https://example.invalid", + model="configured-model", + ) + call = agent_loop.call_args.kwargs + self.assertEqual(call["conversation"].messages[0].content, "inspect this workspace") + self.assertEqual(call["tool_context"].workspace_root, root) + self.assertIsNotNone(call["tool_context"].teammate_runtime) + self.assertEqual(call["tool_context"].teammate_runtime.max_turns, 77) + self.assertEqual(call["tool_context"].teammate_runtime.max_output_tokens, 8192) + self.assertEqual( + call["tool_context"].teammate_runtime.allowed_models, + {"configured-model"}, + ) + self.assertEqual(call["max_turns"], 100) + self.assertEqual(call["max_output_tokens"], 8192) + + def test_run_prompt_validates_inputs_before_provider_creation(self) -> None: + with self.assertRaisesRegex(ValueError, "prompt must be non-empty"): + run_prompt(" ") + with self.assertRaisesRegex(ValueError, "max_turns"): + run_prompt("task", max_turns=0) + with self.assertRaisesRegex(ValueError, "teammate_max_turns"): + run_prompt("task", teammate_max_turns=0) + with self.assertRaisesRegex(ValueError, "max_output_tokens"): + run_prompt("task", max_output_tokens=0) + with self.assertRaisesRegex(ValueError, "workspace is not a directory"): + run_prompt("task", workspace="/path/that/does/not/exist") + + def test_anthropic_environment_overrides_persisted_config(self) -> None: + provider_class = Mock(return_value=Mock(model="env-model")) + environment = { + "ANTHROPIC_AUTH_TOKEN": "env-token", + "ANTHROPIC_BASE_URL": "https://env.example.invalid", + "ANTHROPIC_MODEL": "env-model", + } + with tempfile.TemporaryDirectory() as tmp: + with ( + patch.dict(os.environ, environment, clear=False), + patch( + "src.runner.get_provider_config", + return_value={ + "api_key": "saved-token", + "base_url": "https://saved.example.invalid", + "default_model": "saved-model", + }, + ), + patch("src.runner.get_provider_class", return_value=provider_class), + patch( + "src.runner.run_agent_loop", + return_value=AgentLoopResult("done", None, 1), + ), + ): + run_prompt("task", workspace=tmp, provider_name="anthropic") + + provider_class.assert_called_once_with( + api_key="env-token", + base_url="https://env.example.invalid", + model="env-model", + ) + + def test_qwen_environment_overrides_persisted_config(self) -> None: + provider_class = Mock(return_value=Mock(model="ms-env")) + environment = { + "QWEN_API_KEY": "tione-token", + "QWEN_BASE_URL": "https://qwen.example.invalid/v1", + "QWEN_MODEL": "ms-env", + } + with tempfile.TemporaryDirectory() as tmp: + with ( + patch.dict(os.environ, environment, clear=False), + patch( + "src.runner.get_provider_config", + return_value={ + "api_key": "saved-token", + "base_url": "https://saved.invalid/v1", + "default_model": "ms-saved", + }, + ), + patch("src.runner.get_provider_class", return_value=provider_class), + patch( + "src.runner.run_agent_loop", + return_value=AgentLoopResult("done", None, 1), + ), + ): + run_prompt("task", workspace=tmp, provider_name="qwen") + + provider_class.assert_called_once_with( + api_key="tione-token", + base_url="https://qwen.example.invalid/v1", + model="ms-env", + ) + + def test_explicit_provider_environment_takes_precedence(self) -> None: + provider_class = Mock(return_value=Mock(model="explicit-model")) + with tempfile.TemporaryDirectory() as tmp: + with ( + patch.dict( + os.environ, + { + "QWEN_API_KEY": "process-token", + "QWEN_BASE_URL": "https://process.invalid/v1", + }, + clear=False, + ), + patch( + "src.runner.get_provider_config", + return_value={"default_model": "saved-model"}, + ), + patch("src.runner.get_provider_class", return_value=provider_class), + patch( + "src.runner.run_agent_loop", + return_value=AgentLoopResult("done", None, 1), + ), + ): + run_prompt( + "task", + workspace=tmp, + provider_name="qwen", + provider_env={ + "QWEN_API_KEY": "explicit-token", + "QWEN_BASE_URL": "https://explicit.invalid/v1", + "QWEN_MODEL": "explicit-model", + "QWEN_ENABLE_THINKING": "1", + "QWEN_ROUTING_KEY": "trial-route", + }, + ) + + provider_class.assert_called_once_with( + api_key="explicit-token", + base_url="https://explicit.invalid/v1", + model="explicit-model", + enable_thinking=True, + routing_key="trial-route", + ) + + def test_solo_runtime_removes_all_collaboration_tools(self) -> None: + provider = Mock(model="configured-model") + with tempfile.TemporaryDirectory() as tmp: + with ( + patch("src.runner.get_default_provider", return_value="anthropic"), + patch( + "src.runner.get_provider_config", + return_value={"api_key": "test-key", "default_model": "model"}, + ), + patch( + "src.runner.get_provider_class", + return_value=Mock(return_value=provider), + ), + patch( + "src.runner.run_agent_loop", + return_value=AgentLoopResult("done", None, 1), + ) as agent_loop, + ): + run_prompt( + "task", + workspace=tmp, + include_team_tools=False, + ) + + registry = agent_loop.call_args.kwargs["tool_registry"] + names = {spec.name for spec in registry.list_specs()} + self.assertTrue({"Bash", "Read", "Write"}.issubset(names)) + self.assertTrue( + { + "Agent", + "TaskCreate", + "TeamCreate", + "TeammateCreate", + "TeamRun", + "SendMessage", + "ReadMessages", + }.isdisjoint(names) + ) + + def test_read_prompt_file_relative_to_workspace(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "TASK.md").write_text("Run the task.\n", encoding="utf-8") + self.assertEqual(_read_run_prompt(None, Path("TASK.md"), root), "Run the task.\n") + + def test_read_prompt_from_stdin(self) -> None: + stdin = io.StringIO("Piped task\n") + with patch("src.cli.sys.stdin", stdin): + self.assertEqual(_read_run_prompt(None, None, Path(".")), "Piped task\n") + + def test_cli_dispatches_run_command(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + task = root / "TASK.md" + task.write_text("Do it.\n", encoding="utf-8") + argv = [ + "clawd", + "run", + "--workspace", + str(root), + "--prompt-file", + "TASK.md", + "--max-turns", + "42", + "--quiet", + ] + with patch("src.cli.sys.argv", argv), patch("src.cli.run_once", return_value=0) as once: + self.assertEqual(main(), 0) + + once.assert_called_once_with( + "Do it.\n", + workspace=root, + provider_name=None, + model=None, + max_turns=42, + stream=False, + quiet=True, + ) + + def test_cli_dispatches_team_stop_command(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + argv = [ + "clawd", + "team", + "stop", + "coder", + "--workspace", + str(root), + "--task-policy", + "cancel", + "--reason", + "replace worker", + ] + with patch("src.cli.sys.argv", argv), patch( + "src.cli.handle_team_command", return_value=0 + ) as team_command: + self.assertEqual(main(), 0) + + args = team_command.call_args.args[0] + self.assertEqual(args.team_command, "stop") + self.assertEqual(args.teammate, "coder") + self.assertEqual(args.task_policy, "cancel") + self.assertEqual(args.reason, "replace worker") + self.assertEqual(args.workspace, root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_task_ownership.py b/tests/test_task_ownership.py new file mode 100644 index 0000000..5678d28 --- /dev/null +++ b/tests/test_task_ownership.py @@ -0,0 +1,1190 @@ +from __future__ import annotations + +import hashlib +import os +import posixpath +import shutil +import subprocess +import tempfile +import threading +import time +import unittest +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +from src.execution.backend import CommandOutcome, RemoteStat +from src.providers.base import ChatResponse +from src.teammate.models import AgentRecord, TeamTask +from src.teammate.runtime import TeammateRuntime +from src.tool_system.context import ToolContext +from src.tool_system.defaults import build_default_registry +from src.tool_system.errors import ToolPermissionError +from src.tool_system.remote_tools import ( + RemoteBashTool, + RemoteFileEditTool, + RemoteFileWriteTool, +) +from src.tool_system.tools import BashTool, FileEditTool, FileWriteTool +from src.tool_system.tools import TeamCreateTool, TeamPlanTool, TeamRunTool + + +def _v2_context( + root: Path, + task_id: str = "task-one", + *, + owner: str = "worker-one", + owned_files: list[str] | None = None, + actor_role: str | None = None, + task_metadata: dict | None = None, +) -> ToolContext: + context = ToolContext(workspace_root=root) + team = context.team_store.load_active_team() + if team is None: + team = context.team_store.create_team("ownership") + team.protocol_version = 2 + team.settings["protocol_version"] = 2 + team.settings["quality_gates"] = { + "strict": True, + "protocol_version": 2, + } + team.set_lifecycle_state("running") + context.team_store.save_team(team) + tasks = context.team_store.load_tasks(team.team_id) + tasks[task_id] = TeamTask( + id=task_id, + key=task_id, + subject=task_id, + description="ownership test", + owner=owner, + owned_files=owned_files or ["owned"], + metadata=task_metadata or {}, + ).to_dict() + context.team_store.save_tasks(team.team_id, tasks) + if actor_role is not None: + context.team_store.save_agent( + AgentRecord( + agent_id=owner, + team_id=team.team_id, + name=owner, + role=actor_role, + session_id=f"session-{owner}", + ) + ) + context.reload_team_state() + context.actor_id = owner + context.current_task_id = task_id + return context + + +class LocalOwnershipToolsTests(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name).resolve() + self.context = _v2_context(self.root) + + def tearDown(self) -> None: + self.tmp.cleanup() + + def test_write_and_edit_allow_owned_path(self) -> None: + target = self.root / "owned" / "module.py" + written = FileWriteTool().run( + {"file_path": str(target), "content": "VALUE = 1\n"}, self.context + ) + self.assertFalse(written.is_error) + + edited = FileEditTool().run( + { + "file_path": str(target), + "old_string": "VALUE = 1", + "new_string": "VALUE = 2", + }, + self.context, + ) + self.assertFalse(edited.is_error) + self.assertEqual(target.read_text(encoding="utf-8"), "VALUE = 2\n") + self.assertEqual(self.context.ownership_violations, []) + + def test_write_and_edit_reject_other_tasks_path(self) -> None: + other = self.root / "other" / "module.py" + other.parent.mkdir() + other.write_text("VALUE = 1\n", encoding="utf-8") + self.context.mark_file_read(other) + + with self.assertRaisesRegex(ToolPermissionError, "ownership violation"): + FileWriteTool().run( + {"file_path": str(other), "content": "VALUE = 2\n"}, + self.context, + ) + with self.assertRaisesRegex(ToolPermissionError, "ownership violation"): + FileEditTool().run( + { + "file_path": str(other), + "old_string": "VALUE = 1", + "new_string": "VALUE = 2", + }, + self.context, + ) + self.assertEqual(other.read_text(encoding="utf-8"), "VALUE = 1\n") + self.assertEqual(len(self.context.ownership_violations), 2) + + def test_workspace_prefixed_owned_path_is_normalized(self) -> None: + context = _v2_context( + self.root, + "workspace-prefix", + owned_files=["/workspace/pkg"], + ) + + result = FileWriteTool().run( + { + "file_path": str(self.root / "pkg" / "module.py"), + "content": "VALUE = 1\n", + }, + context, + ) + + self.assertFalse(result.is_error) + self.assertEqual(context.ownership_violations, []) + + def test_only_explicit_integrator_gets_shared_integration_paths(self) -> None: + initializer = self.root / "package" / "__init__.py" + manifest = self.root / "pyproject.toml" + with self.assertRaisesRegex(ToolPermissionError, "lead/integrator"): + FileWriteTool().run( + {"file_path": str(initializer), "content": "VALUE = 1\n"}, + self.context, + ) + + integrator = _v2_context( + self.root, + "integration-task", + owner="integration-worker", + owned_files=["integration-notes.txt"], + actor_role="integrator", + ) + for path, content in ( + (initializer, "VALUE = 2\n"), + (manifest, "[project]\nname = 'sample'\n"), + ): + result = FileWriteTool().run( + {"file_path": str(path), "content": content}, integrator + ) + self.assertFalse(result.is_error) + + # Integration authority is narrow; it is not permission to take another + # worker's ordinary delivery file. + with self.assertRaisesRegex(ToolPermissionError, "ownership violation"): + FileWriteTool().run( + { + "file_path": str(self.root / "other" / "module.py"), + "content": "STOLEN = True\n", + }, + integrator, + ) + + def test_lead_identity_has_explicit_integration_authority(self) -> None: + lead = _v2_context( + self.root, + "lead-integration", + owner="worker-two", + owned_files=["worker-two.py"], + ) + lead.actor_id = str(lead.team["lead_agent_id"]) + + result = FileWriteTool().run( + { + "file_path": str(self.root / "package.json"), + "content": '{"name": "sample"}\n', + }, + lead, + ) + + self.assertFalse(result.is_error) + + def test_runtime_artifacts_do_not_create_false_bash_conflicts(self) -> None: + result = BashTool().run( + { + "command": " && ".join( + [ + "mkdir -p .cache/tool htmlcov", + "printf cache > .cache/tool/result.json", + "printf coverage > .coverage.worker-one", + "printf xml > coverage.xml", + "printf db > application.sqlite", + "printf wal > application.sqlite-wal", + "printf log > test-run.log", + "printf backup > source.py.bak", + "printf temp > output.tmp", + "printf html > htmlcov/index.html", + ] + ) + }, + self.context, + ) + + self.assertFalse(result.is_error) + self.assertEqual(self.context.ownership_violations, []) + + with self.assertRaisesRegex(ToolPermissionError, "ownership violation"): + BashTool().run( + { + "command": ( + "printf updated >> application.sqlite && " + "mkdir -p other && printf bad > other/delivery.py" + ) + }, + self.context, + ) + self.assertEqual( + self.context.ownership_violations[-1]["paths"], + ["other/delivery.py"], + ) + + def test_runtime_and_test_exemptions_do_not_override_other_task_delivery(self) -> None: + _v2_context( + self.root, + "artifact-owner", + owner="worker-two", + owned_files=["fixtures/shared.sqlite", "tests/test_contract.py"], + ) + self.context.reload_team_state() + + with self.assertRaisesRegex(ToolPermissionError, "ownership violation"): + BashTool().run( + { + "command": ( + "mkdir -p fixtures && " + "printf db > fixtures/shared.sqlite" + ) + }, + self.context, + ) + self.assertEqual( + self.context.ownership_violations[-1]["paths"], + ["fixtures/shared.sqlite"], + ) + + with self.assertRaisesRegex(ToolPermissionError, "ownership violation"): + FileWriteTool().run( + { + "file_path": str(self.root / "tests" / "test_contract.py"), + "content": "assert True\n", + }, + self.context, + ) + + def test_new_test_module_is_scratch_but_existing_project_test_is_protected(self) -> None: + tests = self.root / "tests" + tests.mkdir() + existing = tests / "test_existing.py" + existing.write_text("VALUE = 1\n", encoding="utf-8") + self.context.mark_file_read(existing) + + with self.assertRaisesRegex(ToolPermissionError, "ownership violation"): + FileEditTool().run( + { + "file_path": str(existing), + "old_string": "VALUE = 1", + "new_string": "VALUE = 2", + }, + self.context, + ) + self.assertEqual(existing.read_text(encoding="utf-8"), "VALUE = 1\n") + + generated = tests / "test_generated_probe.py" + created = FileWriteTool().run( + { + "file_path": str(generated), + "content": "VALUE = 1\n", + }, + self.context, + ) + self.assertFalse(created.is_error) + edited = FileEditTool().run( + { + "file_path": str(generated), + "old_string": "VALUE = 1", + "new_string": "VALUE = 2", + }, + self.context, + ) + self.assertFalse(edited.is_error) + + created_by_bash = BashTool().run( + { + "command": "printf 'VALUE = 3\\n' > tests/test_bash_probe.py", + }, + self.context, + ) + self.assertFalse(created_by_bash.is_error) + updated_by_bash = BashTool().run( + { + "command": "printf 'VALUE = 4\\n' > tests/test_bash_probe.py", + }, + self.context, + ) + self.assertFalse(updated_by_bash.is_error) + + with self.assertRaisesRegex(ToolPermissionError, "ownership violation"): + BashTool().run( + {"command": "printf 'VALUE = 9\\n' > tests/test_existing.py"}, + self.context, + ) + self.assertEqual(existing.read_text(encoding="utf-8"), "VALUE = 1\n") + + def test_bash_audits_legal_and_illegal_workspace_changes(self) -> None: + legal = BashTool().run( + {"command": "mkdir -p owned && printf 'ok' > owned/generated.txt"}, + self.context, + ) + self.assertFalse(legal.is_error) + self.assertEqual(self.context.ownership_violations, []) + + with self.assertRaisesRegex(ToolPermissionError, "ownership violation"): + BashTool().run( + {"command": "mkdir -p other && printf 'bad' > other/generated.txt"}, + self.context, + ) + self.assertEqual( + self.context.ownership_violations[-1]["paths"], + ["other/generated.txt"], + ) + + def test_bash_restores_unauthorized_source_overwrite_delete_and_create(self) -> None: + other = self.root / "other" + other.mkdir() + overwritten = other / "overwritten.py" + deleted = other / "deleted.py" + created = other / "created.py" + overwritten.write_text("ORIGINAL = 1\n", encoding="utf-8") + deleted.write_text("KEEP = True\n", encoding="utf-8") + + with self.assertRaisesRegex(ToolPermissionError, "ownership violation"): + BashTool().run( + { + "command": " && ".join( + [ + "printf 'HACKED = 1\\n' > other/overwritten.py", + "rm other/deleted.py", + "printf 'NEW = 1\\n' > other/created.py", + "mkdir -p owned", + "printf 'legal\\n' > owned/retained.txt", + ] + ) + }, + self.context, + ) + + self.assertEqual( + overwritten.read_text(encoding="utf-8"), "ORIGINAL = 1\n" + ) + self.assertEqual(deleted.read_text(encoding="utf-8"), "KEEP = True\n") + self.assertFalse(created.exists()) + self.assertEqual( + (self.root / "owned" / "retained.txt").read_text(encoding="utf-8"), + "legal\n", + ) + + def test_bash_restores_clawd_control_state_before_reporting_tampering(self) -> None: + team_id = str(self.context.team["team_id"]) + active = self.root / ".clawd" / "team.json" + team = self.root / ".clawd" / "teams" / team_id / "team.json" + tasks = self.root / ".clawd" / "teams" / team_id / "tasks.json" + events = self.root / ".clawd" / "teams" / team_id / "events.jsonl" + before = { + active: active.read_bytes(), + team: team.read_bytes(), + tasks: tasks.read_bytes(), + events: events.read_bytes(), + } + + command = " && ".join( + [ + "mkdir -p .clawd/task-tests/task-one", + "printf 'assert True\\n' > .clawd/task-tests/task-one/test_kept.py", + f"printf 'forged-active\\n' > {active.relative_to(self.root)}", + f"printf 'forged-team\\n' > {team.relative_to(self.root)}", + f"printf 'forged-tasks\\n' > {tasks.relative_to(self.root)}", + f"printf 'forged-event\\n' >> {events.relative_to(self.root)}", + ] + ) + with self.assertRaisesRegex(ToolPermissionError, "ownership violation"): + BashTool().run({"command": command}, self.context) + + self.assertEqual(active.read_bytes(), before[active]) + self.assertEqual(team.read_bytes(), before[team]) + self.assertEqual(tasks.read_bytes(), before[tasks]) + # Restore happens before the harness appends its genuine ownership event. + self.assertTrue(events.read_bytes().startswith(before[events])) + self.assertNotIn(b"forged-event", events.read_bytes()) + self.assertEqual( + ( + self.root + / ".clawd/task-tests/task-one/test_kept.py" + ).read_text(encoding="utf-8"), + "assert True\n", + ) + changed = set(self.context.ownership_violations[-1]["paths"]) + self.assertTrue( + { + ".clawd/team.json", + f".clawd/teams/{team_id}/team.json", + f".clawd/teams/{team_id}/tasks.json", + f".clawd/teams/{team_id}/events.jsonl", + }.issubset(changed) + ) + + def test_harness_event_waits_for_local_bash_audit_without_false_positive(self) -> None: + team_id = str(self.context.team["team_id"]) + started = self.root / "owned" / "bash-started" + + def append_harness_event() -> None: + deadline = time.monotonic() + 5 + while not started.exists(): + if time.monotonic() >= deadline: + raise TimeoutError("Bash command did not start") + time.sleep(0.01) + self.context.team_store.append_event( + team_id, "test.legitimate_harness_write", {"ok": True} + ) + + with ThreadPoolExecutor(max_workers=1) as pool: + writer = pool.submit(append_harness_event) + result = BashTool().run( + { + "command": ( + "mkdir -p owned && touch owned/bash-started && " + "sleep 0.15 && printf ok > owned/result.txt" + ) + }, + self.context, + ) + writer.result(timeout=5) + + self.assertFalse(result.is_error) + self.assertEqual(self.context.ownership_violations, []) + events = self.context.team_store.list_events(team_id) + self.assertIn( + "test.legitimate_harness_write", {event["type"] for event in events} + ) + + def test_disposable_tests_are_isolated_in_task_private_scratch(self) -> None: + scratch = self.root / ".clawd" / "task-tests" / "task-one" + written = FileWriteTool().run( + { + "file_path": str(scratch / "test_generated.py"), + "content": "def test_generated():\n assert True\n", + }, + self.context, + ) + self.assertFalse(written.is_error) + + bash_result = BashTool().run( + { + "command": ( + "mkdir -p .clawd/task-tests/task-one && " + "printf 'assert 1 == 1\\n' > " + ".clawd/task-tests/task-one/test_from_bash.py" + ) + }, + self.context, + ) + self.assertFalse(bash_result.is_error) + + with self.assertRaisesRegex(ToolPermissionError, "ownership violation"): + BashTool().run( + { + "command": ( + "mkdir -p .clawd/task-tests/task-two && " + "printf 'assert False\\n' > " + ".clawd/task-tests/task-two/test_stolen.py" + ) + }, + self.context, + ) + with self.assertRaisesRegex(ToolPermissionError, "ownership violation"): + FileWriteTool().run( + { + "file_path": str(self.root / "test_undeclared.py"), + "content": "assert True\n", + }, + self.context, + ) + + def test_two_workers_bash_changes_are_attributed_without_false_conflicts(self) -> None: + first = self.context + second = _v2_context( + self.root, + "task-two", + owner="worker-two", + owned_files=["second"], + ) + # This is what TeammateRuntime._child_context does for real workers. + second.mutation_lock = first.mutation_lock + barrier = threading.Barrier(2) + + def write(context: ToolContext, directory: str) -> None: + barrier.wait(timeout=2) + BashTool().run( + { + "command": ( + f"mkdir -p {directory} && sleep 0.05 && " + f"printf '{directory}' > {directory}/result.txt" + ) + }, + context, + ) + + with ThreadPoolExecutor(max_workers=2) as pool: + futures = [ + pool.submit(write, first, "owned"), + pool.submit(write, second, "second"), + ] + for future in futures: + future.result(timeout=5) + + self.assertEqual(first.ownership_violations, []) + self.assertEqual(second.ownership_violations, []) + self.assertEqual((self.root / "owned/result.txt").read_text(), "owned") + self.assertEqual((self.root / "second/result.txt").read_text(), "second") + + def test_v1_teammate_remains_compatible(self) -> None: + team = self.context.team_store.load_active_team() + self.assertIsNotNone(team) + team.protocol_version = 1 + team.settings["protocol_version"] = 1 + team.settings["quality_gates"] = {"strict": True, "protocol_version": 1} + self.context.team_store.save_team(team) + self.context.reload_team_state() + + outside = self.root / "legacy.py" + result = FileWriteTool().run( + {"file_path": str(outside), "content": "legacy = True\n"}, self.context + ) + self.assertFalse(result.is_error) + self.assertTrue(outside.exists()) + + +class _LocalRemoteBackend: + workspace_root = "/workspace" + sandbox_id = "fake-ags" + + def __init__(self, root: Path) -> None: + self.root = root + self.exec_commands: list[str] = [] + + def _local(self, path: str) -> Path: + relative = posixpath.relpath(path, self.workspace_root) + return (self.root / relative).resolve() + + def resolve_path(self, path: str, *, cwd: str, local_root: Path) -> str: + if path.startswith(str(local_root)): + path = self.workspace_root + path[len(str(local_root)) :] + if not path.startswith("/"): + path = posixpath.join(cwd, path) + resolved = posixpath.normpath(path) + if resolved != self.workspace_root and not resolved.startswith( + self.workspace_root + "/" + ): + raise ValueError("outside remote workspace") + return resolved + + def exec(self, command: str, *, cwd: str, timeout_s: int, env=None) -> CommandOutcome: + self.exec_commands.append(command) + command = command.replace(self.workspace_root, str(self.root)) + completed = subprocess.run( + ["bash", "-lc", command], + cwd=self._local(cwd), + capture_output=True, + text=True, + timeout=timeout_s, + ) + return CommandOutcome(completed.returncode, completed.stdout, completed.stderr) + + def stat(self, path: str) -> RemoteStat: + local = self._local(path) + exists = local.exists() + stat = local.stat() if exists else None + return RemoteStat( + path=path, + exists=exists, + is_file=local.is_file(), + is_dir=local.is_dir(), + size=stat.st_size if stat else 0, + mtime_ns=stat.st_mtime_ns if stat else 0, + ) + + def read_text(self, path: str) -> str: + return self._local(path).read_text(encoding="utf-8") + + def read_bytes(self, path: str) -> bytes: + return self._local(path).read_bytes() + + def write_text(self, path: str, content: str) -> None: + local = self._local(path) + local.parent.mkdir(parents=True, exist_ok=True) + local.write_text(content, encoding="utf-8") + + def run_json_helper(self, script: str, payload: dict, *, timeout_s: int = 120): + operation = payload.get("operation") + if operation == "capture_workspace_backup": + backup = Path(payload["backup"]) + if backup.is_symlink() or backup.is_file(): + backup.unlink(missing_ok=True) + elif backup.is_dir(): + shutil.rmtree(backup) + + ignored = set(payload["ignored_dirs"]) + + def ignore(_directory: str, names: list[str]) -> set[str]: + return { + name + for name in names + if name in ignored + or name.endswith(".egg-info") + or ( + not (Path(_directory) / name).is_symlink() + and not (Path(_directory) / name).is_dir() + and not (Path(_directory) / name).is_file() + ) + } + + if payload["full_workspace"]: + shutil.copytree(self.root, backup, symlinks=True, ignore=ignore) + else: + backup.mkdir(parents=True) + source = self.root / ".clawd" + if source.exists(): + shutil.copytree(source, backup / ".clawd", symlinks=True) + return {"captured": True} + + if operation == "restore_workspace_backup": + paths = sorted( + set(payload["paths"]), + key=lambda value: value.count("/"), + ) + for relative in paths: + target = self.root / relative + if target.is_symlink() or target.is_file(): + target.unlink(missing_ok=True) + elif target.is_dir(): + shutil.rmtree(target) + for relative in paths: + source = Path(payload["backup"]) / relative + target = self.root / relative + if not (source.exists() or source.is_symlink()): + continue + target.parent.mkdir(parents=True, exist_ok=True) + if source.is_symlink(): + target.symlink_to(os.readlink(source)) + elif source.is_dir(): + target.mkdir(exist_ok=True) + else: + shutil.copy2(source, target, follow_symlinks=False) + return {"restored": len(paths)} + + # Ownership snapshots are the only helper used by these tests. Mirror the + # AGS helper's result while mapping /workspace to this temporary directory. + output: dict[str, str] = {} + ignored = set(payload["ignored_dirs"]) + declared_paths = tuple(payload["declared_paths"]) + scan_root = ( + self.root / ".clawd" if payload["control_only"] else self.root + ) + + def declared(relative: str) -> bool: + return any( + relative == owned + or relative.startswith(owned + "/") + or owned.startswith(relative + "/") + for owned in declared_paths + ) + + for directory, names, files in os.walk(scan_root): + names[:] = [ + name + for name in names + if ( + name not in ignored and not name.endswith(".egg-info") + ) + or declared( + (Path(directory) / name) + .relative_to(self.root) + .as_posix() + ) + ] + for name in files: + path = Path(directory) / name + relative = path.relative_to(self.root).as_posix() + ignored_parent = any( + part in ignored or part.endswith(".egg-info") + for part in Path(relative).parts[:-1] + ) + ignored_file = ( + name in payload["ignored_files"] + or any( + name.startswith(prefix) + for prefix in payload["ignored_prefixes"] + ) + or any( + name.endswith(suffix) + for suffix in payload["ignored_suffixes"] + ) + ) + if ( + ignored_parent or ignored_file + ) and not declared(relative): + continue + if not path.is_file(): + continue + digest = hashlib.sha256(path.read_bytes()).hexdigest() + output[relative] = f"file:{path.stat().st_mode & 0o777:o}:{digest}" + return output + + +class RemoteOwnershipToolsTests(unittest.TestCase): + def test_remote_bash_restores_unauthorized_source_mutations(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary).resolve() + other = root / "other" + other.mkdir() + overwritten = other / "overwritten.py" + deleted = other / "deleted.py" + created = other / "created.py" + overwritten.write_text("ORIGINAL = 1\n", encoding="utf-8") + deleted.write_text("KEEP = True\n", encoding="utf-8") + context = _v2_context(root, owned_files=["/workspace/owned"]) + context.workspace_backend = _LocalRemoteBackend(root) + context.execution_workspace_root = "/workspace" + context.execution_cwd = "/workspace" + + with self.assertRaisesRegex(ToolPermissionError, "ownership violation"): + RemoteBashTool().run( + { + "command": " && ".join( + [ + "printf 'HACKED = 1\\n' > other/overwritten.py", + "rm other/deleted.py", + "printf 'NEW = 1\\n' > other/created.py", + "mkdir -p owned", + "printf 'legal\\n' > owned/retained.txt", + ] + ) + }, + context, + ) + + self.assertEqual( + overwritten.read_text(encoding="utf-8"), "ORIGINAL = 1\n" + ) + self.assertEqual( + deleted.read_text(encoding="utf-8"), "KEEP = True\n" + ) + self.assertFalse(created.exists()) + self.assertEqual( + (root / "owned" / "retained.txt").read_text(encoding="utf-8"), + "legal\n", + ) + + def test_remote_strict_v2_rejects_recursive_delivery_delete_before_exec(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary).resolve() + package = root / "pkg" + package.mkdir() + (package / "module.py").write_text("VALUE = 1\n", encoding="utf-8") + context = _v2_context(root, owned_files=["/workspace/pkg"]) + backend = _LocalRemoteBackend(root) + context.workspace_backend = backend + context.execution_workspace_root = "/workspace" + context.execution_cwd = "/workspace" + + commands = [ + "rm -rf /workspace/pkg", + "sh -c 'rm -rf /workspace/pkg'", + "find /workspace/pkg -depth -delete", + "git -C /workspace clean -fdx", + ( + "python -c \"import shutil; " + "shutil.rmtree('/workspace/pkg')\"" + ), + ] + for command in commands: + with self.subTest(command=command): + with self.assertRaisesRegex( + ToolPermissionError, + "refuses recursive deletion of deliverable path", + ): + RemoteBashTool().run({"command": command}, context) + + self.assertEqual(backend.exec_commands, []) + self.assertTrue((package / "module.py").exists()) + + def test_remote_strict_guard_ignores_heredoc_source_text(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary).resolve() + context = _v2_context(root, owned_files=["/workspace/owned"]) + context.workspace_backend = _LocalRemoteBackend(root) + context.execution_workspace_root = "/workspace" + context.execution_cwd = "/workspace" + + result = RemoteBashTool().run( + { + "command": ( + "mkdir -p owned && cat > owned/cleanup.py <<'PY'\n" + "rm -rf /workspace/pkg\n" + "find /workspace/pkg -delete\n" + "shutil.rmtree('/workspace/pkg')\n" + "PY" + ) + }, + context, + ) + + self.assertFalse(result.is_error) + self.assertIn( + "rm -rf ", + (root / "owned" / "cleanup.py").read_text(encoding="utf-8"), + ) + + def test_remote_strict_lead_cannot_mutate_clawd_control_state(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary).resolve() + context = _v2_context(root) + context.actor_id = None + context.current_task_id = None + backend = _LocalRemoteBackend(root) + context.workspace_backend = backend + context.execution_workspace_root = "/workspace" + context.execution_cwd = "/workspace" + state = root / ".clawd" / "lead-state.json" + state.write_text('{"status": "original"}\n', encoding="utf-8") + context.mark_remote_file_read("/workspace/.clawd/lead-state.json") + + with self.assertRaisesRegex( + ToolPermissionError, "protects .clawd control state" + ): + RemoteFileWriteTool().run( + { + "file_path": "/workspace/.clawd/forged.json", + "content": "{}\n", + }, + context, + ) + with self.assertRaisesRegex( + ToolPermissionError, "protects .clawd control state" + ): + RemoteFileEditTool().run( + { + "file_path": "/workspace/.clawd/lead-state.json", + "old_string": "original", + "new_string": "forged", + }, + context, + ) + with self.assertRaisesRegex(ToolPermissionError, "ownership violation"): + RemoteBashTool().run( + { + "command": ( + "printf '{\\\"status\\\": \\\"forged\\\"}\\n' " + "> /workspace/.clawd/lead-state.json" + ) + }, + context, + ) + + self.assertFalse((root / ".clawd" / "forged.json").exists()) + self.assertEqual( + state.read_text(encoding="utf-8"), + '{"status": "original"}\n', + ) + + def test_remote_strict_v2_allows_pytest_cache_cleanup(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary).resolve() + cache = root / ".pytest_cache" + cache.mkdir() + (cache / "state").write_text("generated\n", encoding="utf-8") + context = _v2_context(root) + backend = _LocalRemoteBackend(root) + context.workspace_backend = backend + context.execution_workspace_root = "/workspace" + context.execution_cwd = "/workspace" + + result = RemoteBashTool().run( + {"command": "rm -rf /workspace/.pytest_cache"}, context + ) + + self.assertFalse(result.is_error) + self.assertFalse(cache.exists()) + self.assertIn( + "rm -rf /workspace/.pytest_cache", backend.exec_commands + ) + + def test_remote_write_and_bash_use_the_same_v2_scope(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary).resolve() + context = _v2_context(root, owned_files=["/workspace/owned"]) + context.workspace_backend = _LocalRemoteBackend(root) + context.execution_workspace_root = "/workspace" + context.execution_cwd = "/workspace" + + result = RemoteFileWriteTool().run( + {"file_path": "/workspace/owned/remote.py", "content": "OK = 1\n"}, + context, + ) + self.assertFalse(result.is_error) + with self.assertRaisesRegex(ToolPermissionError, "ownership violation"): + RemoteFileWriteTool().run( + { + "file_path": "/workspace/other/remote.py", + "content": "BAD = 1\n", + }, + context, + ) + with self.assertRaisesRegex(ToolPermissionError, "ownership violation"): + RemoteBashTool().run( + {"command": "mkdir -p other && printf bad > other/bash.txt"}, + context, + ) + + generated = RemoteFileWriteTool().run( + { + "file_path": "/workspace/tests/test_remote_probe.py", + "content": "VALUE = 1\n", + }, + context, + ) + self.assertFalse(generated.is_error) + + existing = root / "tests" / "test_existing.py" + existing.write_text("VALUE = 1\n", encoding="utf-8") + context.mark_remote_file_read("/workspace/tests/test_existing.py") + with self.assertRaisesRegex(ToolPermissionError, "ownership violation"): + RemoteFileWriteTool().run( + { + "file_path": "/workspace/tests/test_existing.py", + "content": "VALUE = 2\n", + }, + context, + ) + + artifacts = RemoteBashTool().run( + { + "command": ( + "mkdir -p .cache/tool htmlcov && " + "printf cache > .cache/tool/result && " + "printf coverage > .coverage.remote && " + "printf db > test.sqlite3 && " + "printf backup > module.py.bak && " + "printf html > htmlcov/index.html" + ) + }, + context, + ) + self.assertFalse(artifacts.is_error) + + scratch = "/workspace/.clawd/task-tests/task-one" + result = RemoteFileWriteTool().run( + { + "file_path": f"{scratch}/test_remote.py", + "content": "assert True\n", + }, + context, + ) + self.assertFalse(result.is_error) + result = RemoteBashTool().run( + { + "command": ( + "mkdir -p .clawd/task-tests/task-one && " + "printf ok > .clawd/task-tests/task-one/test_bash.py" + ) + }, + context, + ) + self.assertFalse(result.is_error) + with self.assertRaisesRegex(ToolPermissionError, "ownership violation"): + RemoteBashTool().run( + { + "command": ( + "mkdir -p .clawd/task-tests/task-two && " + "printf bad > .clawd/task-tests/task-two/test_stolen.py" + ) + }, + context, + ) + + def test_remote_bash_restores_clawd_control_state_after_tampering(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary).resolve() + context = _v2_context(root) + context.workspace_backend = _LocalRemoteBackend(root) + context.execution_workspace_root = "/workspace" + context.execution_cwd = "/workspace" + team_id = str(context.team["team_id"]) + active = root / ".clawd" / "team.json" + tasks = root / ".clawd" / "teams" / team_id / "tasks.json" + events = root / ".clawd" / "teams" / team_id / "events.jsonl" + before = { + active: active.read_bytes(), + tasks: tasks.read_bytes(), + events: events.read_bytes(), + } + + command = " && ".join( + [ + "mkdir -p .clawd/task-tests/task-one", + "printf 'assert True\\n' > .clawd/task-tests/task-one/test_kept.py", + "printf 'forged-active\\n' > .clawd/team.json", + f"printf 'forged-tasks\\n' > .clawd/teams/{team_id}/tasks.json", + f"printf 'forged-event\\n' >> .clawd/teams/{team_id}/events.jsonl", + ] + ) + with self.assertRaisesRegex(ToolPermissionError, "ownership violation"): + RemoteBashTool().run({"command": command}, context) + + self.assertEqual(active.read_bytes(), before[active]) + self.assertEqual(tasks.read_bytes(), before[tasks]) + self.assertTrue(events.read_bytes().startswith(before[events])) + self.assertNotIn(b"forged-event", events.read_bytes()) + self.assertEqual( + ( + root / ".clawd/task-tests/task-one/test_kept.py" + ).read_text(encoding="utf-8"), + "assert True\n", + ) + + +class _OwnershipProvider: + model = "test-model" + + def __init__(self, *, infrastructure_error: bool = False) -> None: + self.infrastructure_error = infrastructure_error + self._bad_write_sent = False + self._lock = threading.Lock() + + def chat(self, messages, tools=None, **kwargs): + if self.infrastructure_error: + raise ConnectionError("service unavailable during teammate rollout") + text = "\n".join(str(message.get("content") or "") for message in messages) + with self._lock: + if "Task key: one" in text and not self._bad_write_sent: + self._bad_write_sent = True + return ChatResponse( + content="", + model=self.model, + usage={"input_tokens": 1, "output_tokens": 1}, + finish_reason="tool_use", + tool_uses=[ + { + "id": "bad-write", + "name": "Write", + "input": { + "file_path": "two.py", + "content": "stolen = True\n", + }, + } + ], + ) + return ChatResponse( + content="worker finished", + model=self.model, + usage={"input_tokens": 1, "output_tokens": 1}, + finish_reason="stop", + tool_uses=None, + ) + + +class RuntimeOwnershipOutcomeTests(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name).resolve() + self.registry = build_default_registry(include_user_tools=False) + self.context = ToolContext(workspace_root=self.root) + TeamCreateTool().run( + {"team_name": "strict-ownership", "quality_gates": True}, self.context + ) + + def tearDown(self) -> None: + self.tmp.cleanup() + + def _plan(self) -> None: + result = TeamPlanTool().run( + { + "mode": "replace", + "contract": {"summary": "disjoint files", "interfaces": []}, + "workers": [ + {"name": "one", "instructions": "Implement one.py."}, + {"name": "two", "instructions": "Implement two.py."}, + ], + "tasks": [ + { + "key": "one", + "owner": "one", + "instructions": "Implement one.py.", + "owned_files": ["one.py"], + "acceptance_checks": [ + "python -c \"from pathlib import Path; assert Path('one.py').exists()\"" + ], + }, + { + "key": "two", + "owner": "two", + "instructions": "Implement two.py.", + "owned_files": ["two.py"], + "acceptance_checks": [ + "python -c \"from pathlib import Path; assert Path('two.py').exists()\"" + ], + }, + ], + "validation": { + "profile": "generic", + "install_command": "true", + "import_command": "python -c \"import pathlib\"", + "integration_command": "python -c \"import pathlib; assert pathlib.Path\"", + }, + }, + self.context, + ) + self.assertFalse(result.is_error, result.output) + + def test_runtime_turns_sticky_violation_into_repair_required(self) -> None: + self._plan() + self.context.teammate_runtime = TeammateRuntime( + _OwnershipProvider(), self.registry + ) + + result = TeamRunTool().run({"max_workers": 2}, self.context) + + self.assertEqual(result.output["status"], "repair_required") + self.assertEqual(result.output["lifecycle_state"], "repair_required") + tasks = self.context.team_store.load_tasks(self.context.team["team_id"]) + violated = next(task for task in tasks.values() if task.get("key") == "one") + self.assertEqual(violated["status"], "failed") + self.assertEqual( + violated["metadata"]["ownership_audit"]["status"], "failed" + ) + self.assertFalse((self.root / "two.py").exists()) + + def test_v2_transport_failure_pauses_instead_of_failing_candidate(self) -> None: + self._plan() + self.context.teammate_runtime = TeammateRuntime( + _OwnershipProvider(infrastructure_error=True), self.registry + ) + + result = TeamRunTool().run({"max_workers": 2}, self.context) + + self.assertEqual(result.output["status"], "paused") + self.assertEqual(result.output["lifecycle_state"], "paused") + self.assertEqual(result.output["failure_domain"], "infrastructure") + self.assertTrue(result.output["retryable"]) + tasks = self.context.team_store.load_tasks(self.context.team["team_id"]) + self.assertEqual({task["status"] for task in tasks.values()}, {"pending"}) + self.assertTrue( + all( + task["metadata"]["infrastructure_failure"]["retryable"] + for task in tasks.values() + ) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_team_plan_v2.py b/tests/test_team_plan_v2.py new file mode 100644 index 0000000..d325d60 --- /dev/null +++ b/tests/test_team_plan_v2.py @@ -0,0 +1,975 @@ +from __future__ import annotations + +import copy +import json +import tempfile +import threading +import time +import unittest +from pathlib import Path +from unittest.mock import patch + +from src.teammate.models import TeamTask +from src.teammate.runtime import TeammateRuntime, TeamRunOptions +from src.teammate.store import TeamStore +from src.providers.base import ChatResponse +from src.tool_system.context import ToolContext +from src.tool_system.defaults import build_default_registry +from src.tool_system.protocol import ToolCall +from src.tool_system.tools import ( + TeamCreateTool, + TeamPlanTool, + TeamReplanTool, + TeamRunTool, +) + + +class ConcurrentFinalProvider: + model = "test-model" + + def __init__(self) -> None: + self.barrier = threading.Barrier(2) + self.lock = threading.Lock() + self.active = 0 + self.max_active = 0 + + def chat(self, messages, tools=None, **kwargs): + with self.lock: + self.active += 1 + self.max_active = max(self.max_active, self.active) + try: + self.barrier.wait(timeout=3) + except threading.BrokenBarrierError: + pass + finally: + with self.lock: + self.active -= 1 + return ChatResponse( + content="implementation complete", + model=self.model, + usage={"input_tokens": 1, "output_tokens": 1}, + finish_reason="stop", + tool_uses=None, + ) + + +class TestAtomicTeamPlan(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name).resolve() + self.context = ToolContext(workspace_root=self.root) + self.registry = build_default_registry(include_user_tools=False) + self.context.teammate_runtime = TeammateRuntime(object(), self.registry) + TeamCreateTool().run( + {"team_name": "strict-v2", "quality_gates": True}, self.context + ) + + def tearDown(self) -> None: + self.tmp.cleanup() + + @staticmethod + def _payload() -> dict: + return { + "mode": "replace", + "expected_revision": 0, + "contract": { + "summary": "core freezes a parse contract before parallel work", + "interfaces": [ + { + "name": "pkg.core.parse", + "provider_task": "core", + "consumer_tasks": ["api"], + "signature": "parse(text: str) -> dict", + "mode": "frozen", + }, + { + "name": "pkg.core.artifact", + "provider_task": "core", + "consumer_tasks": ["integration"], + "signature": "installed editable package", + "mode": "handoff", + }, + ], + }, + "workers": [ + {"name": "core-worker", "instructions": "Implement parsing core."}, + {"name": "api-worker", "instructions": "Implement and integrate API."}, + ], + "tasks": [ + { + "key": "core", + "owner": "core-worker", + "instructions": "Implement the core package.", + "owned_files": ["/workspace/pkg"], + "acceptance_checks": ["python -m compileall -q pkg"], + }, + { + "key": "model", + "owner": "core-worker", + "instructions": "Implement the core result model.", + "owned_files": ["./pkg/model.py"], + "acceptance_checks": ["python -m py_compile pkg/model.py"], + }, + { + "key": "api", + "owner": "api-worker", + "instructions": "Implement the public API facade.", + "owned_files": ["./api.py"], + "acceptance_checks": ["python -m py_compile api.py"], + }, + { + "key": "integration", + "owner": "api-worker", + "kind": "validation", + "instructions": "Validate the installed package contract.", + "owned_files": [], + }, + ], + "validation": { + "profile": "python-package", + "imports": ["json"], + "commands": [ + "python -c \"import json; assert json.loads('1') == 1\"" + ], + }, + "execution": {}, + } + + def test_registry_exposes_atomic_team_plan(self) -> None: + self.assertIsNotNone(self.registry.get("TeamPlan")) + + def test_materializes_normalized_plan_and_contract_dependencies(self) -> None: + result = self.registry.dispatch( + ToolCall(name="TeamPlan", input=self._payload()), self.context + ) + + self.assertFalse(result.is_error, result.output) + self.assertEqual(result.output["status"], "ready") + self.assertEqual(result.output["revision"], 1) + self.assertEqual(result.output["protocol_version"], 2) + self.assertEqual(result.output["execution"]["max_workers"], 2) + self.assertEqual(result.output["execution"]["verify_timeout_s"], 900) + self.assertTrue(result.output["execution"]["auto_verify"]) + self.assertIsNone(result.output["execution"]["token_budget"]) + self.assertIsNone(result.output["execution"]["turn_budget"]) + + tasks = {task["key"]: task for task in self.context.tasks.values()} + self.assertEqual(tasks["core"]["owned_files"], ["pkg"]) + self.assertEqual(tasks["model"]["owned_files"], ["pkg/model.py"]) + self.assertEqual(tasks["api"]["blockedBy"], []) + self.assertEqual(tasks["api"]["depends_on_interfaces"], ["pkg.core.parse"]) + self.assertEqual(tasks["integration"]["owned_files"], []) + self.assertEqual(tasks["integration"]["blockedBy"], [tasks["core"]["id"]]) + self.assertEqual( + {task["metadata"]["contract_hash"] for task in tasks.values()}, + { + self.context.team_store.load_active_team() + .settings["quality_gates"]["contract_hash"] + }, + ) + self.assertEqual( + tasks["integration"]["acceptance_checks"], + [result.output["validation"]["integration_command"]], + ) + + stored = self.context.team_store.load_active_team() + self.assertEqual(stored.protocol_version, 2) + self.assertEqual(stored.lifecycle_state, "ready") + self.assertTrue(stored.settings["quality_gates"]["configured"]) + self.assertEqual(stored.settings["team_plan"]["revision"], 1) + manifest = stored.settings["execution_manifest"] + self.assertEqual(manifest["schema_version"], 2) + self.assertEqual(len(manifest["budget_integrity_hash"]), 64) + self.assertEqual(manifest["status"], "frozen") + self.assertEqual(manifest["plan_hash"], result.output["plan_hash"]) + self.assertEqual(manifest["execution"], result.output["execution"]) + self.assertEqual( + manifest["budget_window"]["baseline"], + {"total_tokens": 0, "turns": 0}, + ) + for key in manifest["execution"]: + self.assertNotIn(key, stored.settings) + self.assertEqual(len(self.context.team_store.list_agents(stored.team_id)), 2) + self.assertEqual( + self.context.teammate_runtime._strict_plan_errors( + self.context, stored, require_parallel_start=True + ), + [], + ) + self.assertEqual( + TeamRunOptions.build(stored.settings, {}).max_workers, + 2, + ) + + def test_explicit_max_workers_is_respected(self) -> None: + payload = self._payload() + payload["execution"]["max_workers"] = 1 + + result = TeamPlanTool().run(payload, self.context) + + self.assertFalse(result.is_error, result.output) + self.assertEqual(result.output["execution"]["max_workers"], 1) + stored = self.context.team_store.load_active_team() + self.assertEqual(TeamRunOptions.build(stored.settings, {}).max_workers, 1) + + def test_default_worker_count_runs_distinct_ready_owners_concurrently(self) -> None: + provider = ConcurrentFinalProvider() + self.context.teammate_runtime = TeammateRuntime(provider, self.registry) + (self.root / "pkg").mkdir() + (self.root / "pkg" / "__init__.py").write_text("VALUE = 1\n", encoding="utf-8") + (self.root / "pkg" / "model.py").write_text("MODEL = 1\n", encoding="utf-8") + (self.root / "api.py").write_text("API = 1\n", encoding="utf-8") + (self.root / "pyproject.toml").write_text( + "[build-system]\n" + "requires = ['setuptools']\n" + "build-backend = 'setuptools.build_meta'\n" + "[project]\n" + "name = 'team-plan-concurrency'\n" + "version = '0.0.1'\n", + encoding="utf-8", + ) + planned = TeamPlanTool().run(self._payload(), self.context) + self.assertFalse(planned.is_error, planned.output) + + result = TeamRunTool().run({}, self.context) + + self.assertFalse(result.is_error, result.output) + self.assertEqual(result.output["status"], "completed") + self.assertEqual(provider.max_active, 2) + + def test_cross_owner_overlap_is_structured_and_has_no_side_effects(self) -> None: + payload = self._payload() + payload["tasks"][2]["owned_files"] = ["/workspace/pkg/api.py"] + + result = TeamPlanTool().run(payload, self.context) + + self.assertTrue(result.is_error) + self.assertEqual(result.output["status"], "needs_plan_fix") + issue = next( + item for item in result.output["issues"] if item["code"] == "PATH_OVERLAP" + ) + self.assertEqual(issue["path"], "tasks[2].owned_files") + self.assertEqual(issue["conflicts_with"], "tasks[0].owned_files") + self.assertEqual(self.context.tasks, {}) + team = self.context.team_store.load_active_team() + self.assertNotIn("team_plan", team.settings) + self.assertEqual(self.context.team_store.list_agents(team.team_id), []) + + def test_rejects_weak_acceptance_and_ceremonial_second_worker(self) -> None: + payload = self._payload() + payload["tasks"][0]["acceptance_checks"] = ["test -e pkg"] + payload["tasks"][2]["owner"] = "core-worker" + + result = TeamPlanTool().run(payload, self.context) + codes = {issue["code"] for issue in result.output["issues"]} + + self.assertTrue(result.is_error) + self.assertIn("TRIVIAL_ACCEPTANCE_CHECK", codes) + self.assertIn("MIN_IMPLEMENTATION_OWNERS", codes) + self.assertEqual(self.context.tasks, {}) + + def test_rejects_metadata_only_implementation_partition(self) -> None: + payload = self._payload() + payload["tasks"][2]["owned_files"] = [ + "README.md", + "docs/api.rst", + ".github/workflows/ci.yml", + "pyproject.toml", + ] + + result = TeamPlanTool().run(payload, self.context) + codes = {issue["code"] for issue in result.output["issues"]} + + self.assertTrue(result.is_error) + self.assertIn("CEREMONIAL_IMPLEMENTATION_TASK", codes) + self.assertIn("MIN_IMPLEMENTATION_OWNERS", codes) + self.assertEqual(self.context.tasks, {}) + + def test_rejects_import_and_introspection_only_acceptance(self) -> None: + payload = self._payload() + payload["tasks"][2]["acceptance_checks"] = [ + ( + "python -c \"import api; assert hasattr(api, 'parse'); " + "assert callable(api.parse)\"" + ) + ] + + result = TeamPlanTool().run(payload, self.context) + codes = {issue["code"] for issue in result.output["issues"]} + + self.assertTrue(result.is_error) + self.assertIn("WEAK_ACCEPTANCE_CHECK", codes) + self.assertIn("MIN_IMPLEMENTATION_OWNERS", codes) + + def test_json_stringified_complex_fields_use_full_validation(self) -> None: + payload = self._payload() + for field in ("contract", "workers", "tasks", "validation"): + payload[field] = json.dumps(payload[field]) + + result = self.registry.dispatch( + ToolCall(name="TeamPlan", input=payload), self.context + ) + + self.assertFalse(result.is_error, result.output) + self.assertEqual(result.output["status"], "ready") + schema = TeamPlanTool().spec().input_schema + for field in ("contract", "workers", "tasks", "validation"): + self.assertIn("oneOf", schema["properties"][field]) + + def test_json_stringified_fields_do_not_bypass_semantic_validation(self) -> None: + payload = self._payload() + payload["tasks"][2]["owned_files"] = ["pkg/api.py"] + for field in ("contract", "workers", "tasks", "validation"): + payload[field] = json.dumps(payload[field]) + + result = self.registry.dispatch( + ToolCall(name="TeamPlan", input=payload), self.context + ) + + self.assertTrue(result.is_error) + self.assertIn( + "PATH_OVERLAP", {issue["code"] for issue in result.output["issues"]} + ) + self.assertEqual(self.context.tasks, {}) + + def test_invalid_json_string_returns_structured_issue(self) -> None: + payload = self._payload() + payload["tasks"] = "[{not-json}]" + + result = self.registry.dispatch( + ToolCall(name="TeamPlan", input=payload), self.context + ) + + self.assertTrue(result.is_error) + issue = next( + issue + for issue in result.output["issues"] + if issue["code"] == "INVALID_JSON_STRING" + ) + self.assertEqual(issue["path"], "tasks") + + def test_rejects_fail_open_and_trivial_python_acceptance(self) -> None: + weak_commands = [ + "python -m pytest -q || true", + "python -m pytest -q || :", + "python -m pytest -q; true", + "python -m pytest -q; exit 0", + "python -c ''", + "python -c 'pass'", + "python -c 'print(\"looks good\")'", + "python -c 'exit(0)'", + "python -c 'raise SystemExit(0)'", + "python -c 'assert True'", + "python -c 'assert 1 + 1 == 2'", + ] + for command in weak_commands: + with self.subTest(command=command): + payload = self._payload() + payload["tasks"][0]["acceptance_checks"] = [command] + + result = TeamPlanTool().run(payload, self.context) + codes = {issue["code"] for issue in result.output["issues"]} + + self.assertTrue(result.is_error) + self.assertIn("TRIVIAL_ACCEPTANCE_CHECK", codes) + self.assertEqual(self.context.tasks, {}) + + def test_identical_hash_is_idempotent_before_revision_check(self) -> None: + payload = self._payload() + first = TeamPlanTool().run(payload, self.context) + first_agents = [agent.agent_id for agent in self.context.team_store.list_agents( + first.output["team_id"] + )] + + repeated = TeamPlanTool().run(payload, self.context) + + self.assertFalse(repeated.is_error, repeated.output) + self.assertTrue(repeated.output["idempotent"]) + self.assertEqual(repeated.output["revision"], 1) + self.assertEqual(repeated.output["plan_hash"], first.output["plan_hash"]) + self.assertEqual( + [agent.agent_id for agent in self.context.team_store.list_agents(first.output["team_id"])], + first_agents, + ) + + def test_revision_and_idempotency_conflicts_are_actionable(self) -> None: + payload = self._payload() + payload["idempotency_key"] = "request-1" + first = TeamPlanTool().run(payload, self.context) + self.assertFalse(first.is_error, first.output) + + stale = copy.deepcopy(payload) + stale["idempotency_key"] = "request-2" + stale["contract"]["summary"] += " revision" + stale_result = TeamPlanTool().run(stale, self.context) + self.assertEqual(stale_result.output["issues"][0]["code"], "REVISION_CONFLICT") + + reused = copy.deepcopy(stale) + reused["expected_revision"] = 1 + reused["idempotency_key"] = "request-1" + reused_result = TeamPlanTool().run(reused, self.context) + self.assertEqual( + reused_result.output["issues"][0]["code"], "IDEMPOTENCY_KEY_REUSE" + ) + + def test_repair_required_accepts_explicit_new_revision(self) -> None: + first = TeamPlanTool().run(self._payload(), self.context) + self.assertFalse(first.is_error, first.output) + team_id = first.output["team_id"] + original_task_ids = set(self.context.tasks) + completed: dict[str, dict] = {} + for task_id, data in self.context.tasks.items(): + task = TeamTask.from_dict(data) + task.transition_to("completed") + completed[task_id] = task.to_dict() + self.context.team_store.save_tasks(team_id, completed) + team = self.context.team_store.load_team(team_id) + team.transition_to("running") + team.set_lifecycle_state("repair_required") + self.context.team_store.save_team(team) + self.context.reload_team_state() + + repaired = self._payload() + repaired["expected_revision"] = 1 + repaired["contract"]["summary"] += " with repaired integration" + repaired["tasks"][2]["instructions"] += " Repair the public contract." + result = TeamPlanTool().run(repaired, self.context) + + self.assertFalse(result.is_error, result.output) + self.assertEqual(result.output["revision"], 2) + self.assertTrue(original_task_ids.isdisjoint(self.context.tasks)) + self.assertTrue( + all(task["status"] == "pending" for task in self.context.tasks.values()) + ) + stored = self.context.team_store.load_team(team_id) + self.assertEqual(stored.lifecycle_state, "ready") + self.assertEqual( + stored.settings["quality_gates"]["validation"]["status"], "pending" + ) + + def test_new_revision_removes_omitted_execution_settings(self) -> None: + initial = self._payload() + initial["execution"].update( + { + "timeout_s": 30, + "token_budget": 500, + "turn_budget": 20, + "max_retries": 2, + "lease_timeout_s": 60, + } + ) + first = TeamPlanTool().run(initial, self.context) + self.assertFalse(first.is_error, first.output) + team = self.context.team_store.load_active_team() + team.settings["max_batches"] = 99 + team.settings["unrelated_setting"] = "preserved" + self.context.team_store.save_team(team) + self.context.reload_team_state() + + replacement = self._payload() + replacement["expected_revision"] = 1 + replacement["contract"]["summary"] += " revision two" + result = TeamPlanTool().run(replacement, self.context) + + self.assertFalse(result.is_error, result.output) + stored = self.context.team_store.load_active_team() + for stale in ( + "max_batches", + "timeout_s", + "token_budget", + "turn_budget", + "max_retries", + "lease_timeout_s", + ): + self.assertNotIn(stale, stored.settings) + execution = stored.settings["execution_manifest"]["execution"] + self.assertEqual(execution["max_workers"], 2) + self.assertIsNone(execution["timeout_s"]) + self.assertIsNone(execution["token_budget"]) + self.assertIsNone(execution["turn_budget"]) + self.assertEqual(execution["max_retries"], 0) + self.assertEqual(execution["lease_timeout_s"], 900) + self.assertEqual(execution["verify_timeout_s"], 900) + self.assertTrue(execution["auto_verify"]) + self.assertEqual(stored.settings["unrelated_setting"], "preserved") + + def test_run_rejects_override_of_frozen_execution_manifest(self) -> None: + payload = self._payload() + payload["execution"]["turn_budget"] = 20 + planned = TeamPlanTool().run(payload, self.context) + self.assertFalse(planned.is_error, planned.output) + + result = TeamRunTool().run({"turn_budget": 21}, self.context) + + self.assertTrue(result.is_error) + self.assertEqual(result.output["status"], "blocked") + mismatch = result.output["execution_manifest_mismatches"][0] + self.assertEqual(mismatch["field"], "turn_budget") + self.assertEqual(mismatch["planned"], 20) + self.assertEqual(mismatch["requested"], 21) + stored = self.context.team_store.load_active_team() + self.assertNotIn("turn_budget", stored.settings) + self.assertEqual( + stored.settings["execution_manifest"]["execution"]["turn_budget"], 20 + ) + event = self.context.team_store.list_events(stored.team_id)[-1] + self.assertEqual(event["type"], "team.execution_manifest_mismatch") + self.assertEqual(event["data"]["mismatches"][0]["reason"], mismatch["reason"]) + + def test_run_blocks_tampered_budget_ceiling_before_model_call(self) -> None: + payload = self._payload() + payload["execution"]["turn_budget"] = 20 + planned = TeamPlanTool().run(payload, self.context) + self.assertFalse(planned.is_error, planned.output) + team = self.context.team_store.load_active_team() + manifest = dict(team.settings["execution_manifest"]) + window = dict(manifest["budget_window"]) + ceiling = dict(window["hard_ceiling"]) + ceiling["turns"] = 999 + window["hard_ceiling"] = ceiling + manifest["budget_window"] = window + team.settings["execution_manifest"] = manifest + self.context.team_store.save_team(team) + self.context.reload_team_state() + + result = TeamRunTool().run({}, self.context) + + self.assertTrue(result.is_error) + self.assertEqual(result.output["status"], "blocked") + self.assertEqual(result.output["failure_domain"], "harness") + self.assertTrue(result.output["workspace_preserved"]) + self.assertTrue( + any( + "hard_ceiling.turns" in error + or "budget_integrity_hash" in error + for error in result.output["budget_manifest_errors"] + ) + ) + + def test_repair_budget_is_incremental_but_cannot_raise_global_cap(self) -> None: + initial = self._payload() + initial["execution"].update({"token_budget": 1_000, "turn_budget": 100}) + first = TeamPlanTool().run(initial, self.context) + self.assertFalse(first.is_error, first.output) + team = self.context.team_store.load_active_team() + team.usage = { + "input_tokens": 500, + "output_tokens": 0, + "total_tokens": 500, + "turns": 50, + } + quality = dict(team.settings["quality_gates"]) + quality["plan_accepted"] = True + team.settings["quality_gates"] = quality + manifest = dict(team.settings["execution_manifest"]) + manifest["status"] = "accepted" + team.settings["execution_manifest"] = manifest + team.transition_to("running") + self.context.team_store.save_team(team) + self.context.reload_team_state() + + checkpoint = TeamReplanTool().run( + {"reason": "repair the contract"}, self.context + ) + self.assertFalse(checkpoint.is_error, checkpoint.output) + repair = self._payload() + repair["expected_revision"] = 1 + repair["contract"]["summary"] += " repaired" + repair["execution"].update({"token_budget": 200, "turn_budget": 20}) + second = TeamPlanTool().run(repair, self.context) + self.assertFalse(second.is_error, second.output) + + stored = self.context.team_store.load_active_team() + budget = stored.settings["execution_manifest"] + self.assertEqual( + budget["global_cap"], {"total_tokens": 1_000, "turns": 100} + ) + self.assertEqual( + budget["budget_window"]["baseline"], + {"total_tokens": 500, "turns": 50}, + ) + self.assertEqual( + budget["budget_window"]["hard_ceiling"], + {"total_tokens": 700, "turns": 70}, + ) + options = TeamRunOptions.build(stored.settings, {}) + self.assertIsNone( + TeammateRuntime._budget_error(stored, options, time.monotonic()) + ) + + quality = dict(stored.settings["quality_gates"]) + quality["plan_accepted"] = True + stored.settings["quality_gates"] = quality + manifest = dict(stored.settings["execution_manifest"]) + manifest["status"] = "accepted" + stored.settings["execution_manifest"] = manifest + stored.usage.update({"input_tokens": 650, "total_tokens": 650, "turns": 65}) + stored.set_lifecycle_state("running") + self.context.team_store.save_team(stored) + self.context.reload_team_state() + TeamReplanTool().run({"reason": "second repair"}, self.context) + later = self._payload() + later["expected_revision"] = 2 + later["contract"]["summary"] += " second repair" + later["execution"].update({"token_budget": 500, "turn_budget": 50}) + third = TeamPlanTool().run(later, self.context) + self.assertFalse(third.is_error, third.output) + final = self.context.team_store.load_active_team() + final_budget = final.settings["execution_manifest"] + self.assertEqual( + final_budget["global_cap"], {"total_tokens": 1_000, "turns": 100} + ) + self.assertEqual( + final_budget["budget_window"]["hard_ceiling"], + {"total_tokens": 1_000, "turns": 100}, + ) + consumed = final.settings["last_replan_checkpoint"] + self.assertEqual(consumed["consumed_by_revision"], 3) + self.assertIn("consumed_at", consumed) + + def test_accepted_plan_requires_replan_and_completed_team_is_terminal(self) -> None: + first = TeamPlanTool().run(self._payload(), self.context) + self.assertFalse(first.is_error, first.output) + team = self.context.team_store.load_active_team() + quality = dict(team.settings["quality_gates"]) + quality["plan_accepted"] = True + team.settings["quality_gates"] = quality + manifest = dict(team.settings["execution_manifest"]) + manifest["status"] = "accepted" + team.settings["execution_manifest"] = manifest + team.transition_to("running") + self.context.team_store.save_team(team) + self.context.reload_team_state() + replacement = self._payload() + replacement["expected_revision"] = 1 + replacement["contract"]["summary"] += " unauthorized" + + rejected = TeamPlanTool().run(replacement, self.context) + + self.assertTrue(rejected.is_error) + self.assertEqual(rejected.output["issues"][0]["code"], "REPLAN_REQUIRED") + + team = self.context.team_store.load_active_team() + team.transition_to("completed") + self.context.team_store.save_team(team) + self.context.reload_team_state() + terminal = TeamPlanTool().run(self._payload(), self.context) + self.assertTrue(terminal.is_error) + self.assertEqual(terminal.output["issues"][0]["code"], "TEAM_TERMINAL") + + def test_repair_rejects_unchanged_plan_and_keeps_checkpoint_unconsumed(self) -> None: + payload = self._payload() + first = TeamPlanTool().run(payload, self.context) + self.assertFalse(first.is_error, first.output) + team = self.context.team_store.load_active_team() + quality = dict(team.settings["quality_gates"]) + quality["plan_accepted"] = True + team.settings["quality_gates"] = quality + manifest = dict(team.settings["execution_manifest"]) + manifest["status"] = "accepted" + team.settings["execution_manifest"] = manifest + self.context.team_store.save_team(team) + self.context.reload_team_state() + checkpoint = TeamReplanTool().run( + {"reason": "repair validation without losing the workspace"}, self.context + ) + self.assertFalse(checkpoint.is_error, checkpoint.output) + + unchanged = TeamPlanTool().run(payload, self.context) + + self.assertTrue(unchanged.is_error) + self.assertEqual(unchanged.output["issues"][0]["code"], "REPLAN_REQUIRED") + self.assertIn("unchanged plan", unchanged.output["issues"][0]["message"]) + stored = self.context.team_store.load_active_team() + self.assertEqual(stored.lifecycle_state, "repair_required") + self.assertNotIn( + "consumed_by_revision", stored.settings["last_replan_checkpoint"] + ) + + def test_plan_rollback_cannot_overwrite_concurrent_task_writer(self) -> None: + team_id = str(self.context.team["team_id"]) + plan_paused = threading.Event() + release_plan = threading.Event() + writer_started = threading.Event() + writer_done = threading.Event() + plan_errors: list[Exception] = [] + writer_errors: list[Exception] = [] + original = TeamStore._write_json_unlocked + + def pause_then_fail(path: Path, data: dict) -> None: + if threading.current_thread().name == "plan-commit" and path.name == "tasks.json": + plan_paused.set() + if not release_plan.wait(timeout=3): + raise TimeoutError("test did not release plan transaction") + raise OSError("simulated plan commit failure") + original(path, data) + + def commit_plan() -> None: + try: + TeamPlanTool().run(self._payload(), self.context) + except Exception as exc: # expected simulated storage failure + plan_errors.append(exc) + + external = TeamTask( + id="external-task", + key="external", + subject="External writer", + description="Must survive plan rollback", + ).to_dict() + + def write_external_task() -> None: + writer_started.set() + try: + self.context.team_store.save_tasks( + team_id, {"external-task": external} + ) + except Exception as exc: # pragma: no cover - diagnostic path + writer_errors.append(exc) + finally: + writer_done.set() + + with patch.object( + TeamStore, "_write_json_unlocked", side_effect=pause_then_fail + ): + plan_thread = threading.Thread(target=commit_plan, name="plan-commit") + plan_thread.start() + self.assertTrue(plan_paused.wait(timeout=3)) + writer_thread = threading.Thread( + target=write_external_task, name="task-writer" + ) + writer_thread.start() + self.assertTrue(writer_started.wait(timeout=1)) + self.assertFalse( + writer_done.wait(timeout=0.1), + "task writer entered while TeamPlan transaction was paused", + ) + release_plan.set() + plan_thread.join(timeout=3) + writer_thread.join(timeout=3) + + self.assertFalse(plan_thread.is_alive()) + self.assertFalse(writer_thread.is_alive()) + self.assertEqual(writer_errors, []) + self.assertEqual(len(plan_errors), 1) + self.assertIsInstance(plan_errors[0], OSError) + self.assertEqual( + set(self.context.team_store.load_tasks(team_id)), {"external-task"} + ) + stored = self.context.team_store.load_team(team_id) + self.assertNotIn("team_plan", stored.settings) + self.assertEqual(self.context.team_store.list_agents(team_id), []) + + def test_replan_and_claim_are_serialized_by_one_team_transaction(self) -> None: + planned = TeamPlanTool().run(self._payload(), self.context) + self.assertFalse(planned.is_error, planned.output) + team = self.context.team_store.load_active_team() + team.transition_to("running") + team.set_lifecycle_state("running") + self.context.team_store.save_team(team) + self.context.reload_team_state() + task_id = next(iter(self.context.tasks)) + plan_hash = planned.output["plan_hash"] + replan_paused = threading.Event() + release_replan = threading.Event() + claim_started = threading.Event() + claim_done = threading.Event() + claims: list[TeamTask | None] = [] + replan_errors: list[Exception] = [] + original = TeamStore._write_json_unlocked + + def pause_team_write(path: Path, data: dict) -> None: + if ( + threading.current_thread().name == "replan" + and path.name == "team.json" + and path.parent.name == team.team_id + ): + replan_paused.set() + if not release_replan.wait(timeout=3): + raise TimeoutError("test did not release TeamReplan") + original(path, data) + + def request_replan() -> None: + try: + TeamReplanTool().run( + {"reason": "replace the invalid partition"}, self.context + ) + except Exception as exc: # pragma: no cover - diagnostic path + replan_errors.append(exc) + + def claim_task() -> None: + claim_started.set() + claims.append( + self.context.team_store.claim_task( + team.team_id, + task_id, + lease_id="late-claim", + lease_expires_at="2999-01-01T00:00:00+00:00", + max_retries=0, + expected_plan_hash=plan_hash, + ) + ) + claim_done.set() + + with patch.object( + TeamStore, "_write_json_unlocked", side_effect=pause_team_write + ): + replan_thread = threading.Thread(target=request_replan, name="replan") + replan_thread.start() + self.assertTrue(replan_paused.wait(timeout=3)) + claim_thread = threading.Thread(target=claim_task, name="claim") + claim_thread.start() + self.assertTrue(claim_started.wait(timeout=1)) + self.assertFalse( + claim_done.wait(timeout=0.1), + "task claim entered while TeamReplan transaction was paused", + ) + release_replan.set() + replan_thread.join(timeout=3) + claim_thread.join(timeout=3) + + self.assertEqual(replan_errors, []) + self.assertEqual(claims, [None]) + stored = self.context.team_store.load_active_team() + self.assertEqual(stored.lifecycle_state, "repair_required") + self.assertEqual( + self.context.team_store.load_tasks(team.team_id)[task_id]["status"], + "pending", + ) + + def test_stale_worker_outcome_cannot_resurrect_replaced_revision_task(self) -> None: + first = TeamPlanTool().run(self._payload(), self.context) + self.assertFalse(first.is_error, first.output) + team = self.context.team_store.load_active_team() + old_task_id = next(iter(self.context.tasks)) + old_task = TeamTask.from_dict(self.context.tasks[old_task_id]) + TeamReplanTool().run({"reason": "replace the task graph"}, self.context) + replacement = self._payload() + replacement["expected_revision"] = 1 + replacement["contract"]["summary"] += " with a revised boundary" + second = TeamPlanTool().run(replacement, self.context) + self.assertFalse(second.is_error, second.output) + new_task_ids = set(self.context.tasks) + self.assertNotIn(old_task_id, new_task_ids) + + old_task.transition_to("in_progress") + old_task.transition_to("completed") + old_task.output = "late result from revision one" + self.context.team_store.update_task( + team.team_id, + old_task, + expected_plan_hash=first.output["plan_hash"], + ) + + stored_tasks = self.context.team_store.load_tasks(team.team_id) + self.assertEqual(set(stored_tasks), new_task_ids) + self.assertNotIn(old_task_id, stored_tasks) + self.assertTrue( + all(task["status"] == "pending" for task in stored_tasks.values()) + ) + + def test_produced_only_task_is_not_carried_forward(self) -> None: + first = TeamPlanTool().run(self._payload(), self.context) + self.assertFalse(first.is_error, first.output) + team = self.context.team_store.load_active_team() + task_id = next(iter(self.context.tasks)) + produced = TeamTask.from_dict(self.context.tasks[task_id]) + produced.transition_to("completed") + produced.output = "candidate artifact without harness acceptance" + self.context.team_store.update_task(team.team_id, produced) + TeamReplanTool().run( + { + "reason": "repair validation after an unaccepted delivery", + "replace_completed_work": True, + }, + self.context, + ) + replacement = self._payload() + replacement["expected_revision"] = 1 + replacement["validation"]["commands"] = [ + "python -c \"import json; assert json.loads('2') == 2\"" + ] + + second = TeamPlanTool().run(replacement, self.context) + + self.assertFalse(second.is_error, second.output) + self.assertEqual(second.output["carried_forward_tasks"], []) + self.assertTrue( + all(task["status"] == "pending" for task in self.context.tasks.values()) + ) + + def test_contract_change_invalidates_previously_accepted_tasks(self) -> None: + first = TeamPlanTool().run(self._payload(), self.context) + self.assertFalse(first.is_error, first.output) + team = self.context.team_store.load_active_team() + for raw in self.context.tasks.values(): + accepted = TeamTask.from_dict(raw) + accepted.transition_to("completed") + accepted.set_lifecycle_state("accepted") + accepted.output = f"accepted artifact for {accepted.key}" + accepted.metadata = dict(accepted.metadata) + accepted.metadata["acceptance"] = { + "status": "passed", + "checked_at": "2026-01-01T00:00:00+00:00", + "stages": [ + {"command": command, "exit_code": 0} + for command in accepted.acceptance_checks + ], + } + self.context.team_store.update_task(team.team_id, accepted) + TeamReplanTool().run( + { + "reason": "replace the shared interface contract", + "replace_completed_work": True, + }, + self.context, + ) + replacement = self._payload() + replacement["expected_revision"] = 1 + replacement["contract"]["summary"] += " with a breaking revision" + + second = TeamPlanTool().run(replacement, self.context) + + self.assertFalse(second.is_error, second.output) + self.assertEqual(second.output["carried_forward_tasks"], []) + self.assertTrue( + all(task["status"] == "pending" for task in self.context.tasks.values()) + ) + + def test_team_transaction_lock_is_reentrant_inside_mutator(self) -> None: + team = self.context.team_store.load_active_team() + observed: list[str] = [] + + def mutate(tasks: dict[str, TeamTask]) -> None: + self.context.team_store.save_team(team) + observed.append(team.team_id) + + self.context.team_store.mutate_tasks(team.team_id, mutate) + + self.assertEqual(observed, [team.team_id]) + self.assertEqual(self.context.team_store.load_tasks(team.team_id), {}) + + def test_storage_failure_rolls_back_every_materialized_file(self) -> None: + payload = self._payload() + original = TeamStore._write_json_unlocked + + def fail_on_session(path: Path, data: dict) -> None: + if path.parent.name == "sessions": + raise OSError("simulated session write failure") + original(path, data) + + with patch.object(TeamStore, "_write_json_unlocked", side_effect=fail_on_session): + with self.assertRaisesRegex(OSError, "simulated session"): + TeamPlanTool().run(payload, self.context) + + team = self.context.team_store.load_active_team() + self.assertNotIn("team_plan", team.settings) + self.assertEqual(self.context.team_store.load_tasks(team.team_id), {}) + self.assertEqual(self.context.team_store.list_agents(team.team_id), []) + self.assertEqual( + list((self.context.team_store.team_dir(team.team_id) / "sessions").glob("*.json")), + [], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_team_quality_gates.py b/tests/test_team_quality_gates.py new file mode 100644 index 0000000..7f6d7b6 --- /dev/null +++ b/tests/test_team_quality_gates.py @@ -0,0 +1,1024 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from src.agent.conversation import Conversation +from src.providers.base import ChatResponse +from src.teammate.models import TeamTask +from src.teammate.runtime import TeammateRuntime +from src.tool_system.agent_loop import run_agent_loop +from src.tool_system.context import ToolContext +from src.tool_system.defaults import build_default_registry +from src.tool_system.errors import ToolInputError +from src.tool_system.tools import ( + TaskCreateTool, + TeamAbortTool, + TeamConfigureTool, + TeamCreateTool, + TeamDeleteTool, + TeamPlanTool, + TeamReplanTool, + TeamResumeTool, + TeamRunTool, + TeamVerifyTool, + TeammateCreateTool, +) + + +class FinalProvider: + model = "test-model" + + def __init__(self) -> None: + self.calls = 0 + + def chat(self, messages, tools=None, **kwargs): + self.calls += 1 + return ChatResponse( + content="implemented and checked", + model=self.model, + usage={"input_tokens": 1, "output_tokens": 1}, + finish_reason="stop", + tool_uses=None, + ) + + +class TestTeamQualityGates(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name).resolve() + self.registry = build_default_registry(include_user_tools=False) + self.provider = FinalProvider() + self.context = ToolContext(workspace_root=self.root) + self.context.teammate_runtime = TeammateRuntime(self.provider, self.registry) + TeamCreateTool().run( + {"team_name": "strict", "quality_gates": True}, self.context + ) + TeamConfigureTool().run( + { + "architecture_contract": "samplepkg owns the public VALUE contract", + "install_command": ( + "python -m pip install -e . --no-deps --no-build-isolation" + ), + "import_command": "python -c \"import samplepkg\"", + "integration_command": ( + "python -c \"import samplepkg; assert samplepkg.VALUE == 1\"" + ), + }, + self.context, + ) + for name in ("one", "two"): + TeammateCreateTool().run( + { + "name": name, + "role": "implementation", + "instructions": f"Implement the {name} partition.", + "tools": ["Read", "Write", "Bash"], + }, + self.context, + ) + + def tearDown(self) -> None: + self.tmp.cleanup() + + def _task( + self, + key: str, + owner: str, + path: str, + *, + blocked_by: list[str] | None = None, + provides: list[str] | None = None, + depends: list[str] | None = None, + ) -> str: + payload = { + "key": key, + "subject": key, + "description": f"Implement {key}", + "owner": owner, + "ownedFiles": [path], + "acceptanceChecks": [f"python -m py_compile {path}"], + "providesInterfaces": provides or [], + "dependsOnInterfaces": depends or [], + } + if blocked_by: + payload["blockedBy"] = blocked_by + return TaskCreateTool().run(payload, self.context).output["task"]["id"] + + def _write_package(self) -> None: + (self.root / "samplepkg").mkdir() + (self.root / "samplepkg" / "__init__.py").write_text( + "VALUE = 1\n", encoding="utf-8" + ) + (self.root / "helper.py").write_text("HELPER = True\n", encoding="utf-8") + (self.root / "pyproject.toml").write_text( + "[build-system]\n" + "requires = ['setuptools']\n" + "build-backend = 'setuptools.build_meta'\n" + "[project]\n" + "name = 'strict-team-fixture'\n" + "version = '0.0.1'\n", + encoding="utf-8", + ) + + def _mark_v2_without_plan(self) -> None: + team = self.context.team_store.load_active_team() + self.assertIsNotNone(team) + team.protocol_version = 2 + team.settings["protocol_version"] = 2 + quality = dict(team.settings.get("quality_gates") or {}) + quality["protocol_version"] = 2 + team.settings["quality_gates"] = quality + team.set_lifecycle_state("ready") + self.context.team_store.save_team(team) + self.context.reload_team_state() + + def _submit_v2_plan( + self, + tasks: list[dict], + *, + integration_command: str = ( + "python -c \"import samplepkg; assert samplepkg.VALUE == 1\"" + ), + execution: dict | None = None, + expected_revision: int | None = None, + contract_summary: str = "Two independent implementation partitions", + ): + tasks = [ + { + "instructions": f"Implement and verify {task.get('key', 'the task')}.", + **task, + } + for task in tasks + ] + payload = { + "mode": "replace", + "contract": { + "summary": contract_summary, + "interfaces": [], + }, + "workers": [ + {"name": "one", "instructions": "Implement partition one."}, + {"name": "two", "instructions": "Implement partition two."}, + ], + "tasks": tasks, + "validation": { + "profile": "generic", + "install_command": "true", + "import_command": "python -c \"import samplepkg\"", + "integration_command": integration_command, + }, + "execution": execution or {}, + } + if expected_revision is not None: + payload["expected_revision"] = expected_revision + result = TeamPlanTool().run(payload, self.context) + self.assertFalse(result.is_error, result.output) + self.assertEqual(result.output["status"], "ready") + return result + + def test_rejects_single_worker_and_overlapping_ownership_before_model_calls(self) -> None: + self._task("first", "one", "samplepkg") + self._task("second", "two", "samplepkg/__init__.py") + + result = TeamRunTool().run({"max_workers": 2}, self.context) + + self.assertTrue(result.is_error) + self.assertEqual(result.output["status"], "blocked") + self.assertIn("ownedFiles overlap", result.output["error"]) + self.assertEqual(self.provider.calls, 0) + + def test_completed_tasks_require_clean_validation_before_team_completion(self) -> None: + self._write_package() + self._task("package", "one", "samplepkg") + self._task("helper", "two", "helper.py") + + rollout = TeamRunTool().run({"max_workers": 2}, self.context) + + self.assertFalse(rollout.is_error) + self.assertEqual(rollout.output["status"], "verification_required") + self.assertEqual(self.context.team_store.load_active_team().status, "running") + + verified = TeamVerifyTool().run({"timeout_s": 120}, self.context) + + self.assertFalse(verified.is_error, verified.output) + self.assertEqual(verified.output["status"], "completed") + self.assertEqual(verified.output["validation"]["status"], "passed") + self.assertEqual( + [stage["stage"] for stage in verified.output["validation"]["stages"]], + ["bootstrap", "install", "import", "integration"], + ) + + def test_interface_dependency_requires_peer_coordination(self) -> None: + self._task("provider", "one", "provider.py", provides=["public-api"]) + self._task("independent", "two", "independent.py") + self._task( + "consumer", + "two", + "consumer.py", + blocked_by=["provider"], + depends=["public-api"], + ) + + result = TeamRunTool().run({"max_workers": 2}, self.context) + + self.assertTrue(result.is_error) + self.assertEqual(result.output["status"], "blocked") + self.assertIn("peer message", result.output["error"]) + + def test_v2_team_run_accepts_tasks_and_verifies_automatically(self) -> None: + self._write_package() + self._submit_v2_plan( + [ + { + "key": "package", + "owner": "one", + "owned_files": ["samplepkg/__init__.py"], + "acceptance_checks": [ + "python -m py_compile samplepkg/__init__.py" + ], + }, + { + "key": "helper", + "owner": "two", + "owned_files": ["helper.py"], + "acceptance_checks": ["python -m py_compile helper.py"], + }, + ] + ) + + rollout = TeamRunTool().run({"max_workers": 2}, self.context) + + self.assertFalse(rollout.is_error, rollout.output) + self.assertEqual(rollout.output["status"], "completed") + self.assertEqual(rollout.output["lifecycle_state"], "completed") + self.context.reload_team_state() + self.assertEqual( + {task["lifecycle_state"] for task in self.context.tasks.values()}, + {"accepted"}, + ) + events_before = self.context.team_store.list_events( + str(self.context.team["team_id"]) + ) + verified = TeamVerifyTool().run({"timeout_s": 120}, self.context) + events_after = self.context.team_store.list_events( + str(self.context.team["team_id"]) + ) + self.assertFalse(verified.is_error, verified.output) + self.assertTrue(verified.output["verification_reused"]) + self.assertEqual(len(events_after), len(events_before)) + + def test_v2_verification_infrastructure_failure_cleans_up_and_pauses(self) -> None: + self._submit_v2_plan( + [ + { + "key": "package", + "owner": "one", + "owned_files": ["samplepkg/__init__.py"], + "acceptance_checks": [ + "python -m py_compile samplepkg/__init__.py" + ], + }, + { + "key": "helper", + "owner": "two", + "owned_files": ["helper.py"], + "acceptance_checks": ["python -m py_compile helper.py"], + }, + ] + ) + team = self.context.team_store.load_active_team() + self.assertIsNotNone(team) + for raw in self.context.team_store.load_tasks(team.team_id).values(): + task = TeamTask.from_dict(raw) + task.transition_to("in_progress") + task.attempt = 1 + task.transition_to("completed") + task.set_lifecycle_state("accepted") + self.context.team_store.update_task(team.team_id, task) + quality = dict(team.settings["quality_gates"]) + quality["plan_accepted"] = True + team.settings["quality_gates"] = quality + team.transition_to("running") + self.context.team_store.save_team(team) + self.context.workspace_backend = object() + self.context.execution_workspace_root = "/workspace" + self.context.execution_cwd = "/workspace" + self.context.reload_team_state() + + with patch.object( + TeammateRuntime, + "_run_validation_command", + side_effect=ConnectionError("service unavailable during verification"), + ), patch.object( + TeammateRuntime, "_cleanup_validation_root" + ) as cleanup: + verified = TeamVerifyTool().run({"timeout_s": 120}, self.context) + + self.assertTrue(verified.is_error) + self.assertEqual(verified.output["status"], "paused") + self.assertEqual(verified.output["failure_domain"], "infrastructure") + self.assertTrue(verified.output["retryable"]) + cleanup.assert_called_once() + validation_root = cleanup.call_args.args[1] + self.assertTrue( + validation_root.startswith(f"/tmp/clawd-team-verify-{team.team_id}-") + ) + self.assertNotEqual(validation_root, f"/tmp/clawd-team-verify-{team.team_id}") + stored = self.context.team_store.load_active_team() + self.assertEqual(stored.lifecycle_state, "paused") + self.assertEqual( + stored.settings["quality_gates"]["validation"]["status"], "paused" + ) + + def test_same_owner_nested_paths_do_not_trigger_write_conflict(self) -> None: + self._write_package() + self._task("package", "one", "samplepkg") + self._task("module", "one", "samplepkg/__init__.py") + self._task("helper", "two", "helper.py") + + rollout = TeamRunTool().run({"max_workers": 2}, self.context) + + self.assertFalse(rollout.is_error, rollout.output) + self.assertEqual(rollout.output["status"], "verification_required") + + def test_v2_validation_task_may_have_no_owned_files(self) -> None: + self._write_package() + self._submit_v2_plan( + [ + { + "key": "package", + "owner": "one", + "owned_files": ["samplepkg/__init__.py"], + "acceptance_checks": [ + "python -m py_compile samplepkg/__init__.py" + ], + }, + { + "key": "helper", + "owner": "two", + "owned_files": ["helper.py"], + "acceptance_checks": ["python -m py_compile helper.py"], + }, + { + "key": "validation", + "kind": "validation", + "owner": "two", + "owned_files": [], + "acceptance_checks": ["python -c \"import samplepkg\""], + }, + ] + ) + + rollout = TeamRunTool().run({"max_workers": 2}, self.context) + + self.assertFalse(rollout.is_error, rollout.output) + self.assertEqual(rollout.output["status"], "completed") + + def test_v2_failed_validation_requires_repair_and_cannot_be_deleted(self) -> None: + self._write_package() + self._submit_v2_plan( + [ + { + "key": "package", + "owner": "one", + "owned_files": ["samplepkg/__init__.py"], + "acceptance_checks": [ + "python -m py_compile samplepkg/__init__.py" + ], + }, + { + "key": "helper", + "owner": "two", + "owned_files": ["helper.py"], + "acceptance_checks": ["python -m py_compile helper.py"], + }, + ], + integration_command="python -c \"raise SystemExit(3)\"", + ) + + rollout = TeamRunTool().run({"max_workers": 2}, self.context) + + self.assertEqual(rollout.output["status"], "repair_required") + self.assertEqual( + self.context.team_store.load_active_team().lifecycle_state, + "repair_required", + ) + stale_retry = TeamRunTool().run({"max_workers": 2}, self.context) + self.assertTrue(stale_retry.is_error) + self.assertIn("new TeamPlan revision", stale_retry.output["error"]) + self.assertEqual( + self.context.team_store.load_active_team().lifecycle_state, + "repair_required", + ) + deleted = TeamDeleteTool().run({}, self.context) + self.assertTrue(deleted.is_error) + self.assertFalse(deleted.output["success"]) + self.assertEqual(deleted.output["next_required_action"], "TeamReplan") + self.assertNotIn("call TeamAbort", deleted.output["message"]) + self.assertIsNotNone(self.context.team_store.load_active_team()) + + aborted = TeamAbortTool().run( + {"reason": "integration contract cannot be repaired"}, self.context + ) + self.assertEqual(aborted.output["status"], "aborted") + self.assertEqual( + self.context.team_store.load_active_team().lifecycle_state, "aborted" + ) + self.assertTrue( + any( + event["type"] == "team.aborted" + for event in self.context.team_store.list_events( + str(self.context.team["team_id"]) + ) + ) + ) + + def test_validation_failure_replan_revised_plan_then_completes(self) -> None: + self._write_package() + tasks = [ + { + "key": "package", + "owner": "one", + "owned_files": ["samplepkg/__init__.py"], + "acceptance_checks": [ + "python -m py_compile samplepkg/__init__.py" + ], + }, + { + "key": "helper", + "owner": "two", + "owned_files": ["helper.py"], + "acceptance_checks": ["python -m py_compile helper.py"], + }, + ] + first = self._submit_v2_plan( + tasks, + integration_command="python -c \"raise SystemExit(9)\"", + execution={"turn_budget": 20}, + ) + + failed = TeamRunTool().run({}, self.context) + + self.assertTrue(failed.is_error) + self.assertEqual(failed.output["status"], "repair_required") + self.assertIn("TeamReplan first", failed.output["next_required_action"]) + calls_after_first_revision = self.provider.calls + usage_after_first_revision = dict( + self.context.team_store.load_active_team().usage + ) + prior_task_ids = set(self.context.tasks) + prior_owner_ids = {task["owner"] for task in self.context.tasks.values()} + checkpointed = TeamReplanTool().run( + { + "reason": "replace the failing integration contract", + "replace_completed_work": True, + }, + self.context, + ) + self.assertEqual( + checkpointed.output["checkpoint"]["plan_hash"], + first.output["plan_hash"], + ) + second = self._submit_v2_plan( + tasks, + expected_revision=1, + execution={"turn_budget": 20}, + ) + self.assertNotEqual(second.output["plan_hash"], first.output["plan_hash"]) + self.assertEqual( + {item["key"] for item in second.output["carried_forward_tasks"]}, + {"package", "helper"}, + ) + self.context.reload_team_state() + self.assertTrue(prior_task_ids.isdisjoint(self.context.tasks)) + self.assertTrue( + prior_owner_ids.isdisjoint( + {task["owner"] for task in self.context.tasks.values()} + ) + ) + self.assertTrue( + all( + task["metadata"]["plan_hash"] == second.output["plan_hash"] + for task in self.context.tasks.values() + ) + ) + self.assertEqual( + { + (task["status"], task["lifecycle_state"]) + for task in self.context.tasks.values() + }, + {("completed", "produced")}, + ) + self.assertTrue( + all( + "acceptance" not in task["metadata"] + and task["metadata"]["carry_forward"]["requires_acceptance"] + for task in self.context.tasks.values() + ) + ) + + completed = TeamRunTool().run({}, self.context) + + self.assertFalse(completed.is_error, completed.output) + self.assertEqual(completed.output["status"], "completed") + self.assertEqual(self.provider.calls, calls_after_first_revision) + stored = self.context.team_store.load_active_team() + self.assertEqual(stored.lifecycle_state, "completed") + self.assertEqual(stored.usage, usage_after_first_revision) + self.assertEqual( + stored.settings["last_replan_checkpoint"]["consumed_by_revision"], 2 + ) + self.context.reload_team_state() + self.assertTrue( + all( + task["lifecycle_state"] == "accepted" + and task["metadata"]["acceptance"]["status"] == "passed" + and task["metadata"]["acceptance"]["checked_at"] + != task["metadata"]["carry_forward"]["accepted_evidence"][ + "checked_at" + ] + for task in self.context.tasks.values() + ) + ) + self.assertTrue( + any( + event["type"] == "team.tasks_carried_forward" + for event in self.context.team_store.list_events(stored.team_id) + ) + ) + + def test_changed_task_invalidates_its_dependency_closure_only(self) -> None: + self._write_package() + (self.root / "consumer.py").write_text( + "CONSUMER = True\n", encoding="utf-8" + ) + tasks = [ + { + "key": "package", + "owner": "one", + "owned_files": ["samplepkg/__init__.py"], + "acceptance_checks": [ + "python -m py_compile samplepkg/__init__.py" + ], + }, + { + "key": "helper", + "owner": "two", + "owned_files": ["helper.py"], + "acceptance_checks": ["python -m py_compile helper.py"], + }, + { + "key": "consumer", + "owner": "two", + "blocked_by": ["package"], + "owned_files": ["consumer.py"], + "acceptance_checks": ["python -m py_compile consumer.py"], + }, + ] + self._submit_v2_plan( + tasks, + integration_command="python -c \"raise SystemExit(7)\"", + ) + failed = TeamRunTool().run({}, self.context) + self.assertEqual(failed.output["status"], "repair_required") + calls_after_first_revision = self.provider.calls + TeamReplanTool().run( + { + "reason": "repair package implementation and revalidate consumers", + "replace_completed_work": True, + }, + self.context, + ) + revised_tasks = [dict(task) for task in tasks] + revised_tasks[0]["instructions"] = "Repair the package implementation." + + second = self._submit_v2_plan( + revised_tasks, + expected_revision=1, + ) + + self.assertEqual( + [item["key"] for item in second.output["carried_forward_tasks"]], + ["helper"], + ) + self.context.reload_team_state() + by_key = {task["key"]: task for task in self.context.tasks.values()} + self.assertEqual(by_key["helper"]["lifecycle_state"], "produced") + self.assertEqual(by_key["package"]["status"], "pending") + self.assertEqual(by_key["consumer"]["status"], "pending") + self.assertEqual(by_key["consumer"]["blockedBy"], [by_key["package"]["id"]]) + + completed = TeamRunTool().run({}, self.context) + + self.assertFalse(completed.is_error, completed.output) + self.assertEqual(completed.output["status"], "completed") + self.assertEqual(self.provider.calls - calls_after_first_revision, 2) + + def test_budget_exhaustion_is_terminal_and_agent_loop_fails(self) -> None: + self._write_package() + self._submit_v2_plan( + [ + { + "key": "package", + "owner": "one", + "owned_files": ["samplepkg/__init__.py"], + "acceptance_checks": [ + "python -m py_compile samplepkg/__init__.py" + ], + }, + { + "key": "helper", + "owner": "two", + "owned_files": ["helper.py"], + "acceptance_checks": ["python -m py_compile helper.py"], + }, + ], + execution={"turn_budget": 1}, + ) + marker = self.root / "budget-terminal-workspace.txt" + marker.write_text("preserved\n", encoding="utf-8") + + exhausted = TeamRunTool().run({}, self.context) + + self.assertTrue(exhausted.is_error) + self.assertEqual(exhausted.output["status"], "budget_exhausted") + self.assertTrue(exhausted.output["terminal"]) + self.assertFalse(exhausted.output["resume_allowed"]) + self.assertFalse(exhausted.output["replan_allowed"]) + self.assertEqual(marker.read_text(encoding="utf-8"), "preserved\n") + stored = self.context.team_store.load_active_team() + self.assertEqual(stored.status, "failed") + self.assertEqual(stored.lifecycle_state, "budget_exhausted") + calls_after_exhaustion = self.provider.calls + + resumed = TeamResumeTool().run({}, self.context) + self.assertTrue(resumed.is_error) + self.assertEqual(resumed.output["status"], "budget_exhausted") + self.assertEqual(self.provider.calls, calls_after_exhaustion) + with self.assertRaisesRegex(ToolInputError, "terminal"): + TeamReplanTool().run( + {"reason": "try to add budget after the cap"}, self.context + ) + + conversation = Conversation() + conversation.add_user_message("Finish the remaining team work") + loop_result = run_agent_loop( + conversation, + self.provider, + self.registry, + self.context, + max_turns=2, + ) + self.assertTrue(loop_result.failed) + self.assertEqual(loop_result.failure_reason, "team_budget_exhausted") + + def test_team_replan_preserves_workspace_and_records_checkpoint(self) -> None: + self._write_package() + self._submit_v2_plan( + [ + { + "key": "package", + "owner": "one", + "owned_files": ["samplepkg/__init__.py"], + "acceptance_checks": [ + "python -m py_compile samplepkg/__init__.py" + ], + }, + { + "key": "helper", + "owner": "two", + "owned_files": ["helper.py"], + "acceptance_checks": ["python -m py_compile helper.py"], + }, + ] + ) + team = self.context.team_store.load_active_team() + self.assertIsNotNone(team) + team.set_lifecycle_state("repair_required") + self.context.team_store.save_team(team) + tasks_before = self.context.team_store.load_tasks(team.team_id) + marker = self.root / "keep-this-artifact.txt" + marker.write_text("preserve me\n", encoding="utf-8") + + replanned = TeamReplanTool().run( + {"reason": "replace an invalid ownership split"}, self.context + ) + + self.assertFalse(replanned.is_error, replanned.output) + self.assertEqual(replanned.output["status"], "replan_required") + self.assertEqual(replanned.output["workspace_action"], "none") + self.assertTrue(replanned.output["workspace_preserved"]) + self.assertTrue(replanned.output["artifacts_preserved"]) + self.assertEqual(replanned.output["checkpoint"]["plan_revision"], 1) + self.assertEqual( + self.context.team_store.load_tasks(team.team_id), tasks_before + ) + self.assertEqual(marker.read_text(encoding="utf-8"), "preserve me\n") + stored = self.context.team_store.load_active_team() + self.assertEqual(stored.lifecycle_state, "repair_required") + self.assertEqual( + stored.settings["last_replan_checkpoint"]["checkpoint_id"], + replanned.output["checkpoint"]["checkpoint_id"], + ) + self.assertTrue( + any( + event["type"] == "team.replan_requested" + for event in self.context.team_store.list_events(team.team_id) + ) + ) + self.assertIsNotNone(self.registry.get("TeamReplan")) + self.assertIs(self.registry.get("TeamReset"), self.registry.get("TeamReplan")) + + def test_team_replan_requires_opt_in_to_replace_produced_work(self) -> None: + self._write_package() + self._submit_v2_plan( + [ + { + "key": "package", + "owner": "one", + "owned_files": ["samplepkg/__init__.py"], + "acceptance_checks": [ + "python -m py_compile samplepkg/__init__.py" + ], + }, + { + "key": "helper", + "owner": "two", + "owned_files": ["helper.py"], + "acceptance_checks": ["python -m py_compile helper.py"], + }, + ] + ) + team = self.context.team_store.load_active_team() + self.assertIsNotNone(team) + tasks = self.context.team_store.load_tasks(team.team_id) + task_id = next(iter(tasks)) + produced = TeamTask.from_dict(tasks[task_id]) + produced.transition_to("in_progress") + produced.transition_to("completed") + produced.output = "implemented package API" + self.context.team_store.update_task(team.team_id, produced) + team.set_lifecycle_state("repair_required") + self.context.team_store.save_team(team) + + with self.assertRaisesRegex(ToolInputError, "replace_completed_work=true"): + TeamReplanTool().run({"reason": "start over"}, self.context) + + self.assertEqual( + self.context.team_store.load_tasks(team.team_id)[task_id]["output"], + "implemented package API", + ) + replanned = TeamReplanTool().run( + { + "reason": "the accepted interface split is incorrect", + "replace_completed_work": True, + }, + self.context, + ) + self.assertIn(produced.key, replanned.output["checkpoint"]["artifact_tasks"]) + self.assertEqual( + self.context.team_store.load_tasks(team.team_id)[task_id]["output"], + "implemented package API", + ) + + def test_team_replan_rejects_completed_team(self) -> None: + self._write_package() + self._submit_v2_plan( + [ + { + "key": "package", + "owner": "one", + "owned_files": ["samplepkg/__init__.py"], + "acceptance_checks": [ + "python -m py_compile samplepkg/__init__.py" + ], + }, + { + "key": "helper", + "owner": "two", + "owned_files": ["helper.py"], + "acceptance_checks": ["python -m py_compile helper.py"], + }, + ] + ) + completed = TeamRunTool().run({"max_workers": 2}, self.context) + self.assertEqual(completed.output["status"], "completed") + + with self.assertRaisesRegex(ToolInputError, "completed team"): + TeamReplanTool().run( + { + "reason": "accidental restart", + "replace_completed_work": True, + }, + self.context, + ) + + stored = self.context.team_store.load_active_team() + self.assertEqual(stored.lifecycle_state, "completed") + + def test_team_replan_requires_workers_to_stop_without_suggesting_abort(self) -> None: + self._submit_v2_plan( + [ + { + "key": "package", + "owner": "one", + "owned_files": ["samplepkg/__init__.py"], + "acceptance_checks": [ + "python -m py_compile samplepkg/__init__.py" + ], + }, + { + "key": "helper", + "owner": "two", + "owned_files": ["helper.py"], + "acceptance_checks": ["python -m py_compile helper.py"], + }, + ] + ) + team = self.context.team_store.load_active_team() + self.assertIsNotNone(team) + tasks = self.context.team_store.load_tasks(team.team_id) + task_id = next(iter(tasks)) + active = TeamTask.from_dict(tasks[task_id]) + active.transition_to("in_progress") + self.context.team_store.update_task(team.team_id, active) + + with self.assertRaises(ToolInputError) as raised: + TeamReplanTool().run({"reason": "restart workers"}, self.context) + + message = str(raised.exception) + self.assertIn("TeamCancel", message) + self.assertIn("TeamReplan", message) + self.assertIn("Do not use TeamAbort", message) + + def test_aborted_v2_team_finishes_agent_loop_as_failure(self) -> None: + self._write_package() + self._submit_v2_plan( + [ + { + "key": "package", + "owner": "one", + "owned_files": ["samplepkg/__init__.py"], + "acceptance_checks": [ + "python -m py_compile samplepkg/__init__.py" + ], + }, + { + "key": "helper", + "owner": "two", + "owned_files": ["helper.py"], + "acceptance_checks": ["python -m py_compile helper.py"], + }, + ] + ) + TeamAbortTool().run({"reason": "unrecoverable plan"}, self.context) + replan = TeamPlanTool().run( + { + "mode": "replace", + "contract": {"summary": "replacement", "interfaces": []}, + "workers": [ + {"name": "one", "instructions": "Retry partition one."}, + {"name": "two", "instructions": "Retry partition two."}, + ], + "tasks": [ + { + "key": "package", + "owner": "one", + "instructions": "Retry package.", + "owned_files": ["samplepkg/__init__.py"], + "acceptance_checks": [ + "python -m py_compile samplepkg/__init__.py" + ], + }, + { + "key": "helper", + "owner": "two", + "instructions": "Retry helper.", + "owned_files": ["helper.py"], + "acceptance_checks": ["python -m py_compile helper.py"], + }, + ], + "validation": { + "profile": "generic", + "install_command": "true", + "import_command": "python -c \"import samplepkg\"", + "integration_command": "python -c \"import samplepkg\"", + }, + }, + self.context, + ) + resumed = TeamResumeTool().run({}, self.context) + self.assertTrue(replan.is_error) + self.assertIn("aborted", str(replan.output)) + self.assertTrue(resumed.is_error) + self.assertEqual(resumed.output["status"], "aborted") + self.assertEqual( + self.context.team_store.load_active_team().lifecycle_state, "aborted" + ) + conversation = Conversation() + conversation.add_user_message("Implement the task") + + result = run_agent_loop( + conversation, + self.provider, + self.registry, + self.context, + max_turns=2, + ) + + self.assertTrue(result.failed) + self.assertEqual(result.failure_reason, "team_aborted") + + def test_v2_rejects_legacy_incremental_plan(self) -> None: + self._write_package() + self._task("package", "one", "samplepkg/__init__.py") + self._task("helper", "two", "helper.py") + self._mark_v2_without_plan() + + rollout = TeamRunTool().run({"max_workers": 2}, self.context) + + self.assertTrue(rollout.is_error) + self.assertEqual(rollout.output["status"], "blocked") + self.assertIn("atomic TeamPlan", rollout.output["error"]) + self.assertEqual(self.provider.calls, 0) + + def test_v2_completed_state_drift_fails_closed_at_max_turns(self) -> None: + self._write_package() + self._submit_v2_plan( + [ + { + "key": "package", + "owner": "one", + "owned_files": ["samplepkg/__init__.py"], + "acceptance_checks": [ + "python -m py_compile samplepkg/__init__.py" + ], + }, + { + "key": "helper", + "owner": "two", + "owned_files": ["helper.py"], + "acceptance_checks": ["python -m py_compile helper.py"], + }, + ] + ) + completed = TeamRunTool().run({"max_workers": 2}, self.context) + self.assertEqual(completed.output["status"], "completed") + task_id = next(iter(self.context.tasks)) + self.context.tasks[task_id]["lifecycle_state"] = "produced" + self.context.persist_tasks() + conversation = Conversation() + conversation.add_user_message("Finish now") + + result = run_agent_loop( + conversation, + self.provider, + self.registry, + self.context, + max_turns=1, + ) + + self.assertTrue(result.failed) + self.assertEqual(result.response_text, "[Max tool turns reached]") + self.assertEqual(result.failure_reason, "team_lifecycle_failure") + + def test_v2_infrastructure_interruption_pauses_instead_of_completing(self) -> None: + self._write_package() + self._submit_v2_plan( + [ + { + "key": "package", + "owner": "one", + "owned_files": ["samplepkg/__init__.py"], + "acceptance_checks": [ + "python -m py_compile samplepkg/__init__.py" + ], + }, + { + "key": "helper", + "owner": "two", + "owned_files": ["helper.py"], + "acceptance_checks": ["python -m py_compile helper.py"], + }, + ] + ) + with patch.object( + self.context.teammate_runtime, + "_run_validation_command", + side_effect=TimeoutError("pending request was cancelled"), + ): + rollout = TeamRunTool().run({"max_workers": 2}, self.context) + + self.assertFalse(rollout.is_error) + self.assertEqual(rollout.output["status"], "paused") + self.assertEqual(rollout.output["lifecycle_state"], "paused") + self.assertEqual( + self.context.team_store.load_active_team().lifecycle_state, "paused" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_teammate_benchmark.py b/tests/test_teammate_benchmark.py new file mode 100644 index 0000000..54a0532 --- /dev/null +++ b/tests/test_teammate_benchmark.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import importlib.util +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).parents[1] +BENCHMARK = ROOT / "teammate-evals" / "solo-vs-team" / "benchmark.py" + + +def load_benchmark_module(): + spec = importlib.util.spec_from_file_location("solo_vs_team_benchmark", BENCHMARK) + if spec is None or spec.loader is None: + raise RuntimeError("could not load benchmark module") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class TestSoloVsTeamBenchmark(unittest.TestCase): + def test_all_five_fixtures_are_valid_and_fail_before_repair(self) -> None: + completed = subprocess.run( + [sys.executable, str(BENCHMARK), "--validate-fixtures"], + cwd=ROOT, + capture_output=True, + text=True, + ) + self.assertEqual(completed.returncode, 0, completed.stdout + completed.stderr) + self.assertIn("5 scenarios valid", completed.stdout) + + def test_lists_the_five_named_scenarios(self) -> None: + completed = subprocess.run( + [sys.executable, str(BENCHMARK), "--list"], + cwd=ROOT, + capture_output=True, + text=True, + check=True, + ) + lines = [line for line in completed.stdout.splitlines() if line.strip()] + self.assertEqual(len(lines), 5) + self.assertTrue(any(line.startswith("webhook-idempotency:") for line in lines)) + + def test_reads_historical_team_metrics_after_team_delete(self) -> None: + benchmark = load_benchmark_module() + with tempfile.TemporaryDirectory() as temp_dir: + workspace = Path(temp_dir) + team_dir = workspace / ".clawd" / "teams" / "team-1" + (team_dir / "agents").mkdir(parents=True) + (team_dir / "messages").mkdir() + (team_dir / "agents" / "worker.json").write_text("{}", encoding="utf-8") + (team_dir / "messages" / "handoff.json").write_text("{}", encoding="utf-8") + (team_dir / "team.json").write_text( + json.dumps( + { + "team_id": "team-1", + "status": "cancelled", + "usage": {"input_tokens": 11, "output_tokens": 7, "turns": 3}, + } + ), + encoding="utf-8", + ) + (team_dir / "tasks.json").write_text( + json.dumps({"task-1": {"status": "completed"}}), encoding="utf-8" + ) + (team_dir / "events.jsonl").write_text( + '{"type":"team.failed"}\n{"type":"team.cancelled"}\n', encoding="utf-8" + ) + + metrics = benchmark._load_team_metrics(workspace) + + self.assertTrue(metrics["present"]) + self.assertFalse(metrics["active"]) + self.assertEqual(metrics["status"], "cancelled") + self.assertEqual(metrics["worker_usage"]["input_tokens"], 11) + self.assertEqual(metrics["failed_events"], 1) + self.assertEqual(metrics["cancelled_events"], 1) + + def test_adaptive_prompt_leaves_team_shape_to_the_lead(self) -> None: + benchmark = load_benchmark_module() + scenario = benchmark.load_scenarios(["config-migration"])[0] + workspace = Path(scenario["fixture"]) + + prompt = benchmark.build_prompt(workspace, "adaptive") + normalized = " ".join(prompt.split()) + + self.assertIn("decide whether this task benefits from a team", normalized) + self.assertIn("valid to solve it directly", normalized) + self.assertIn("communication topology should emerge", normalized) + self.assertNotIn("exactly these three teammates", prompt) + self.assertTrue(benchmark._protocol_ok("adaptive", {"present": False})) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_teammate_resilience.py b/tests/test_teammate_resilience.py new file mode 100644 index 0000000..2283e7e --- /dev/null +++ b/tests/test_teammate_resilience.py @@ -0,0 +1,823 @@ +from __future__ import annotations + +import threading +import time +import tempfile +import unittest +import subprocess +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from src.providers.base import ChatResponse +from src.teammate.control import reassign_task +from src.teammate.models import TeamTask +from src.teammate.runtime import TeammateRuntime +from src.tool_system.context import ToolContext +from src.tool_system.defaults import build_default_registry +from src.tool_system.tools import ( + TaskCreateTool, + TaskRetryTool, + TaskUpdateTool, + TeamCancelTool, + TeamCreateTool, + TeamResumeTool, + TeamRunTool, + TeammateCreateTool, + TeammateResumeTool, + TeammateStopTool, +) +from src.tool_system.errors import ToolInputError + + +class FinalProvider: + model = "test-model" + + def __init__(self, *, delay: float = 0.0, tokens: int = 2) -> None: + self.delay = delay + self.tokens = tokens + self.calls = 0 + + def chat(self, messages, tools=None, **kwargs): + self.calls += 1 + if self.delay: + time.sleep(self.delay) + return ChatResponse( + content="done", + model=self.model, + usage={"input_tokens": self.tokens - 1, "output_tokens": 1}, + finish_reason="stop", + tool_uses=None, + ) + + +class FailOnceProvider(FinalProvider): + def chat(self, messages, tools=None, **kwargs): + self.calls += 1 + if self.calls == 1: + raise RuntimeError("transient provider failure") + return ChatResponse( + content="recovered", + model=self.model, + usage={"input_tokens": 1, "output_tokens": 1}, + finish_reason="stop", + tool_uses=None, + ) + + +class ParallelProvider(FinalProvider): + def __init__(self) -> None: + super().__init__(delay=0.08) + self.active = 0 + self.max_active = 0 + self.lock = threading.Lock() + + def chat(self, messages, tools=None, **kwargs): + with self.lock: + self.calls += 1 + self.active += 1 + self.max_active = max(self.max_active, self.active) + try: + time.sleep(self.delay) + return ChatResponse( + content="parallel done", + model=self.model, + usage={"input_tokens": 1, "output_tokens": 1}, + finish_reason="stop", + tool_uses=None, + ) + finally: + with self.lock: + self.active -= 1 + + +class WorktreeProvider(FinalProvider): + def __init__(self) -> None: + super().__init__() + self.responses = [ + ChatResponse( + content="", + model=self.model, + usage={"input_tokens": 1, "output_tokens": 1}, + finish_reason="tool_use", + tool_uses=[ + { + "id": "write-isolated", + "name": "Write", + "input": { + "file_path": "isolated.txt", + "content": "created in teammate worktree\n", + }, + } + ], + ), + ChatResponse( + content="implemented", + model=self.model, + usage={"input_tokens": 1, "output_tokens": 1}, + finish_reason="stop", + tool_uses=None, + ), + ] + + def chat(self, messages, tools=None, **kwargs): + self.calls += 1 + return self.responses.pop(0) + + +class ToolThenFinalProvider(FinalProvider): + def chat(self, messages, tools=None, **kwargs): + self.calls += 1 + if self.calls == 1: + return ChatResponse( + content="", + model=self.model, + usage={"input_tokens": 1, "output_tokens": 1}, + finish_reason="tool_use", + tool_uses=[ + { + "id": "read-base", + "name": "Read", + "input": {"file_path": "input.txt"}, + } + ], + ) + return ChatResponse( + content="done", + model=self.model, + usage={"input_tokens": 1, "output_tokens": 1}, + finish_reason="stop", + tool_uses=None, + ) + + +class BlockingProvider(FinalProvider): + def __init__(self) -> None: + super().__init__() + self.started = threading.Event() + self.release = threading.Event() + + def chat(self, messages, tools=None, **kwargs): + self.calls += 1 + self.started.set() + self.release.wait(timeout=2) + return ChatResponse( + content="finished after cancellation", + model=self.model, + usage={"input_tokens": 1, "output_tokens": 1}, + finish_reason="stop", + tool_uses=None, + ) + + +class SelectiveBlockingProvider(FinalProvider): + def __init__(self) -> None: + super().__init__() + self.started = threading.Event() + self.release = threading.Event() + self.lock = threading.Lock() + + def chat(self, messages, tools=None, **kwargs): + with self.lock: + self.calls += 1 + if "stop-me" in str(messages): + self.started.set() + self.release.wait(timeout=2) + content = "stopped worker returned" + else: + content = "survivor completed" + return ChatResponse( + content=content, + model=self.model, + usage={"input_tokens": 1, "output_tokens": 1}, + finish_reason="stop", + tool_uses=None, + ) + + +class SequenceProvider(FinalProvider): + def __init__(self, responses: list[ChatResponse]) -> None: + super().__init__() + self.responses = responses + + def chat(self, messages, tools=None, **kwargs): + self.calls += 1 + return self.responses.pop(0) + + +def _tool_response(call_id: str, name: str, tool_input: dict) -> ChatResponse: + return ChatResponse( + content="", + model="test-model", + usage={"input_tokens": 1, "output_tokens": 1}, + finish_reason="tool_use", + tool_uses=[{"id": call_id, "name": name, "input": tool_input}], + ) + + +def _final_response(content: str) -> ChatResponse: + return ChatResponse( + content=content, + model="test-model", + usage={"input_tokens": 1, "output_tokens": 1}, + finish_reason="stop", + tool_uses=None, + ) + + +class TestTeammateResilience(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name).resolve() + self.registry = build_default_registry(include_user_tools=False) + self.context = ToolContext(workspace_root=self.root) + self.team = TeamCreateTool().run({"team_name": "resilience"}, self.context).output + + def tearDown(self) -> None: + self.tmp.cleanup() + + def _agent(self, name: str) -> dict: + return TeammateCreateTool().run( + { + "name": name, + "role": "worker", + "instructions": "Complete the assigned task.", + "tools": ["Read"], + }, + self.context, + ).output + + def _task(self, key: str, owner: str) -> str: + return TaskCreateTool().run( + { + "key": key, + "subject": key, + "description": f"Complete {key}", + "owner": owner, + }, + self.context, + ).output["task"]["id"] + + def _runtime(self, provider) -> None: + self.context.teammate_runtime = TeammateRuntime(provider, self.registry) + + def test_completed_team_reopens_for_a_late_task(self) -> None: + self._agent("worker") + first_task = self._task("first", "worker") + provider = FinalProvider() + self._runtime(provider) + + first = TeamRunTool().run({}, self.context) + self.assertEqual(first.output["status"], "completed") + self.assertEqual(self.context.tasks[first_task]["status"], "completed") + + late_task = self._task("late", "worker") + second = TeamRunTool().run({}, self.context) + + self.assertFalse(second.is_error) + self.assertEqual(second.output["status"], "completed") + self.assertEqual(second.output["executed_task_ids"], [late_task]) + self.assertEqual(self.context.tasks[late_task]["status"], "completed") + self.assertEqual(provider.calls, 2) + events = self.context.team_store.list_events(self.team["team_id"]) + reopened = [event for event in events if event["type"] == "team.reopened"] + self.assertEqual(len(reopened), 1) + self.assertEqual(reopened[0]["data"]["unfinished_task_ids"], [late_task]) + + def test_teammate_model_must_match_runtime_allowlist(self) -> None: + self.context.teammate_runtime = TeammateRuntime( + FinalProvider(), + self.registry, + allowed_models={"served-model"}, + ) + + with self.assertRaisesRegex(ToolInputError, "unsupported teammate model"): + TeammateCreateTool().run( + { + "name": "wrong-model", + "role": "worker", + "instructions": "Complete the task.", + "tools": ["Read"], + "model": "claude-3-7-sonnet-20250219", + }, + self.context, + ) + + created = TeammateCreateTool().run( + { + "name": "right-model", + "role": "worker", + "instructions": "Complete the task.", + "tools": ["Read"], + "model": "served-model", + }, + self.context, + ) + self.assertEqual(created.output["name"], "right-model") + + def test_runtime_enforces_minimum_team_timeout(self) -> None: + self._agent("worker") + self._task("bounded", "worker") + self.context.teammate_runtime = TeammateRuntime( + FinalProvider(), + self.registry, + minimum_timeout_s=900, + ) + + result = TeamRunTool().run({"timeout_s": 120}, self.context) + + self.assertEqual(result.output["status"], "completed") + team = self.context.team_store.load_team(self.team["team_id"]) + self.assertEqual(team.settings["timeout_s"], 900) + events = self.context.team_store.list_events(self.team["team_id"]) + adjusted = [event for event in events if event["type"] == "team.options_adjusted"] + self.assertEqual(adjusted[0]["data"]["timeout_s"]["requested"], 120) + self.assertEqual(adjusted[0]["data"]["timeout_s"]["effective"], 900) + + def test_recovers_expired_in_progress_lease(self) -> None: + agent = self._agent("worker") + task_id = self._task("recover", "worker") + task = TeamTask.from_dict(self.context.tasks[task_id]) + task.transition_to("in_progress") + task.attempt = 1 + task.lease_id = "dead-run" + task.lease_expires_at = ( + datetime.now(timezone.utc) - timedelta(seconds=1) + ).isoformat() + self.context.team_store.update_task(self.team["team_id"], task) + stored_agent = self.context.team_store.load_agent(self.team["team_id"], agent["agent_id"]) + stored_agent.transition_to("running") + self.context.team_store.save_agent(stored_agent) + + self._runtime(FinalProvider()) + result = TeamResumeTool().run({}, self.context) + + self.assertFalse(result.is_error) + restored = self.context.team_store.load_tasks(self.team["team_id"])[task_id] + self.assertEqual(restored["status"], "completed") + self.assertEqual(restored["attempt"], 2) + self.assertIsNone(restored["lease_id"]) + events = self.context.team_store.list_events(self.team["team_id"]) + self.assertIn("task.recovered", {event["type"] for event in events}) + + def test_automatically_retries_transient_failure(self) -> None: + self._agent("worker") + task_id = self._task("retry", "worker") + provider = FailOnceProvider() + self._runtime(provider) + + result = TeamRunTool().run({"max_retries": 1}, self.context) + + self.assertFalse(result.is_error) + task = self.context.team_store.load_tasks(self.team["team_id"])[task_id] + self.assertEqual(task["status"], "completed") + self.assertEqual(task["attempt"], 2) + self.assertEqual(provider.calls, 2) + events = self.context.team_store.list_events(self.team["team_id"]) + self.assertIn("task.retry_scheduled", {event["type"] for event in events}) + + def test_manual_task_retry_and_team_resume(self) -> None: + self._agent("worker") + task_id = self._task("manual", "worker") + task = TeamTask.from_dict(self.context.tasks[task_id]) + task.transition_to("in_progress") + task.transition_to("failed") + task.output = "failed before restart" + self.context.team_store.update_task(self.team["team_id"], task) + team = self.context.team_store.load_team(self.team["team_id"]) + team.transition_to("running") + team.transition_to("failed") + self.context.team_store.save_team(team) + self.context.reload_team_state() + + retried = TaskRetryTool().run({"taskId": "manual"}, self.context) + self.assertEqual(retried.output["status"], "pending") + self._runtime(FinalProvider()) + resumed = TeamResumeTool().run({}, self.context) + + self.assertFalse(resumed.is_error) + self.assertEqual( + self.context.team_store.load_tasks(self.team["team_id"])[task_id]["status"], + "completed", + ) + + def test_cancelled_team_can_be_resumed(self) -> None: + self._agent("worker") + self._task("cancel", "worker") + cancelled = TeamCancelTool().run({"reason": "operator request"}, self.context) + self.assertEqual(cancelled.output["status"], "cancelled") + + self._runtime(FinalProvider()) + resumed = TeamResumeTool().run({}, self.context) + + self.assertFalse(resumed.is_error) + self.assertEqual(resumed.output["status"], "completed") + + def test_token_budget_overrun_after_completion_is_reported(self) -> None: + self._agent("worker") + self._task("budget", "worker") + self._runtime(FinalProvider(tokens=10)) + + result = TeamRunTool().run({"token_budget": 5}, self.context) + + self.assertFalse(result.is_error) + self.assertIn("token budget", result.output["budget_warning"]) + team = self.context.team_store.load_team(self.team["team_id"]) + self.assertEqual(team.status, "completed") + self.assertEqual(team.usage["total_tokens"], 10) + events = self.context.team_store.list_events(self.team["team_id"]) + self.assertTrue( + any(event["type"] == "team.budget_exceeded_after_completion" for event in events) + ) + + def test_timeout_overrun_preserves_completed_result_with_warning(self) -> None: + self._agent("worker") + self._task("timeout", "worker") + self._runtime(FinalProvider(delay=0.04)) + + result = self.context.teammate_runtime.run_team(self.context, timeout_s=0.01) + + self.assertEqual(result["status"], "completed") + self.assertIn("timeout", result["budget_warning"]) + + def test_turn_budget_limits_model_round_trips(self) -> None: + (self.root / "input.txt").write_text("input\n", encoding="utf-8") + self._agent("worker") + self._task("turn-budget", "worker") + self._runtime(ToolThenFinalProvider()) + + result = TeamRunTool().run({"turn_budget": 1}, self.context) + + self.assertTrue(result.is_error) + self.assertIn("Max tool turns", result.output["error"]) + self.assertEqual(result.output["usage"]["turns"], 1) + + def test_cooperative_cancel_is_observed_after_active_model_call(self) -> None: + self._agent("worker") + task_id = self._task("cancel-active", "worker") + provider = BlockingProvider() + self._runtime(provider) + result_box: list = [] + + thread = threading.Thread( + target=lambda: result_box.append(TeamRunTool().run({}, self.context)), + daemon=True, + ) + thread.start() + self.assertTrue(provider.started.wait(timeout=1)) + cancelling_context = ToolContext(workspace_root=self.root) + TeamCancelTool().run({"reason": "stop active run"}, cancelling_context) + provider.release.set() + thread.join(timeout=2) + + self.assertFalse(thread.is_alive()) + self.assertEqual(result_box[0].output["status"], "cancelled") + task = self.context.team_store.load_tasks(self.team["team_id"])[task_id] + self.assertEqual(task["status"], "cancelled") + + def test_lead_stops_one_worker_without_cancelling_team(self) -> None: + stopped = self._agent("stopped-worker") + self._agent("survivor") + stopped_task = self._task("stop-me", "stopped-worker") + survivor_task = self._task("keep-going", "survivor") + provider = SelectiveBlockingProvider() + self._runtime(provider) + holder: dict[str, object] = {} + + def run_team() -> None: + holder["result"] = TeamRunTool().run( + {"max_workers": 2}, self.context + ).output + + thread = threading.Thread(target=run_team) + thread.start() + self.assertTrue(provider.started.wait(timeout=1)) + stop_context = ToolContext(workspace_root=self.root) + stopped_result = TeammateStopTool().run( + { + "teammate": stopped["agent_id"], + "task_policy": "requeue", + "reason": "lead replaced this worker", + }, + stop_context, + ).output + self.assertEqual(stopped_result["status"], "stopping") + provider.release.set() + thread.join(timeout=3) + self.assertFalse(thread.is_alive()) + + tasks = self.context.team_store.load_tasks(self.team["team_id"]) + self.assertEqual(tasks[stopped_task]["status"], "pending") + self.assertIsNone(tasks[stopped_task]["owner"]) + self.assertEqual(tasks[survivor_task]["status"], "completed") + agent = self.context.team_store.load_agent( + self.team["team_id"], stopped["agent_id"] + ) + self.assertEqual(agent.status, "cancelled") + self.assertIsNotNone(agent.stopped_at) + self.assertEqual(holder["result"]["status"], "blocked") + self.assertEqual( + self.context.team_store.load_active_team().status, + "running", + ) + event_types = [ + event["type"] + for event in self.context.team_store.list_events(self.team["team_id"]) + ] + self.assertIn("agent.stop_requested", event_types) + self.assertIn("agent.stopped", event_types) + self.assertIn("run.cancelled", event_types) + self.assertIn("task.requeued", event_types) + self.assertEqual(event_types.count("agent.stopped"), 1) + self.assertEqual(event_types.count("task.requeued"), 1) + + def test_stop_cancel_policy_and_worker_permissions(self) -> None: + worker = self._agent("worker") + task_id = self._task("cancel-me", "worker") + child_context = ToolContext( + workspace_root=self.root, + actor_id=worker["agent_id"], + ) + with self.assertRaisesRegex(ToolInputError, "only the lead"): + TeammateStopTool().run({"teammate": "worker"}, child_context) + + result = TeammateStopTool().run( + {"teammate": "worker", "task_policy": "cancel"}, + self.context, + ).output + self.assertEqual(result["cancelled_task_ids"], [task_id]) + task = self.context.team_store.load_tasks(self.team["team_id"])[task_id] + self.assertEqual(task["status"], "cancelled") + self.assertEqual( + self.context.team_store.load_active_team().status, + "created", + ) + + def test_stopped_worker_can_resume_and_receive_requeued_task(self) -> None: + self._agent("worker") + replacement = self._agent("replacement") + task_id = self._task("handoff", "worker") + TeammateStopTool().run( + {"teammate": "worker", "task_policy": "requeue"}, + self.context, + ) + resumed = TeammateResumeTool().run( + {"teammate": "worker"}, self.context + ).output + self.assertEqual(resumed["status"], "idle") + reassigned = reassign_task( + self.context.team_store, + task_id, + replacement["agent_id"], + ) + self.assertEqual(reassigned["owner"], replacement["agent_id"]) + task = self.context.team_store.load_tasks(self.team["team_id"])[task_id] + self.assertEqual(task["status"], "pending") + self.assertEqual(task["owner"], replacement["agent_id"]) + + def test_ready_tasks_run_in_parallel_without_lost_updates(self) -> None: + self._agent("one") + self._agent("two") + one = self._task("one", "one") + two = self._task("two", "two") + provider = ParallelProvider() + self._runtime(provider) + + result = TeamRunTool().run({"max_workers": 2}, self.context) + + self.assertFalse(result.is_error) + self.assertGreaterEqual(provider.max_active, 2) + tasks = self.context.team_store.load_tasks(self.team["team_id"]) + self.assertEqual(tasks[one]["status"], "completed") + self.assertEqual(tasks[two]["status"], "completed") + self.assertEqual(result.output["usage"]["turns"], 2) + + def test_same_teammate_tasks_are_serialized(self) -> None: + self._agent("one") + self._task("first", "one") + self._task("second", "one") + provider = ParallelProvider() + self._runtime(provider) + + result = TeamRunTool().run({"max_workers": 2}, self.context) + + self.assertFalse(result.is_error) + self.assertEqual(provider.max_active, 1) + self.assertEqual(provider.calls, 2) + + def test_active_lease_blocks_second_runner_without_failing_team(self) -> None: + self._agent("worker") + task_id = self._task("leased", "worker") + task = self.context.team_store.claim_task( + self.team["team_id"], + task_id, + lease_id="active-run", + lease_expires_at=( + datetime.now(timezone.utc) + timedelta(minutes=5) + ).isoformat(), + max_retries=0, + ) + self.assertIsNotNone(task) + team = self.context.team_store.load_team(self.team["team_id"]) + team.transition_to("running") + self.context.team_store.save_team(team) + self._runtime(FinalProvider()) + + result = TeamRunTool().run({}, self.context) + + self.assertTrue(result.is_error) + self.assertEqual(result.output["status"], "blocked") + self.assertEqual( + self.context.team_store.load_team(self.team["team_id"]).status, + "running", + ) + + def test_task_claim_is_atomic_across_store_instances(self) -> None: + self._agent("worker") + task_id = self._task("claim", "worker") + claimed: list[TeamTask | None] = [] + barrier = threading.Barrier(2) + + def claim(label: str) -> None: + context = ToolContext(workspace_root=self.root) + barrier.wait(timeout=1) + claimed.append( + context.team_store.claim_task( + self.team["team_id"], + task_id, + lease_id=label, + lease_expires_at=( + datetime.now(timezone.utc) + timedelta(minutes=5) + ).isoformat(), + max_retries=0, + ) + ) + + threads = [ + threading.Thread(target=claim, args=("one",)), + threading.Thread(target=claim, args=("two",)), + ] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=2) + + self.assertEqual(sum(item is not None for item in claimed), 1) + + def test_reviewer_rejection_can_drive_repair_and_re_review(self) -> None: + self._agent("coder") + self._agent("reviewer") + implementation = self._task("implementation", "coder") + review = TaskCreateTool().run( + { + "key": "review", + "subject": "review", + "description": "Review implementation", + "owner": "reviewer", + "blockedBy": ["implementation"], + }, + self.context, + ).output["task"]["id"] + provider = SequenceProvider( + [ + _final_response("initial implementation"), + _tool_response( + "reject-message", + "SendMessage", + {"to": "coder", "summary": "changes requested", "message": "Fix edge case."}, + ), + _tool_response( + "fail-review", + "TaskUpdate", + {"taskId": review, "status": "failed", "output": "edge case remains"}, + ), + _final_response("review rejected"), + _tool_response( + "repair-message", + "SendMessage", + {"to": "reviewer", "summary": "repair", "message": "Edge case fixed."}, + ), + _final_response("repair complete"), + _tool_response( + "approval-message", + "SendMessage", + {"to": "lead", "summary": "approved", "message": "Repair approved."}, + ), + _final_response("review approved"), + ] + ) + self._runtime(provider) + + first = TeamRunTool().run({}, self.context) + self.assertTrue(first.is_error) + self.assertEqual( + self.context.team_store.load_tasks(self.team["team_id"])[review]["status"], + "failed", + ) + + self.context.reload_team_state() + repair = TaskCreateTool().run( + { + "key": "repair", + "subject": "repair", + "description": "Address reviewer feedback", + "owner": "coder", + "blockedBy": [implementation], + }, + self.context, + ).output["task"]["id"] + TaskUpdateTool().run( + {"taskId": review, "addBlockedBy": [repair]}, self.context + ) + TaskRetryTool().run({"taskId": review}, self.context) + + resumed = TeamResumeTool().run({}, self.context) + + self.assertFalse(resumed.is_error, resumed.output) + tasks = self.context.team_store.load_tasks(self.team["team_id"]) + self.assertEqual(tasks[repair]["status"], "completed") + self.assertEqual(tasks[review]["status"], "completed") + messages = self.context.team_store.list_messages(self.team["team_id"]) + self.assertEqual(len(messages), 3) + + +class TestTeammateWorktree(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name).resolve() + subprocess.run(["git", "init", "-q"], cwd=self.root, check=True) + (self.root / "base.txt").write_text("base\n", encoding="utf-8") + subprocess.run(["git", "add", "base.txt"], cwd=self.root, check=True) + subprocess.run( + [ + "git", + "-c", + "user.name=Test", + "-c", + "user.email=test@example.invalid", + "commit", + "-qm", + "base", + ], + cwd=self.root, + check=True, + ) + self.registry = build_default_registry(include_user_tools=False) + self.context = ToolContext(workspace_root=self.root) + self.team = TeamCreateTool().run({"team_name": "worktree"}, self.context).output + self.agent = None + + def tearDown(self) -> None: + if self.agent is not None: + from src.teammate.worktree import TeammateWorktreeManager + + stored = self.context.team_store.load_agent( + self.team["team_id"], self.agent["agent_id"] + ) + if stored is not None: + TeammateWorktreeManager(self.root).remove(stored, force=True) + self.tmp.cleanup() + + def test_auto_integrates_isolated_teammate_changes(self) -> None: + self.context.teammate_runtime = TeammateRuntime(WorktreeProvider(), self.registry) + self.agent = TeammateCreateTool().run( + { + "name": "coder", + "role": "implementation", + "instructions": "Create the requested file.", + "tools": ["Write"], + "workspace_mode": "worktree", + "auto_integrate": True, + }, + self.context, + ).output + TaskCreateTool().run( + { + "key": "isolated", + "subject": "isolated", + "description": "Create isolated.txt", + "owner": "coder", + }, + self.context, + ) + + result = TeamRunTool().run({}, self.context) + + self.assertFalse(result.is_error, result.output) + self.assertEqual( + (self.root / "isolated.txt").read_text(encoding="utf-8"), + "created in teammate worktree\n", + ) + log = subprocess.run( + ["git", "log", "-1", "--pretty=%s"], + cwd=self.root, + capture_output=True, + text=True, + check=True, + ).stdout.strip() + self.assertEqual(log, "clawd teammate coder: isolated") + events = self.context.team_store.list_events(self.team["team_id"]) + self.assertIn("worktree.integrated", {event["type"] for event in events}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_teammate_runtime.py b/tests/test_teammate_runtime.py new file mode 100644 index 0000000..2c218eb --- /dev/null +++ b/tests/test_teammate_runtime.py @@ -0,0 +1,208 @@ +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +from src.providers.base import ChatResponse +from src.teammate.runtime import TeammateRuntime +from src.tool_system.context import ToolContext +from src.tool_system.defaults import build_default_registry +from src.tool_system.errors import ToolInputError +from src.tool_system.tools import TaskCreateTool, TeamCreateTool, TeammateCreateTool, TeamRunTool + + +class ScriptedProvider: + model = "test-model" + + def __init__(self) -> None: + self.responses = [ + self._tool("research-message", "SendMessage", {"to": "coder", "summary": "rules", "message": "Shipping is not discountable."}), + self._final("Research complete."), + self._tool("coder-message", "SendMessage", {"to": "reviewer", "summary": "implementation", "message": "Pricing fixed; tests pass."}), + self._final("Implementation complete."), + self._tool("review-message", "SendMessage", {"to": "lead", "summary": "approval", "message": "Reviewed and approved."}), + self._final("Review complete."), + ] + self.calls: list[list[dict]] = [] + + @staticmethod + def _tool(call_id: str, name: str, tool_input: dict) -> ChatResponse: + return ChatResponse( + content="", + model="test-model", + usage={"input_tokens": 1, "output_tokens": 1}, + finish_reason="tool_use", + tool_uses=[{"id": call_id, "name": name, "input": tool_input}], + ) + + @staticmethod + def _final(content: str) -> ChatResponse: + return ChatResponse( + content=content, + model="test-model", + usage={"input_tokens": 1, "output_tokens": 1}, + finish_reason="stop", + tool_uses=None, + ) + + def chat(self, messages, tools=None, **kwargs): + self.calls.append(messages) + return self.responses.pop(0) + + +class TestTeammateRuntime(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name).resolve() + self.registry = build_default_registry(include_user_tools=False) + self.context = ToolContext(workspace_root=self.root) + + def tearDown(self) -> None: + self.tmp.cleanup() + + def test_runs_dependency_chain_with_independent_sessions_and_messages(self) -> None: + created = TeamCreateTool().run({"team_name": "order-repair"}, self.context).output + provider = ScriptedProvider() + self.context.teammate_runtime = TeammateRuntime(provider, self.registry) + with self.assertRaises(ToolInputError): + TeammateCreateTool().run( + { + "name": "invalid", + "role": "invalid", + "instructions": "Use a missing tool", + "tools": ["DoesNotExist"], + }, + self.context, + ) + tool_sets = { + "researcher": ["Read", "Glob", "Grep"], + "coder": ["Read", "Glob", "Grep", "Write", "Edit", "Bash"], + "reviewer": ["Read", "Glob", "Grep", "Bash"], + } + teammates = {} + for name, tools in tool_sets.items(): + teammates[name] = TeammateCreateTool().run( + { + "name": name, + "role": name, + "instructions": f"Act as the {name}.", + "tools": tools, + }, + self.context, + ).output + + analysis = TaskCreateTool().run( + {"key": "analysis", "subject": "Analysis", "description": "Inspect rules", "owner": "researcher"}, + self.context, + ).output["task"] + implementation = TaskCreateTool().run( + { + "key": "implementation", + "subject": "Implementation", + "description": "Repair pricing", + "owner": "coder", + "blockedBy": ["analysis"], + }, + self.context, + ).output["task"] + review = TaskCreateTool().run( + { + "key": "review", + "subject": "Review", + "description": "Review and test", + "owner": "reviewer", + "blockedBy": ["implementation"], + }, + self.context, + ).output["task"] + + first_batch = TeamRunTool().run({"max_batches": 1}, self.context) + self.assertEqual(first_batch.output["status"], "running") + self.assertEqual(first_batch.output["executed_task_ids"], [analysis["id"]]) + self.assertNotIn("max_batches", self.context.team["settings"]) + + second_batch = TeamRunTool().run({"max_batches": 1}, self.context) + self.assertEqual(second_batch.output["status"], "running") + self.assertEqual(second_batch.output["executed_task_ids"], [implementation["id"]]) + + result = TeamRunTool().run({}, self.context) + + self.assertFalse(result.is_error) + self.assertEqual(result.output["status"], "completed") + self.assertEqual( + first_batch.output["executed_task_ids"] + + second_batch.output["executed_task_ids"] + + result.output["executed_task_ids"], + [analysis["id"], implementation["id"], review["id"]], + ) + self.assertEqual({task["status"] for task in self.context.tasks.values()}, {"completed"}) + agents = self.context.team_store.list_agents(created["team_id"]) + self.assertEqual({agent.status for agent in agents}, {"completed"}) + + messages = self.context.team_store.list_messages(created["team_id"]) + self.assertEqual(len(messages), 3) + self.assertEqual([message.status for message in messages], ["consumed", "consumed", "delivered"]) + names = {agent.agent_id: agent.name for agent in agents} + names[created["lead_agent_id"]] = "lead" + self.assertEqual( + [(names[message.sender_id], names[message.recipient_id]) for message in messages], + [("researcher", "coder"), ("coder", "reviewer"), ("reviewer", "lead")], + ) + for teammate in teammates.values(): + session = self.context.team_store.load_session(created["team_id"], teammate["session_id"]) + self.assertGreaterEqual(len(session["conversation"]["messages"]), 3) + trace_events = self.context.team_store.list_events(created["team_id"]) + trace_types = {event["type"] for event in trace_events} + self.assertTrue({ + "run.started", + "model.started", + "model.response", + "tool.started", + "tool.completed", + "run.completed", + }.issubset(trace_types)) + self.assertEqual( + sum(event["type"] == "team.batch_paused" for event in trace_events), 2 + ) + send_message_events = [ + event for event in trace_events + if event["type"] == "tool.started" + and event["data"].get("tool_name") == "SendMessage" + ] + self.assertEqual( + [event["data"].get("actor_name") for event in send_message_events], + ["researcher", "coder", "reviewer"], + ) + self.assertEqual(provider.responses, []) + + acceptance = { + "required_agents": ["researcher", "coder", "reviewer"], + "required_tasks": [ + {"name": "analysis", "owner": "researcher", "blocked_by": []}, + {"name": "implementation", "owner": "coder", "blocked_by": ["analysis"]}, + {"name": "review", "owner": "reviewer", "blocked_by": ["implementation"]}, + ], + "required_messages": [ + {"from": "researcher", "to": "coder"}, + {"from": "coder", "to": "reviewer"}, + {"from": "reviewer", "to": "lead"}, + ], + "required_test_command": "python -m unittest checks.order_acceptance -v", + "required_final_status": "completed", + } + (self.root / "acceptance.json").write_text(json.dumps(acceptance), encoding="utf-8") + evaluator = Path(__file__).parents[1] / "teammate-evals" / "order-discount" / "evaluate.py" + checked = subprocess.run( + [sys.executable, str(evaluator), "--workspace", str(self.root), "--collaboration-only"], + capture_output=True, + text=True, + ) + self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_teammate_store.py b/tests/test_teammate_store.py new file mode 100644 index 0000000..51abb81 --- /dev/null +++ b/tests/test_teammate_store.py @@ -0,0 +1,341 @@ +from __future__ import annotations + +import json +import tempfile +import unittest +from pathlib import Path + +from src.teammate.models import AgentRecord, Message, Team, TeamTask +from src.tool_system.context import ToolContext +from src.tool_system.errors import ToolInputError +from src.tool_system.tools import ( + ReadMessagesTool, + SendMessageTool, + TaskCreateTool, + TaskGetTool, + TaskOutputTool, + TaskRetryTool, + TaskUpdateTool, + TeamCancelTool, + TeamCreateTool, + TeamDeleteTool, + TeamIntegrateTool, + TeammateCreateTool, + TeammateResumeTool, + TeammateStopTool, + TeamRunTool, + TeamResumeTool, +) + + +class TestTeammateModels(unittest.TestCase): + def test_team_state_machine_allows_reopen_but_rejects_invalid_transition(self) -> None: + team = Team(team_id="team-1", team_name="demo", lead_agent_id="lead-1") + team.transition_to("running") + team.transition_to("completed") + team.transition_to("running") + + with self.assertRaises(ValueError): + team.transition_to("created") + + def test_agent_and_message_validate_status(self) -> None: + with self.assertRaises(ValueError): + AgentRecord( + agent_id="agent-1", + team_id="team-1", + name="researcher", + role="research", + session_id="session-1", + status="unknown", + ) + + agent = AgentRecord( + agent_id="agent-2", + team_id="team-1", + name="coder", + role="implementation", + session_id="session-2", + ) + agent.transition_to("running") + agent.transition_to("stopping") + agent.transition_to("cancelled") + self.assertEqual(agent.status, "cancelled") + with self.assertRaises(ValueError): + Message( + message_id="message-1", + team_id="team-1", + sender_id="lead-1", + recipient_id="agent-1", + content="hello", + status="unknown", + ) + + def test_task_and_message_state_machines(self) -> None: + task = TeamTask(id="task-1", subject="Inspect", description="Inspect runtime") + task.transition_to("in_progress") + task.transition_to("completed") + with self.assertRaises(ValueError): + task.transition_to("in_progress") + + message = Message( + message_id="message-1", + team_id="team-1", + sender_id="lead-1", + recipient_id="agent-1", + content="hello", + ) + message.transition_to("delivered") + message.transition_to("consumed") + self.assertIsNotNone(message.delivered_at) + self.assertIsNotNone(message.consumed_at) + + +class TestTeamPersistence(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name).resolve() + + def tearDown(self) -> None: + self.tmp.cleanup() + + def test_team_layout_and_task_state_survive_context_restart(self) -> None: + context = ToolContext(workspace_root=self.root) + created = TeamCreateTool().run( + {"team_name": "demo", "description": "persistent team"}, context + ).output + team_id = created["team_id"] + team_dir = self.root / ".clawd" / "teams" / team_id + + self.assertTrue((self.root / ".clawd" / "team.json").is_file()) + self.assertTrue((team_dir / "team.json").is_file()) + self.assertTrue((team_dir / "tasks.json").is_file()) + self.assertTrue((team_dir / "events.jsonl").is_file()) + self.assertTrue((team_dir / "agents").is_dir()) + self.assertTrue((team_dir / "sessions").is_dir()) + self.assertTrue((team_dir / "messages").is_dir()) + self.assertEqual(context.team_store.list_events(team_id)[0]["type"], "team.created") + + task = TaskCreateTool().run( + {"subject": "Inspect", "description": "Inspect the runtime"}, context + ).output["task"] + TaskUpdateTool().run( + {"taskId": task["id"], "status": "in_progress", "owner": "lead"}, context + ) + + restored = ToolContext(workspace_root=self.root) + self.assertEqual(restored.team["team_id"], team_id) + self.assertEqual(restored.tasks[task["id"]]["status"], "in_progress") + self.assertEqual(restored.tasks[task["id"]]["owner"], created["lead_agent_id"]) + + def test_disband_removes_active_pointer_and_preserves_history(self) -> None: + context = ToolContext(workspace_root=self.root) + team_id = TeamCreateTool().run({"team_name": "demo"}, context).output["team_id"] + + result = TeamDeleteTool().run({}, context).output + + self.assertTrue(result["success"]) + self.assertFalse((self.root / ".clawd" / "team.json").exists()) + archived_path = self.root / ".clawd" / "teams" / team_id / "team.json" + archived = json.loads(archived_path.read_text(encoding="utf-8")) + self.assertEqual(archived["status"], "cancelled") + + def test_agent_records_roundtrip_through_team_store(self) -> None: + context = ToolContext(workspace_root=self.root) + team_id = TeamCreateTool().run({"team_name": "demo"}, context).output["team_id"] + agent = AgentRecord( + agent_id="researcher-1", + team_id=team_id, + name="researcher", + role="research", + session_id="session-1", + ) + + context.team_store.save_agent(agent) + + restored = context.team_store.load_agent(team_id, agent.agent_id) + self.assertEqual(restored, agent) + self.assertEqual(context.team_store.list_agents(team_id), [agent]) + + def test_messages_and_sessions_roundtrip_through_team_store(self) -> None: + context = ToolContext(workspace_root=self.root) + created = TeamCreateTool().run({"team_name": "demo"}, context).output + teammate = TeammateCreateTool().run( + { + "name": "researcher", + "role": "research", + "instructions": "Inspect only", + "tools": ["Read"], + }, + context, + ).output + + result = SendMessageTool().run( + {"to": "researcher", "summary": "start", "message": "Inspect requirements"}, + context, + ).output + + stored = context.team_store.load_message(created["team_id"], result["message_id"]) + self.assertEqual(stored.sender_id, created["lead_agent_id"]) + self.assertEqual(stored.recipient_id, teammate["agent_id"]) + self.assertEqual(stored.status, "delivered") + session = context.team_store.load_session(created["team_id"], teammate["session_id"]) + self.assertEqual(session["agent_id"], teammate["agent_id"]) + + def test_creation_tools_explain_how_to_start_worker_execution(self) -> None: + context = ToolContext(workspace_root=self.root) + + created = TeamCreateTool().run({"team_name": "guided"}, context).output + self.assertFalse(created["team_started"]) + self.assertEqual( + [item["tool"] for item in created["next_required_actions"]], + ["TeammateCreate", "TaskCreate", "TeamRun"], + ) + + teammate = TeammateCreateTool().run( + { + "name": "reviewer", + "role": "review", + "instructions": "Review the implementation", + "tools": ["Read"], + }, + context, + ).output + self.assertFalse(teammate["worker_started"]) + self.assertEqual( + [item["tool"] for item in teammate["next_required_actions"]], + ["TaskCreate", "TeamRun"], + ) + + task = TaskCreateTool().run( + { + "key": "review", + "subject": "Review", + "description": "Review the implementation", + "owner": "reviewer", + }, + context, + ).output + self.assertFalse(task["task_started"]) + self.assertEqual(task["next_required_actions"][0]["tool"], "TeamRun") + + def test_lead_does_not_wait_for_a_worker_that_has_not_started(self) -> None: + context = ToolContext(workspace_root=self.root) + TeamCreateTool().run({"team_name": "idle"}, context) + TeammateCreateTool().run( + { + "name": "reviewer", + "role": "review", + "instructions": "Review the implementation", + "tools": ["Read", "SendMessage"], + }, + context, + ) + + output = ReadMessagesTool().run({"wait_s": 1}, context).output + + self.assertEqual(output["messages"], []) + self.assertTrue(output["wait_skipped"]) + self.assertEqual(output["next_required_actions"][0]["tool"], "TaskCreate") + + def test_teammates_can_exchange_and_poll_direct_messages(self) -> None: + lead_context = ToolContext(workspace_root=self.root) + created = TeamCreateTool().run({"team_name": "dynamic"}, lead_context).output + first = TeammateCreateTool().run( + { + "name": "frontend", + "role": "frontend contract", + "instructions": "Coordinate the API contract", + "tools": ["Read"], + }, + lead_context, + ).output + second = TeammateCreateTool().run( + { + "name": "backend", + "role": "backend contract", + "instructions": "Coordinate the API contract", + "tools": ["Read"], + }, + lead_context, + ).output + sender = ToolContext(workspace_root=self.root, actor_id=first["agent_id"]) + receiver = ToolContext(workspace_root=self.root, actor_id=second["agent_id"]) + + SendMessageTool().run( + { + "to": "backend", + "summary": "interface", + "message": {"path": "/orders", "method": "POST"}, + }, + sender, + ) + inbox = ReadMessagesTool().run({"wait_s": 0}, receiver).output["messages"] + + self.assertEqual(len(inbox), 1) + self.assertEqual(inbox[0]["from"], "frontend") + self.assertEqual(inbox[0]["message"]["path"], "/orders") + self.assertEqual(ReadMessagesTool().run({}, receiver).output["messages"], []) + events = lead_context.team_store.list_events(created["team_id"]) + self.assertTrue(any(event["type"] == "message.consumed" for event in events)) + + def test_task_tools_accept_stable_keys_and_done_alias(self) -> None: + context = ToolContext(workspace_root=self.root) + TeamCreateTool().run({"team_name": "dynamic"}, context) + task = TaskCreateTool().run( + { + "key": "backend-contract", + "subject": "Backend contract", + "description": "Implement the agreed contract", + }, + context, + ).output["task"] + + updated = TaskUpdateTool().run( + {"taskId": "backend-contract", "status": "done", "output": "ready"}, context + ).output + + self.assertEqual(updated["taskId"], task["id"]) + self.assertEqual(updated["statusChange"]["to"], "completed") + self.assertEqual( + TaskGetTool().run({"taskId": "backend-contract"}, context).output["task"]["status"], + "completed", + ) + self.assertEqual( + TaskOutputTool().run({"task_id": "backend-contract"}, context).output["task"]["output"], + "ready", + ) + + def test_task_tool_enforces_state_transitions_without_partial_update(self) -> None: + context = ToolContext(workspace_root=self.root) + TeamCreateTool().run({"team_name": "demo"}, context) + task_id = TaskCreateTool().run( + {"subject": "Inspect", "description": "Inspect runtime"}, context + ).output["task"]["id"] + TaskUpdateTool().run({"taskId": task_id, "status": "completed"}, context) + + with self.assertRaises(ToolInputError): + TaskUpdateTool().run( + {"taskId": task_id, "status": "in_progress", "subject": "Changed"}, context + ) + + self.assertEqual(context.tasks[task_id]["subject"], "Inspect") + + def test_mutating_tools_are_not_marked_read_only(self) -> None: + self.assertFalse(TeamCreateTool().spec().is_read_only) + self.assertFalse(TeamCancelTool().spec().is_read_only) + self.assertFalse(TeamDeleteTool().spec().is_read_only) + self.assertFalse(TeamIntegrateTool().spec().is_read_only) + self.assertFalse(TaskCreateTool().spec().is_read_only) + self.assertFalse(TaskUpdateTool().spec().is_read_only) + self.assertFalse(TaskRetryTool().spec().is_read_only) + self.assertFalse(ReadMessagesTool().spec().is_read_only) + self.assertFalse(SendMessageTool().spec().is_read_only) + self.assertFalse(TeammateCreateTool().spec().is_read_only) + self.assertFalse(TeammateStopTool().spec().is_read_only) + self.assertFalse(TeammateResumeTool().spec().is_read_only) + self.assertFalse(TeamRunTool().spec().is_read_only) + self.assertFalse(TeamResumeTool().spec().is_read_only) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_teammate_viewer.py b/tests/test_teammate_viewer.py new file mode 100644 index 0000000..54c9177 --- /dev/null +++ b/tests/test_teammate_viewer.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import json +import tempfile +import threading +import unittest +import urllib.request +from pathlib import Path + +from src.agent.conversation import Conversation, TextContentBlock, ToolUseContentBlock +from src.teammate.models import AgentRecord, Message +from src.teammate.trace import redact_trace_value +from src.teammate.viewer import build_trace_snapshot, create_trace_server +from src.tool_system.context import ToolContext +from src.tool_system.tools import TaskCreateTool, TeamCreateTool + + +class TestTeammateTraceViewer(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + self.context = ToolContext(workspace_root=self.root) + self.team = TeamCreateTool().run({"team_name": "trace-demo"}, self.context).output + self.agent = AgentRecord( + agent_id="researcher-1", + team_id=self.team["team_id"], + name="researcher", + role="research", + session_id="session-1", + model="test-model", + ) + self.context.team_store.save_agent(self.agent) + + def tearDown(self) -> None: + self.tmp.cleanup() + + def test_reconstructs_legacy_tool_events_from_session(self) -> None: + task = TaskCreateTool().run( + {"key": "analysis", "subject": "Inspect", "description": "Inspect files", "owner": self.agent.agent_id}, + self.context, + ).output["task"] + conversation = Conversation() + conversation.add_user_message("Inspect requirements") + conversation.add_assistant_message([ + TextContentBlock(text="I will inspect the file."), + ToolUseContentBlock(id="read-1", name="Read", input={"file_path": "requirements.md"}), + ]) + conversation.add_tool_result_message("read-1", json.dumps({"content": "rule one"})) + self.context.team_store.save_session( + self.team["team_id"], + self.agent.session_id, + { + "session_id": self.agent.session_id, + "team_id": self.team["team_id"], + "agent_id": self.agent.agent_id, + "model": "test-model", + "conversation": conversation.to_dict(), + }, + ) + message = Message( + message_id="message-1", + team_id=self.team["team_id"], + sender_id=self.agent.agent_id, + recipient_id=self.team["lead_agent_id"], + content="Rules inspected", + summary="analysis", + ) + message.transition_to("delivered") + self.context.team_store.save_message(message) + + snapshot = build_trace_snapshot(self.root) + + self.assertTrue(snapshot["historical_reconstruction"]) + self.assertEqual(snapshot["tasks"][0]["id"], task["id"]) + self.assertEqual(snapshot["messages"][0]["sender_name"], "researcher") + trace_types = {event["type"] for event in snapshot["events"]} + self.assertTrue({"model.response", "tool.started", "tool.completed"}.issubset(trace_types)) + tool_event = next(event for event in snapshot["events"] if event["type"] == "tool.started") + self.assertEqual(tool_event["data"]["tool_input"]["file_path"], "requirements.md") + self.assertEqual(tool_event["actor_name"], "researcher") + + def test_redacts_credentials_without_hiding_token_usage(self) -> None: + value = redact_trace_value({ + "api_key": "top-secret", + "input_tokens": 42, + "command": "ANTHROPIC_AUTH_TOKEN=exposed curl -H 'Authorization: Bearer abc123'", + }) + + self.assertEqual(value["api_key"], "[REDACTED]") + self.assertEqual(value["input_tokens"], 42) + self.assertNotIn("exposed", value["command"]) + self.assertNotIn("abc123", value["command"]) + + def test_http_api_serves_snapshot(self) -> None: + try: + server = create_trace_server(self.root, port=0) + except PermissionError: + self.skipTest("sandbox does not permit binding a localhost test server") + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + port = server.server_address[1] + with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/state", timeout=3) as response: + payload = json.loads(response.read().decode("utf-8")) + self.assertEqual(payload["team"]["team_name"], "trace-demo") + self.assertEqual(response.status, 200) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=3) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tool_system_tools.py b/tests/test_tool_system_tools.py index 5942cbc..81fba3f 100644 --- a/tests/test_tool_system_tools.py +++ b/tests/test_tool_system_tools.py @@ -4,6 +4,7 @@ import json import os import socket +import sys import tempfile import time import unittest @@ -194,6 +195,15 @@ def test_grep_content_mode_with_line_numbers(self) -> None: class TestBashTool(ToolSystemTests): + def _enable_strict_team(self) -> None: + self.ctx.team = { + "protocol_version": 2, + "settings": { + "protocol_version": 2, + "quality_gates": {"strict": True, "protocol_version": 2}, + }, + } + def test_bash_echo(self) -> None: out = BashTool().run({"command": "echo hello"}, self.ctx).output self.assertEqual(out["exit_code"], 0) @@ -203,6 +213,163 @@ def test_bash_blocks_sudo(self) -> None: with self.assertRaises(Exception): BashTool().run({"command": "sudo echo nope"}, self.ctx) + def test_strict_team_blocks_recursive_source_deletion(self) -> None: + self._enable_strict_team() + + with self.assertRaisesRegex(Exception, "preserves the best workspace"): + BashTool().run( + {"command": "cd /workspace && rm -rf src/package"}, self.ctx + ) + + def test_strict_team_allows_recursive_generated_cache_cleanup(self) -> None: + self._enable_strict_team() + cache = self.root / ".pytest_cache" + cache.mkdir() + (cache / "state").write_text("x", encoding="utf-8") + + result = BashTool().run( + {"command": "rm -rf .pytest_cache", "cwd": str(self.root)}, self.ctx + ) + + self.assertEqual(result.output["exit_code"], 0) + self.assertFalse(cache.exists()) + + def test_strict_team_blocks_common_nested_destructive_forms(self) -> None: + self._enable_strict_team() + package = self.root / "pkg" + package.mkdir() + source = package / "module.py" + source.write_text("VALUE = 1\n", encoding="utf-8") + commands = [ + "sh -c 'rm -rf pkg'", + "find pkg -depth -delete", + "git clean -fdx", + ( + f"{sys.executable} -c \"import shutil; " + "shutil.rmtree('pkg')\"" + ), + ] + + for command in commands: + with self.subTest(command=command): + with self.assertRaisesRegex( + Exception, "preserves the best workspace" + ): + BashTool().run( + {"command": command, "cwd": str(self.root)}, self.ctx + ) + self.assertEqual( + source.read_text(encoding="utf-8"), "VALUE = 1\n" + ) + + def test_strict_destructive_guard_ignores_source_text_and_heredoc_data(self) -> None: + self._enable_strict_team() + + printf_result = BashTool().run( + { + "command": "printf '%s' 'rm -rf pkg; git clean -fdx' > note.txt", + "cwd": str(self.root), + }, + self.ctx, + ) + heredoc_result = BashTool().run( + { + "command": ( + "cat > cleanup.py <<'PY'\n" + "# example only\n" + "shutil.rmtree('pkg')\n" + "find pkg -delete\n" + "PY" + ), + "cwd": str(self.root), + }, + self.ctx, + ) + python_result = BashTool().run( + { + "command": ( + f"{sys.executable} -c \"print(" + "\\\"shutil.rmtree('pkg')\\\")\"" + ), + "cwd": str(self.root), + }, + self.ctx, + ) + + self.assertEqual(printf_result.output["exit_code"], 0) + self.assertEqual(heredoc_result.output["exit_code"], 0) + self.assertEqual(python_result.output["exit_code"], 0) + self.assertIn("rm -rf pkg", (self.root / "note.txt").read_text()) + self.assertIn("find pkg -delete", (self.root / "cleanup.py").read_text()) + + def test_strict_lead_cannot_mutate_clawd_with_file_or_bash_tools(self) -> None: + self._enable_strict_team() + control = self.root / ".clawd" + control.mkdir() + state = control / "state.json" + state.write_text('{"status": "original"}\n', encoding="utf-8") + FileReadTool().run({"file_path": str(state)}, self.ctx) + + with self.assertRaisesRegex(Exception, "protects .clawd control state"): + FileWriteTool().run( + { + "file_path": str(control / "forged.json"), + "content": "{}\n", + }, + self.ctx, + ) + with self.assertRaisesRegex(Exception, "protects .clawd control state"): + FileEditTool().run( + { + "file_path": str(state), + "old_string": "original", + "new_string": "forged", + }, + self.ctx, + ) + with self.assertRaisesRegex(Exception, "ownership violation"): + BashTool().run( + { + "command": "printf '{\\\"status\\\": \\\"forged\\\"}\\n' > .clawd/state.json", + "cwd": str(self.root), + }, + self.ctx, + ) + + self.assertFalse((control / "forged.json").exists()) + self.assertEqual( + state.read_text(encoding="utf-8"), '{"status": "original"}\n' + ) + + def test_bash_runs_a_trailing_command_after_cd(self) -> None: + nested = self.root / "nested" + nested.mkdir() + + out = BashTool().run( + {"command": f"cd {nested} && printf 'ran-here:%s' \"$PWD\""}, + self.ctx, + ).output + + self.assertEqual(out["exit_code"], 0) + self.assertEqual(out["stdout"], f"ran-here:{nested}") + self.assertEqual(self.ctx.cwd, self.root) + + def test_bash_exposes_the_active_python_interpreter(self) -> None: + out = BashTool().run( + { + "command": ( + '"$CLAWD_PYTHON" -c "import sys; print(sys.executable)"' + ) + }, + self.ctx, + ).output + + self.assertEqual(out["exit_code"], 0) + self.assertEqual( + Path(out["stdout"].strip()).resolve(), + Path(sys.executable).resolve(), + ) + class TestWebFetchTool(ToolSystemTests): def test_web_fetch_blocks_file_scheme(self) -> None: @@ -384,6 +551,24 @@ def test_tool_search(self) -> None: out = ToolSearchTool(reg).run({"query": "read"}, self.ctx).output self.assertIn("Read", out["matches"]) + def test_tool_search_matches_natural_language_and_returns_schema(self) -> None: + reg = build_default_registry(include_user_tools=False) + out = ToolSearchTool(reg).run({"query": "read file content local"}, self.ctx).output + self.assertEqual(out["matches"][0], "Read") + read = next(tool for tool in out["tools"] if tool["name"] == "Read") + self.assertIn("file_path", read["input_schema"]["properties"]) + + def test_tool_search_supports_wildcard_and_synonyms(self) -> None: + reg = build_default_registry(include_user_tools=False) + search = ToolSearchTool(reg) + all_tools = search.run({"query": "*", "max_results": 50}, self.ctx).output + self.assertIn("Read", all_tools["matches"]) + self.assertIn("Bash", all_tools["matches"]) + self.assertGreaterEqual(all_tools["total_matches"], len(all_tools["matches"])) + + shell = search.run({"query": "execute shell command"}, self.ctx).output + self.assertEqual(shell["matches"][0], "Bash") + def test_cron_tools_roundtrip(self) -> None: created = CronCreateTool().run({"cron": "*/5 * * * *", "prompt": "ping"}, self.ctx).output cron_id = created["id"] diff --git a/uv.lock b/uv.lock index 4a25587..0155c90 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,174 @@ version = 1 revision = 3 requires-python = ">=3.10" +[[package]] +name = "aiohappyeyeballs" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "async-timeout", marker = "python_full_version < '3.11'" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/67/58ded4b3f2e10f94972d8928050c85330e249a31dd45a0e5f3c0e9c3fa05/aiohttp-3.14.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8f6bb621e5863cfe8fe5ff5468002d200ec31f30f1280b259dc505b02595099e", size = 766140, upload-time = "2026-06-07T21:05:37.471Z" }, + { url = "https://files.pythonhosted.org/packages/18/68/4ae5b4e08943f316594bb68da89957d3baf5760588fa09509594bd777e4b/aiohttp-3.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4f7215cb3933784f79ed20e5f050e15984f390424339b22375d5a53c933a0491", size = 519430, upload-time = "2026-06-07T21:05:40.751Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c1/316c8f3549dbe5245f92bfd523ec6f32dd4d98cafe21df3f6a19b1184c75/aiohttp-3.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d9d4e294455b23a68c9b8f042d0e8e377a265bcb15332753695f6e5b6819e0ce", size = 514406, upload-time = "2026-06-07T21:05:42.111Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ee/fb0ac28684e8d753b83c8a4eebc19a5846912aa0a4daaabb6a9936363840/aiohttp-3.14.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b238af795833d5731d049d82bc84b768ae6f8f97f0495963b3ed9935c5901cc3", size = 1703649, upload-time = "2026-06-07T21:05:43.427Z" }, + { url = "https://files.pythonhosted.org/packages/3b/57/aa2beab673331f111885db8a7b69dfe3ab0e53e446a0ace18ca694b4dc58/aiohttp-3.14.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4e5e0ae56914ecdbf446493addefc0159053dd53962cef37d7839f37f73d505", size = 1675126, upload-time = "2026-06-07T21:05:44.897Z" }, + { url = "https://files.pythonhosted.org/packages/47/ea/dad128abe365e79be03b16ed464198ac73e0d257e8260c6f7d6f31cbef26/aiohttp-3.14.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:092e4ce3619a7c6dee52a6bdabda973d9b34b66781f840ce93c7e0cec30cf521", size = 1771558, upload-time = "2026-06-07T21:05:46.405Z" }, + { url = "https://files.pythonhosted.org/packages/63/f3/b5b4e10327cb85d34d24232c6b71b64602f190b3ccb238a043ac6b187dac/aiohttp-3.14.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb33777ea21e8b7ecde0e6fc84f598be0a1192eab1a63bc746d75aa75d38e7bd", size = 1856631, upload-time = "2026-06-07T21:05:47.844Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9d/93294c3045775c708ac8310eb3d3622a11d2951345ad590d532d62a1faa4/aiohttp-3.14.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23119f8fd4f5d16902ed459b63b100bcd269628075162bddac56cc7b5273b3fb", size = 1714139, upload-time = "2026-06-07T21:05:49.982Z" }, + { url = "https://files.pythonhosted.org/packages/29/c4/93067c85a0373492ce8e577435203c5947c454af074ac48ed4f3a1b9dd4a/aiohttp-3.14.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:57fc6745a4b7d0f5a9eb4f40a69718be6c0bc1b8368cc9fe89e90118719f4f42", size = 1588321, upload-time = "2026-06-07T21:05:51.431Z" }, + { url = "https://files.pythonhosted.org/packages/c4/39/9ff91aaf02af8b7b8222a987466da539f154c3e01732c22b5f5a20a8ee66/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6fd35beba67c4183b09375c5fff9accb47524191a244a99f95fd4472f5402c2b", size = 1670375, upload-time = "2026-06-07T21:05:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e4/77452a3676b8d99ac1375f77691d6bf65ea6e9f4b201b82ef77c916dc767/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:672b9d65f42eb877f5c3f234a4547e4e1a226ca8c2eed879bb34670a0ce51192", size = 1690933, upload-time = "2026-06-07T21:05:54.902Z" }, + { url = "https://files.pythonhosted.org/packages/7d/84/b0059a7c7fc05ea23f3bc1596ba91c12f79588b9450564a24cac37536d0a/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:24ba13339fed9251d9b1a1bec8c7ab84c0d1675d79d33501e11f94f8b9a84e05", size = 1740798, upload-time = "2026-06-07T21:05:56.458Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3a/e2a513ecbfc362591caa51a7f7e011b3bfc8938b388ae44cd95560d36999/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:94da27378da0610e341c4d30de29a191672683cc82b8f9556e8f7c7212a020fe", size = 1576412, upload-time = "2026-06-07T21:05:57.953Z" }, + { url = "https://files.pythonhosted.org/packages/a1/10/08f1654f538f93d36dcac66310a06eefce4641cdafca83f9f0a5317be254/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:52cdac9432d8b4a719f35094a818d95adcae0f0b4fe9b9b921909e0c87de9e7d", size = 1750199, upload-time = "2026-06-07T21:05:59.488Z" }, + { url = "https://files.pythonhosted.org/packages/99/e4/d91b70c57d8b8e9611e4a2e52238ca3698d3dc1c2efe25b7a9bf594ac584/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:672ac254412a24d0d0cf00a9e6c238877e4be5e5fa2d188832c1244f45f31966", size = 1699356, upload-time = "2026-06-07T21:06:01.131Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f1/15340176f35ff61b95dbe34020bcf43f9e624a2d7bbac934715ff97d2033/aiohttp-3.14.1-cp310-cp310-win32.whl", hash = "sha256:2fe3607e71acc6ebb0ec8e492a247bf7a291226192dc0084236dfc12478916f6", size = 458939, upload-time = "2026-06-07T21:06:02.86Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c2/a2f1ec5b37f903109e43ae2862268cfe4a67a60c1b2cf43169fcdff5995f/aiohttp-3.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:30099eda75a53c32efb0920e9c33c195314d2cc1c680fbfd30894932ac5f27df", size = 482583, upload-time = "2026-06-07T21:06:04.666Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7a/7b56f6732ef79530afaa72aa335d41b67c8d79b946995f0b11ad72985435/aiohttp-3.14.1-cp310-cp310-win_arm64.whl", hash = "sha256:5a837f49d901f9e368651b676912bff1104ed8c1a83b280bcd7b29adccef5c9c", size = 453470, upload-time = "2026-06-07T21:06:06.322Z" }, + { url = "https://files.pythonhosted.org/packages/26/dd/bf526e6f0a1120dd6f2df2e97bacfe4d358f13d17a0ff5847301a1375a51/aiohttp-3.14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2", size = 765225, upload-time = "2026-06-07T21:06:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e1/a2872aa55495a70f61310d411541c6ee23812d9a884e000c716e1bc3edbf/aiohttp-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f", size = 518743, upload-time = "2026-06-07T21:06:09.749Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e7/c60c7b209e509cc787de3cea0550a518538cfc08003e1c1e14c1c63fff71/aiohttp-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8", size = 514139, upload-time = "2026-06-07T21:06:11.26Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8d/614ace2f579702c9840ab1e1447fd8509e35b0b904f7196418fa2f57b25d/aiohttp-3.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04", size = 1784088, upload-time = "2026-06-07T21:06:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/726e90f99542bf292f81a96a12cc4847deb86f3ccf62c6f4014a201f4d33/aiohttp-3.14.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8", size = 1737835, upload-time = "2026-06-07T21:06:14.564Z" }, + { url = "https://files.pythonhosted.org/packages/0b/4b/d176d5c4db9d33dacf0543102ea59503bc1d528af4cfd0b719949ca49389/aiohttp-3.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6", size = 1842801, upload-time = "2026-06-07T21:06:16.228Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d6/5a99b563690ea0cbed912ae94a2ce33993a5709a651a3a4fe761e7dd973a/aiohttp-3.14.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af", size = 1929992, upload-time = "2026-06-07T21:06:17.947Z" }, + { url = "https://files.pythonhosted.org/packages/76/7f/a987b14a3859094b3cea3f4825219c3e5536242564af6e3f9c2f6c994eb2/aiohttp-3.14.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730", size = 1786989, upload-time = "2026-06-07T21:06:19.677Z" }, + { url = "https://files.pythonhosted.org/packages/f1/1a/420e5c85a3e73349372ed22ce0b6af86bfa6ce16a4b20a64a2e94608c781/aiohttp-3.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621", size = 1640129, upload-time = "2026-06-07T21:06:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/a7/80/18a592ed3be0a402cc03670bd72ee1f8563ddbe1d8d5542dbf868f274136/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee", size = 1756576, upload-time = "2026-06-07T21:06:24.8Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0b/8b3d5713373858ff71a617daf6e3b0e81ad63e79d09a3cf2f6b6b983939c/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573", size = 1754668, upload-time = "2026-06-07T21:06:26.528Z" }, + { url = "https://files.pythonhosted.org/packages/9f/49/fd564575cf225821d7ba5a117cb8bc27213d8a7e1811162afb43ae077039/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7", size = 1817019, upload-time = "2026-06-07T21:06:28.297Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/e850c9ae6fc91356552ae668bb6c51e93fa29c8aef13398a10b56678557f/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf", size = 1631638, upload-time = "2026-06-07T21:06:30.242Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/3c337ba72451a89806ace6f75bddc92bafc5b8d53d90115a512858024b63/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85", size = 1835660, upload-time = "2026-06-07T21:06:31.943Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9c/9c18cf367a0498212d9ba7daf990b504a5e8ae064cda4b504e2647c89c03/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3", size = 1775698, upload-time = "2026-06-07T21:06:33.72Z" }, + { url = "https://files.pythonhosted.org/packages/b5/63/a251a9d2a6cb45065b2ddc0bde2b3dd10108740a9a42f632c66405a761a2/aiohttp-3.14.1-cp311-cp311-win32.whl", hash = "sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126", size = 458386, upload-time = "2026-06-07T21:06:35.279Z" }, + { url = "https://files.pythonhosted.org/packages/17/ca/69274c51dcd6e8947d77b2806cf47a4a15f2c846e2cbeb1882547d3da283/aiohttp-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5", size = 483406, upload-time = "2026-06-07T21:06:36.824Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8a/c25904f77690c3688ec140f87591ef11a0cfe36bf3d5c0f1f38056fb62b3/aiohttp-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b", size = 452987, upload-time = "2026-06-07T21:06:38.371Z" }, + { url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" }, + { url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" }, + { url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" }, + { url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" }, + { url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" }, + { url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" }, + { url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" }, + { url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" }, + { url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" }, + { url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" }, + { url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" }, + { url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" }, + { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" }, + { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" }, + { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" }, + { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" }, + { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" }, + { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" }, + { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" }, + { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" }, + { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" }, + { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" }, + { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" }, + { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" }, + { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" }, + { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" }, + { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" }, + { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" }, + { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" }, + { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" }, + { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" }, + { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" }, + { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" }, + { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" }, + { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" }, + { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" }, + { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" }, + { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" }, + { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" }, + { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" }, + { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" }, + { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" }, + { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" }, + { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" }, + { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -44,6 +212,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + [[package]] name = "backports-tarfile" version = "1.2.0" @@ -53,6 +239,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, ] +[[package]] +name = "bashlex" +version = "0.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/60/aae0bb54f9af5e0128ba90eb83d8d0d506ee8f0475c4fdda3deeda20b1d2/bashlex-0.18.tar.gz", hash = "sha256:5bb03a01c6d5676338c36fd1028009c8ad07e7d61d8a1ce3f513b7fff52796ee", size = 68742, upload-time = "2023-01-18T15:21:26.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/be/6985abb1011fda8a523cfe21ed9629e397d6e06fb5bae99750402b25c95b/bashlex-0.18-py2.py3-none-any.whl", hash = "sha256:91d73a23a3e51711919c1c899083890cdecffc91d8c088942725ac13e9dcfffa", size = 69539, upload-time = "2023-01-18T15:21:24.167Z" }, +] + [[package]] name = "build" version = "1.4.2" @@ -248,6 +443,12 @@ dependencies = [ ] [package.optional-dependencies] +ags = [ + { name = "aiohttp" }, + { name = "swe-rex" }, + { name = "tencentcloud-sdk-python-ags" }, + { name = "tencentcloud-sdk-python-common" }, +] dev = [ { name = "build" }, { name = "pytest" }, @@ -256,6 +457,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "aiohttp", marker = "extra == 'ags'", specifier = ">=3.10" }, { name = "anthropic" }, { name = "build", marker = "extra == 'dev'", specifier = ">=1.0.0" }, { name = "openai" }, @@ -263,11 +465,26 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, { name = "python-dotenv" }, { name = "rich" }, + { name = "swe-rex", marker = "extra == 'ags'", specifier = ">=1.4.0" }, + { name = "tencentcloud-sdk-python-ags", marker = "extra == 'ags'", specifier = ">=3.1.132" }, + { name = "tencentcloud-sdk-python-common", marker = "extra == 'ags'", specifier = ">=3.1.132" }, { name = "tiktoken", specifier = ">=0.7.0" }, { name = "twine", marker = "extra == 'dev'", specifier = ">=5.0.0" }, { name = "zhipuai" }, ] -provides-extras = ["dev"] +provides-extras = ["dev", "ags"] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] [[package]] name = "colorama" @@ -360,6 +577,143 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] +[[package]] +name = "fastapi" +version = "0.139.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/71/f4cfcd72fd94af40d1c76141b69c3d27acaa3641e8fd64872929ff6998b9/fastapi-0.139.1.tar.gz", hash = "sha256:99461bde7ac3fc34c78443da1f4dad3ca8f3182580029a2827692db216a8d7ae", size = 422782, upload-time = "2026-07-16T09:18:34.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/4d/73d2e5e891d56722f5b6def27f67e91332342a18468f2ea20aa5da9ff64d/fastapi-0.139.1-py3-none-any.whl", hash = "sha256:17faa81907751a8a85cd44c46f37fb576bde0078cb37de40bf1cd55de7104d87", size = 130147, upload-time = "2026-07-16T09:18:32.723Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/4a/557715d5047da48d54e659203b9335be7bfaafda2c3f627b7c47e0b3aaf3/frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011", size = 86230, upload-time = "2025-10-06T05:35:23.699Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fb/c85f9fed3ea8fe8740e5b46a59cc141c23b842eca617da8876cfce5f760e/frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565", size = 49621, upload-time = "2025-10-06T05:35:25.341Z" }, + { url = "https://files.pythonhosted.org/packages/63/70/26ca3f06aace16f2352796b08704338d74b6d1a24ca38f2771afbb7ed915/frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad", size = 49889, upload-time = "2025-10-06T05:35:26.797Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ed/c7895fd2fde7f3ee70d248175f9b6cdf792fb741ab92dc59cd9ef3bd241b/frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2", size = 219464, upload-time = "2025-10-06T05:35:28.254Z" }, + { url = "https://files.pythonhosted.org/packages/6b/83/4d587dccbfca74cb8b810472392ad62bfa100bf8108c7223eb4c4fa2f7b3/frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186", size = 221649, upload-time = "2025-10-06T05:35:29.454Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/fd3b9cd046ec5fff9dab66831083bc2077006a874a2d3d9247dea93ddf7e/frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e", size = 219188, upload-time = "2025-10-06T05:35:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ce/80/6693f55eb2e085fc8afb28cf611448fb5b90e98e068fa1d1b8d8e66e5c7d/frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450", size = 231748, upload-time = "2025-10-06T05:35:32.101Z" }, + { url = "https://files.pythonhosted.org/packages/97/d6/e9459f7c5183854abd989ba384fe0cc1a0fb795a83c033f0571ec5933ca4/frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef", size = 236351, upload-time = "2025-10-06T05:35:33.834Z" }, + { url = "https://files.pythonhosted.org/packages/97/92/24e97474b65c0262e9ecd076e826bfd1d3074adcc165a256e42e7b8a7249/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4", size = 218767, upload-time = "2025-10-06T05:35:35.205Z" }, + { url = "https://files.pythonhosted.org/packages/ee/bf/dc394a097508f15abff383c5108cb8ad880d1f64a725ed3b90d5c2fbf0bb/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff", size = 235887, upload-time = "2025-10-06T05:35:36.354Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/25b201b9c015dbc999a5baf475a257010471a1fa8c200c843fd4abbee725/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c", size = 228785, upload-time = "2025-10-06T05:35:37.949Z" }, + { url = "https://files.pythonhosted.org/packages/84/f4/b5bc148df03082f05d2dd30c089e269acdbe251ac9a9cf4e727b2dbb8a3d/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f", size = 230312, upload-time = "2025-10-06T05:35:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/db/4b/87e95b5d15097c302430e647136b7d7ab2398a702390cf4c8601975709e7/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7", size = 217650, upload-time = "2025-10-06T05:35:40.377Z" }, + { url = "https://files.pythonhosted.org/packages/e5/70/78a0315d1fea97120591a83e0acd644da638c872f142fd72a6cebee825f3/frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a", size = 39659, upload-time = "2025-10-06T05:35:41.863Z" }, + { url = "https://files.pythonhosted.org/packages/66/aa/3f04523fb189a00e147e60c5b2205126118f216b0aa908035c45336e27e4/frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6", size = 43837, upload-time = "2025-10-06T05:35:43.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/75/1135feecdd7c336938bd55b4dc3b0dfc46d85b9be12ef2628574b28de776/frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e", size = 39989, upload-time = "2025-10-06T05:35:44.596Z" }, + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -629,6 +983,144 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, ] +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/0b/19348d4c98980c4851d2f943f8ebafdece2ae7ef737adcfa5994ce8e5f10/multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5", size = 77176, upload-time = "2026-01-26T02:42:59.784Z" }, + { url = "https://files.pythonhosted.org/packages/ef/04/9de3f8077852e3d438215c81e9b691244532d2e05b4270e89ce67b7d103c/multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8", size = 44996, upload-time = "2026-01-26T02:43:01.674Z" }, + { url = "https://files.pythonhosted.org/packages/31/5c/08c7f7fe311f32e83f7621cd3f99d805f45519cd06fafb247628b861da7d/multidict-6.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872", size = 44631, upload-time = "2026-01-26T02:43:03.169Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7f/0e3b1390ae772f27501199996b94b52ceeb64fe6f9120a32c6c3f6b781be/multidict-6.7.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991", size = 242561, upload-time = "2026-01-26T02:43:04.733Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f4/8719f4f167586af317b69dd3e90f913416c91ca610cac79a45c53f590312/multidict-6.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03", size = 242223, upload-time = "2026-01-26T02:43:06.695Z" }, + { url = "https://files.pythonhosted.org/packages/47/ab/7c36164cce64a6ad19c6d9a85377b7178ecf3b89f8fd589c73381a5eedfd/multidict-6.7.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981", size = 222322, upload-time = "2026-01-26T02:43:08.472Z" }, + { url = "https://files.pythonhosted.org/packages/f5/79/a25add6fb38035b5337bc5734f296d9afc99163403bbcf56d4170f97eb62/multidict-6.7.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6", size = 254005, upload-time = "2026-01-26T02:43:10.127Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7b/64a87cf98e12f756fc8bd444b001232ffff2be37288f018ad0d3f0aae931/multidict-6.7.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190", size = 251173, upload-time = "2026-01-26T02:43:11.731Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ac/b605473de2bb404e742f2cc3583d12aedb2352a70e49ae8fce455b50c5aa/multidict-6.7.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92", size = 243273, upload-time = "2026-01-26T02:43:13.063Z" }, + { url = "https://files.pythonhosted.org/packages/03/65/11492d6a0e259783720f3bc1d9ea55579a76f1407e31ed44045c99542004/multidict-6.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee", size = 238956, upload-time = "2026-01-26T02:43:14.843Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a7/7ee591302af64e7c196fb63fe856c788993c1372df765102bd0448e7e165/multidict-6.7.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2", size = 233477, upload-time = "2026-01-26T02:43:16.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/99/c109962d58756c35fd9992fed7f2355303846ea2ff054bb5f5e9d6b888de/multidict-6.7.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568", size = 243615, upload-time = "2026-01-26T02:43:17.84Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5f/1973e7c771c86e93dcfe1c9cc55a5481b610f6614acfc28c0d326fe6bfad/multidict-6.7.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40", size = 249930, upload-time = "2026-01-26T02:43:19.06Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a5/f170fc2268c3243853580203378cd522446b2df632061e0a5409817854c7/multidict-6.7.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962", size = 243807, upload-time = "2026-01-26T02:43:20.286Z" }, + { url = "https://files.pythonhosted.org/packages/de/01/73856fab6d125e5bc652c3986b90e8699a95e84b48d72f39ade6c0e74a8c/multidict-6.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505", size = 239103, upload-time = "2026-01-26T02:43:21.508Z" }, + { url = "https://files.pythonhosted.org/packages/e7/46/f1220bd9944d8aa40d8ccff100eeeee19b505b857b6f603d6078cb5315b0/multidict-6.7.1-cp310-cp310-win32.whl", hash = "sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122", size = 41416, upload-time = "2026-01-26T02:43:22.703Z" }, + { url = "https://files.pythonhosted.org/packages/68/00/9b38e272a770303692fc406c36e1a4c740f401522d5787691eb38a8925a8/multidict-6.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df", size = 46022, upload-time = "2026-01-26T02:43:23.77Z" }, + { url = "https://files.pythonhosted.org/packages/64/65/d8d42490c02ee07b6bbe00f7190d70bb4738b3cce7629aaf9f213ef730dd/multidict-6.7.1-cp310-cp310-win_arm64.whl", hash = "sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db", size = 43238, upload-time = "2026-01-26T02:43:24.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, + { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, + { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + [[package]] name = "nh3" version = "0.3.4" @@ -691,6 +1183,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -712,6 +1216,143 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, ] +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/56/030b7b4719d53085722893e0009dffb9236aa10bca1b12121bdc5626ef16/propcache-0.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a81be28596d6559f6131ef33e10200de6e17643b3c74ce03f9eb103be6ae8b", size = 93417, upload-time = "2026-05-08T20:59:15.597Z" }, + { url = "https://files.pythonhosted.org/packages/1a/55/1140a8e067b8ec093a18a4ae7bb0045d9db65da38a08618ddc5e2f1994aa/propcache-0.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29cbaac5ea0212663e6845e04b5e188d5a6ae6dd919810ac835bf1d3b42c3f4c", size = 53847, upload-time = "2026-05-08T20:59:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/20/42/0e7443c90310498561addf346e7d57fe3c6ba1914e1ba938b5464c7bbfd2/propcache-0.5.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6bf3be92233808fcd338eba0fb4d0b59ec5772af4f4ecfcec450d1bfc0f8b5eb", size = 53512, upload-time = "2026-05-08T20:59:18.64Z" }, + { url = "https://files.pythonhosted.org/packages/b7/db/cf51a71bab2009517d1a7f0ee07657e3bd446c4d69f67e6966cf17bcf956/propcache-0.5.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f8ea531c794b9d6274acd4e8d2c2ebcac590a4361d27482edd3010b79f1325e", size = 58068, upload-time = "2026-05-08T20:59:20.683Z" }, + { url = "https://files.pythonhosted.org/packages/b7/43/39b6bdee9699fa1e1641c519feeb64a67e2a9f93bb465c70776b37a7333f/propcache-0.5.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:decfca4c79dd53ebab484b00cc4b6717d8c369f86e74aa4ca395a64ac651495e", size = 61020, upload-time = "2026-05-08T20:59:22.112Z" }, + { url = "https://files.pythonhosted.org/packages/26/0b/843726fbb0a29a8c5684fdb25971823638399f31e52e9d1f06a02dc9aa6b/propcache-0.5.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4621064bbf28fa77ff64dd5d94367c04684c67d3a5bf1dff25f0cd0d98a38f3b", size = 62732, upload-time = "2026-05-08T20:59:23.805Z" }, + { url = "https://files.pythonhosted.org/packages/39/6e/899fed76dc1942b8a64193a4f059d7f1a2c7ef65085e8a9366ed8ec0d199/propcache-0.5.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b96db7141a592cbc968daf1feea83a118e6ab378af4abbc72b248c895414c22d", size = 60140, upload-time = "2026-05-08T20:59:25.389Z" }, + { url = "https://files.pythonhosted.org/packages/ab/09/3da4be9b5b879219ad234aa535b3dd4a080ed1ad48d3a73ca07a9e798f22/propcache-0.5.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ca071adabaab6e9219924bbe00af821f1ee7de113a9eca1cdc292de3d120f4d", size = 60400, upload-time = "2026-05-08T20:59:27.238Z" }, + { url = "https://files.pythonhosted.org/packages/60/2f/09b72b874a9aa0044faf52a69807a6ed618e267ceaa9ec4a63195fa5b504/propcache-0.5.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e4294d04a94dcab1b3bccd8b66d962dcad411a1d19414b2a41d1445f1de32ad0", size = 58155, upload-time = "2026-05-08T20:59:28.48Z" }, + { url = "https://files.pythonhosted.org/packages/8a/37/97489848c54c95578045473954f10956d619ce6a09e7ac137b71cdcb698b/propcache-0.5.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a0e399a2eccb91ed18721f86aa85757727400b6865c89e88934781deb9c8498b", size = 57037, upload-time = "2026-05-08T20:59:30.146Z" }, + { url = "https://files.pythonhosted.org/packages/22/db/6c695285ccfc49012743ee9c98212b8c5dd0aed7b63cfd816d4a0f7a1601/propcache-0.5.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:823581fd5cb08b12a48bfa11fe962a7916766b6170c17b028fbdf762b85eb9bf", size = 61103, upload-time = "2026-05-08T20:59:31.626Z" }, + { url = "https://files.pythonhosted.org/packages/98/a9/1e500401ca593b0bdb6bf75a70bc2d723835fd53360edff6af70692c7546/propcache-0.5.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:949c91d1a990cf3b2e8188dfcfb25005e0b834a06c63fa4ef9f360878ce21ecf", size = 60394, upload-time = "2026-05-08T20:59:32.829Z" }, + { url = "https://files.pythonhosted.org/packages/1f/87/f638b6e375eae0f30a1a2325d8b34fd85fdc785bb9960cf805f3bf1ec69a/propcache-0.5.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:cc1177027eda740fdb152706bd215a3f124e3eea15afc39f2cb9fe351b50619e", size = 63084, upload-time = "2026-05-08T20:59:35.964Z" }, + { url = "https://files.pythonhosted.org/packages/f6/18/884573f5d97b6d9eba68de759a82c901b7e39d7904d30f7b8d58d42d2a12/propcache-0.5.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b05d643f944a8c3c4bd86d65ffd87bf3264b617f87791940302bc474d2ff5274", size = 60999, upload-time = "2026-05-08T20:59:38.481Z" }, + { url = "https://files.pythonhosted.org/packages/8f/1a/c3915eb059ceec9e758a56e4cfd955292bc0f201be2176a46b76d94b303a/propcache-0.5.2-cp310-cp310-win32.whl", hash = "sha256:8114f28879e0904748e831c3a7774261bd9e75f49be089f389a76f959dcd13fe", size = 39036, upload-time = "2026-05-08T20:59:40.323Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/1dfd5607501a602d19c1c449d2d193b7d1c611f9246b4059026a1189a80e/propcache-0.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:5fcb98e7598b1ee0addab320d90f65b530297a867dbfe9de52ea838077e16e3d", size = 42190, upload-time = "2026-05-08T20:59:42.232Z" }, + { url = "https://files.pythonhosted.org/packages/57/93/f71588ad08b3e6f4b555b5ef215808a3c02b042d0151ad82fa6f15be677a/propcache-0.5.2-cp310-cp310-win_arm64.whl", hash = "sha256:04dc2390d9edbbaef7461f33322555976ffddf0b650a038649d026358714e6c5", size = 38545, upload-time = "2026-05-08T20:59:44.087Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f1/8a8cc1c2c7e7934ab77e0163414f736fadbc0f5e8dd9673b952355ac175b/propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78", size = 90744, upload-time = "2026-05-08T20:59:45.799Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f4/651b1225e976bd1a2ba5cfba0c29d096581c2636b437e3a9a7ab6276270a/propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959", size = 52033, upload-time = "2026-05-08T20:59:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/15/a8/8ede85d6aa1f79fc7dc2f8fd2c8d65920b8272c3892903c8a1affde48cfb/propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7", size = 52754, upload-time = "2026-05-08T20:59:49.202Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fe/b3551b41bbc2f5b5bb088fc6920567cd43101253e68fbaa261339eb96fe1/propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511", size = 57573, upload-time = "2026-05-08T20:59:50.778Z" }, + { url = "https://files.pythonhosted.org/packages/83/27/ab851ebd1b7172e3e161f5f8d39e315d54a91bea246f01f4d872d3376aef/propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660", size = 60645, upload-time = "2026-05-08T20:59:52.227Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/466b3d18022e9897cbda9c735c493c5bd747d7a4c6f5ea1480b4cec434b6/propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66", size = 61563, upload-time = "2026-05-08T20:59:53.866Z" }, + { url = "https://files.pythonhosted.org/packages/27/1b/16ab7f2cf2041da2f60d156ba64c2484eadf9168075b4ff43c3ef60045af/propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b", size = 58888, upload-time = "2026-05-08T20:59:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/0a/67/bb777ffd907633563bf35fd859c4ce97b0512c32f4633cf5d1eb7c33512b/propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67", size = 59253, upload-time = "2026-05-08T20:59:57.075Z" }, + { url = "https://files.pythonhosted.org/packages/b9/42/64f8d90b73fd9cdc1499b48057ff6d9cd2a98a25734c9bb62ecf07e87061/propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f", size = 57558, upload-time = "2026-05-08T20:59:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/02/dba5bc03c9041f2092ea55a449caf5dfe68352c6654511b29ba0654ddb69/propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c", size = 55007, upload-time = "2026-05-08T20:59:59.837Z" }, + { url = "https://files.pythonhosted.org/packages/14/c0/43f649c7aa2a77a3b100d84e9dea3a483120ecb608bfe36ce49eaff517fe/propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0", size = 60355, upload-time = "2026-05-08T21:00:01.144Z" }, + { url = "https://files.pythonhosted.org/packages/83/c0/435dafd27f1cb4a495381dae60e25883ccfe4020bb72818e8184c1678092/propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6", size = 59057, upload-time = "2026-05-08T21:00:02.401Z" }, + { url = "https://files.pythonhosted.org/packages/53/ae/6e292df9135d659944e96cb3389258e4a663e5b2b5f6c217ef0ddc8d2f73/propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27", size = 61938, upload-time = "2026-05-08T21:00:03.638Z" }, + { url = "https://files.pythonhosted.org/packages/0b/42/314ebc50d8159055411fd6b0bda322ff510e4b1f7d2e4927940ad0f6af20/propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f", size = 59731, upload-time = "2026-05-08T21:00:04.881Z" }, + { url = "https://files.pythonhosted.org/packages/b8/9b/2da6dee38871c3c8772fabc2758325a5c9077d6d18c597737dc04dd884cd/propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0", size = 38966, upload-time = "2026-05-08T21:00:06.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/4e/f17363fb58c0afe05b067361cb6d86ed2d29de6506779a27547c4d183075/propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82", size = 42135, upload-time = "2026-05-08T21:00:08.088Z" }, + { url = "https://files.pythonhosted.org/packages/c6/eb/6af6685077d22e8b33358d3c548e3282706a0b3cd85044ffba4e5dd08e3b/propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab", size = 38381, upload-time = "2026-05-08T21:00:09.692Z" }, + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, + { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, + { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, + { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, + { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, + { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, + { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, + { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, + { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, + { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, + { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, + { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, + { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, + { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, + { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, + { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, + { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -908,6 +1549,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + [[package]] name = "pywin32-ctypes" version = "0.2.3" @@ -1123,6 +1773,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "swe-rex" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bashlex" }, + { name = "fastapi" }, + { name = "pexpect" }, + { name = "pydantic" }, + { name = "python-multipart" }, + { name = "requests" }, + { name = "rich" }, + { name = "uvicorn" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/86/a069f93ec866151a4d476d546e60220e66b3788878b6e248b2df3ab2c5f1/swe_rex-1.4.0.tar.gz", hash = "sha256:14f8a24c49a63f9e251340b1109ac75a4aacbaece410f8599209de9bfca843c0", size = 41755, upload-time = "2025-08-14T01:19:20.22Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/0d/d06ab2aa78138055c297490762cd7b4d8ac58a544783f874c869cdb7b534/swe_rex-1.4.0-py3-none-any.whl", hash = "sha256:61261ad03eb23b717b5901cd5d229f24f6e1be2e120aad5c2e5ea3384a1d15ad", size = 47756, upload-time = "2025-08-14T01:19:18.93Z" }, +] + +[[package]] +name = "tencentcloud-sdk-python-ags" +version = "3.1.135" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tencentcloud-sdk-python-common" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/26/ddd0e457c46f232c1d19fa22e3bd7bf38e116142f773f5de2aee1d6842b1/tencentcloud_sdk_python_ags-3.1.135.tar.gz", hash = "sha256:27ee50d4466b17ab361a9d0086b16749d95991ece3684d6a57475bc6fc853399", size = 20287, upload-time = "2026-07-17T04:16:59.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/2a/f295811daefd990b4af79c6d03f6892a7460e56ade7e30d7fa366687f6f9/tencentcloud_sdk_python_ags-3.1.135-py2.py3-none-any.whl", hash = "sha256:b5ed10a19402c12e494eb238537f6abcb503d7cb5957622706b6f708bc53217f", size = 23152, upload-time = "2026-07-17T04:16:58.179Z" }, +] + +[[package]] +name = "tencentcloud-sdk-python-common" +version = "3.1.136" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/09/45aef4503c3c8555f7c5577e373775bc28976cc586a066abada8760524a8/tencentcloud_sdk_python_common-3.1.136.tar.gz", hash = "sha256:f9e4922a4477057b9517f1e70f169dadab83b805c1743bba1fb238aac0281276", size = 22674, upload-time = "2026-07-19T20:15:12.543Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ab/2bd7d3a62646c0ee9424e09d0c34ee7c814ef015366780ad413d9f9f5d4c/tencentcloud_sdk_python_common-3.1.136-py2.py3-none-any.whl", hash = "sha256:106a88c6e89c09bfc8bfc0582fbce84180670611aebae43b71f7bdaa3068da48", size = 34274, upload-time = "2026-07-19T20:15:10.693Z" }, +] + [[package]] name = "tiktoken" version = "0.12.0" @@ -1300,6 +2006,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] +[[package]] +name = "uvicorn" +version = "0.51.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, +] + [[package]] name = "wcwidth" version = "0.6.0" @@ -1309,6 +2029,122 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, ] +[[package]] +name = "yarl" +version = "1.24.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/df/f1c7a3de0831cd83194f1a85c5bb431b13f81e6b45079314c86d1c4ef3f2/yarl-1.24.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5249a113065c2b7a958bc699759e359cd61cfc81e3069662208f48f191b7ed12", size = 129057, upload-time = "2026-05-19T21:27:47.564Z" }, + { url = "https://files.pythonhosted.org/packages/48/41/7daafb32dd7562bf45b1ce56562e7e1a9146f6479b6456873eb8a3413c40/yarl-1.24.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7f4425fa244fbf530b006d0c5f79ce920114cfff5b4f5f6056e669f8e160fdc0", size = 91545, upload-time = "2026-05-19T21:27:50.089Z" }, + { url = "https://files.pythonhosted.org/packages/a8/8f/7b3ec212f1ea0683f55f978e3246bc313c38818664edfc97a9f349a4901e/yarl-1.24.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:15c0b5e49d3c44e2a0b93e6a49476c5edad0a7686b92c395765a7ea775572a75", size = 91380, upload-time = "2026-05-19T21:27:51.953Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1b/8bafab7db23b0567ae9db749099b329d91e3b82bc6028b2050ba583e116c/yarl-1.24.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:246d32a53a947c8f0189f5d699cbd4c7036de45d9359e13ba238d1239678c727", size = 105957, upload-time = "2026-05-19T21:27:53.98Z" }, + { url = "https://files.pythonhosted.org/packages/7f/77/21030c2f8d21d21559719beafc772ada2014be933418ed1eaed9cc800e42/yarl-1.24.2-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:64480fb3e4d4ed9ed71c48a91a477384fc342a50ca30071d2f8a88d51d9c9413", size = 97242, upload-time = "2026-05-19T21:27:55.981Z" }, + { url = "https://files.pythonhosted.org/packages/50/d8/f9ea63d1b6aa910a866e089d871fff6cbd49caab29b86b35221a62dfa0d5/yarl-1.24.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:349de4701dc3760b6e876628423a8f147ef4f5599d10aba1e10702075d424ed9", size = 114719, upload-time = "2026-05-19T21:27:58.037Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a3/04e0ee98ac58a249ea7ed75223f5f901ba81a834f0b4921b58e5cec11757/yarl-1.24.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d162677af8d5d3d6ebab8394b021f4d041ac107a4b705873148a77a49dc9e1b2", size = 112140, upload-time = "2026-05-19T21:27:59.618Z" }, + { url = "https://files.pythonhosted.org/packages/02/ad/0b9cc9f38a7324a7eb1d80f834eaa5283d17e9271bbda3186e598dddaeac/yarl-1.24.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f5f5c6ec23a9043f2d139cc072f53dd23168d202a334b9b2fda8de4c3e890d90", size = 106721, upload-time = "2026-05-19T21:28:02.586Z" }, + { url = "https://files.pythonhosted.org/packages/65/e7/a52478ebfc66ec989e085c6ae038b9f1bfa4190baa193b133b669c709e2f/yarl-1.24.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:60de6742447fbbf697f16f070b8a443f1b5fe6ca3826fbef9fe70ecd5328e643", size = 106478, upload-time = "2026-05-19T21:28:04.523Z" }, + { url = "https://files.pythonhosted.org/packages/04/d8/5508530fea8472542de00013ae280765fc938ee196fc4030c43a498afb36/yarl-1.24.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:acf93187c3710e422368eb768aee98db551ec7c85adc250207a95c16548ab7ac", size = 105423, upload-time = "2026-05-19T21:28:06.515Z" }, + { url = "https://files.pythonhosted.org/packages/84/f1/ece28505e9628e8b756e11bb4f28864a17cc33b6b44db4d2aaf0622bf630/yarl-1.24.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f4b0352fd41fd34b6651934606268816afd6914d09626f9bcbbf018edb0afb3f", size = 99878, upload-time = "2026-05-19T21:28:08.637Z" }, + { url = "https://files.pythonhosted.org/packages/3f/52/fb5d34529b46dd84013afcfb30b8d2bc2832ed03d412736f577d604fa393/yarl-1.24.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:6b208bb939099b4b297438da4e9b25357f0b1c791888669b963e45b203ea9f36", size = 114025, upload-time = "2026-05-19T21:28:10.64Z" }, + { url = "https://files.pythonhosted.org/packages/43/f0/ff9d31aaab024f7a251c0ed308a98ae29bf9f7dc344e78f28b1322431ca2/yarl-1.24.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4b85b8825e631295ff4bc8943f7471d54c533a9360bbe15ebb38e018b555bb8a", size = 105613, upload-time = "2026-05-19T21:28:12.784Z" }, + { url = "https://files.pythonhosted.org/packages/31/7d/3296fb3f3ecd52bf9ae6c16b0895c1cda7e9170a2083861552b683f70264/yarl-1.24.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e26acf20c26cb4fefc631fdb75aca2a6b8fa8b7b5d7f204fb6a8f1e63c706f53", size = 111665, upload-time = "2026-05-19T21:28:14.393Z" }, + { url = "https://files.pythonhosted.org/packages/1a/74/77aa6ddaca4fbf42e45e675a465c43956dd40702281049975a2aa04eae59/yarl-1.24.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:819ca24f8eafcfb683c1bd5f44f2f488cea1274eb8944731ffd2e1f10f619342", size = 106914, upload-time = "2026-05-19T21:28:15.893Z" }, + { url = "https://files.pythonhosted.org/packages/d8/02/7611f22cd1d4ed7373eb7f9ee21fde1046edba2e7c0e514880d760352f48/yarl-1.24.2-cp310-cp310-win_amd64.whl", hash = "sha256:5cb0f995a901c36be096ccbf4c673591c2faabbe96279598ffaec8c030f85bf4", size = 92658, upload-time = "2026-05-19T21:28:17.471Z" }, + { url = "https://files.pythonhosted.org/packages/91/00/671d0add79938127292839ae44506ce2f7fe8909c72d5a931864f128fd0b/yarl-1.24.2-cp310-cp310-win_arm64.whl", hash = "sha256:f408eace7e22a68b467a0562e0d27d322f91fe3eaaa6f466b962c6cfaea9fa39", size = 87887, upload-time = "2026-05-19T21:28:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c5/1ce244152ff2839645e7cae92f90e7bafcb2c52bea7ff586ac714f14f5df/yarl-1.24.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:36348bebb147b83818b9d7e673ea4debc75970afc6ffdc7e3975ad05ce5a58c1", size = 128971, upload-time = "2026-05-19T21:28:20.543Z" }, + { url = "https://files.pythonhosted.org/packages/87/5a/00f36967203ed89cb3acd2c8ed526cc3fed9418eb70ce128160a911c8499/yarl-1.24.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a97e42c8a2233f2f279ecadd9e4a037bcb5d813b78435e8eedd4db5a9e9708c", size = 91507, upload-time = "2026-05-19T21:28:22.556Z" }, + { url = "https://files.pythonhosted.org/packages/31/d0/1fb0c1cd27288f39f6974da4318c32768d72c9890984541fdf1e2e32a51d/yarl-1.24.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8d027d56f1035e339d1001ac33eceab5b2ec8e42e449787bb75e289fb9a5cd1d", size = 91343, upload-time = "2026-05-19T21:28:24.092Z" }, + { url = "https://files.pythonhosted.org/packages/03/ce/d4a646508bed2f8dec6435b40166fe9308dd191262033d3f307b2bbcaecd/yarl-1.24.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a6377060e7927187a42b7eb202090cbe2b34933a4eeaf90e3bd9e33432e5cae", size = 105704, upload-time = "2026-05-19T21:28:25.872Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/b3278e82d8bc41485bcf6d856cd0433262593de615b1d3dc43bd3f5bead4/yarl-1.24.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:17076578bce0049a5ce57d14ad1bded391b68a3b213e9b81b0097b090244999a", size = 97281, upload-time = "2026-05-19T21:28:27.352Z" }, + { url = "https://files.pythonhosted.org/packages/17/5b/4cee6e7c92e487bebe7afc797da0aa54a248ab4e776a68fe369ec29665a5/yarl-1.24.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:50713f1d4d6be6375bb178bb43d140ee1acb8abe589cd723320b7925a275be1e", size = 114020, upload-time = "2026-05-19T21:28:29.458Z" }, + { url = "https://files.pythonhosted.org/packages/5c/82/111076571545a7d4f9cca3fbd5c6f40615af58642be09f12328f48022468/yarl-1.24.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:34263e2fa8fb5bb63a0d97706cda38edbad62fddb58c7f12d6acbc092812aa50", size = 111450, upload-time = "2026-05-19T21:28:31.262Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ec/08f671f69a444d704aeecebf92af659b67b97a869942411d0a578b08c334/yarl-1.24.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49016d82f032b1bd1e10b01078a7d29ae71bf468eeae0ea22df8bab691e60003", size = 106384, upload-time = "2026-05-19T21:28:32.856Z" }, + { url = "https://files.pythonhosted.org/packages/e5/86/ce41e7a7a199340b2330d52b60f25c4074b6636dd0e60b1a80d31a9db042/yarl-1.24.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3f6d2c216318f8f32038ca3f72501ba08536f0fd18a36e858836b121b2deed9f", size = 106153, upload-time = "2026-05-19T21:28:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5d/31be8a729531ab3e55ac3e7e5c800be8c89ea98947f418b2f6ea259fb6ee/yarl-1.24.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:08d3a33218e0c64393e7610284e770409a9c31c429b078bcb24096ed0a783b8f", size = 105322, upload-time = "2026-05-19T21:28:36.642Z" }, + { url = "https://files.pythonhosted.org/packages/47/9b/b57afb22b386ae87ac9940f09878b98d8c333f89113e6fc96fcf4ca9eb64/yarl-1.24.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5d699376c4ca3cba49bbfae3a05b5b70ded572937171ce1e0b8d87118e2ba294", size = 99057, upload-time = "2026-05-19T21:28:38.386Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4f/06348c27c8389256c313e8a57d796808fc0264c915dd5e7cfd3c0e314dc7/yarl-1.24.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a1cab588b4fa14bea2e55ebea27478adfb05372f47573738e1acc4a36c0b05d2", size = 113502, upload-time = "2026-05-19T21:28:40.091Z" }, + { url = "https://files.pythonhosted.org/packages/5f/1c/284f307b298e4a17b7943b07d9d7ecc4151537f8d137ba51f3bb6c31ca20/yarl-1.24.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ec87ccc31bd21db7ad009d8572c127c1000f268517618a4cc09adba3c2a7f21c", size = 105253, upload-time = "2026-05-19T21:28:41.987Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bf/0de123bec8619e45c80cbded9085f61b5b4a9eddb8abe6d25d28ee1ec866/yarl-1.24.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d1dd47a22843b212baa8d74f37796815d43bd046b42a0f41e9da433386c3136b", size = 111345, upload-time = "2026-05-19T21:28:43.93Z" }, + { url = "https://files.pythonhosted.org/packages/90/af/0248eb065e51129d2a9b2436cd1b5c772c19a6b04e5b6a186955671e3319/yarl-1.24.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7b54b9c67c2b06bd7b9a77253d242124b9c95d2c02def5a1144001ee547dd9d5", size = 106558, upload-time = "2026-05-19T21:28:45.806Z" }, + { url = "https://files.pythonhosted.org/packages/21/3c/f960d7a65ef97d8ba9b424fb5128796a4bc710fc6df2ddbbd7dfdc3bbd20/yarl-1.24.2-cp311-cp311-win_amd64.whl", hash = "sha256:f8fdbcff8b2c7c9284e60c196f693588598ddcee31e11c18e14949ce44519d45", size = 92808, upload-time = "2026-05-19T21:28:48.465Z" }, + { url = "https://files.pythonhosted.org/packages/03/1a/49fb03750e4de4d2284cd5b885a383133c34eef45bd59631b2bb8b7e81e8/yarl-1.24.2-cp311-cp311-win_arm64.whl", hash = "sha256:b32c37a7a337e90822c45797bf3d79d60875cfcccd3ecc80e9f453d87026c122", size = 87610, upload-time = "2026-05-19T21:28:50.07Z" }, + { url = "https://files.pythonhosted.org/packages/f0/da/866bcb01076ba49d2b42b309867bed3826421f1c479655eb7a607b44f20b/yarl-1.24.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8", size = 129957, upload-time = "2026-05-19T21:28:51.695Z" }, + { url = "https://files.pythonhosted.org/packages/bf/1d/fcefb70922ea2268a8971d8e5874d9a8218644200fb8465f1dcad55e6851/yarl-1.24.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2", size = 92164, upload-time = "2026-05-19T21:28:53.242Z" }, + { url = "https://files.pythonhosted.org/packages/29/b6/170e2b8d4e3bc30e6bfdcca53556537f5bf595e938632dfcb059311f3ff6/yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d", size = 91688, upload-time = "2026-05-19T21:28:54.865Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a5/c9f655d5553ea0b99fdac9d6a99ad3f9b3e73b8e5758bb46f58c9831f74c/yarl-1.24.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035", size = 102902, upload-time = "2026-05-19T21:28:56.963Z" }, + { url = "https://files.pythonhosted.org/packages/5d/bc/6b9664d815d79af4ee553337f9d606c56bbf269186ada9172de45f1b5f60/yarl-1.24.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576", size = 97931, upload-time = "2026-05-19T21:28:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/98/ec/32ba48acae30fecd60928f5791188b80a9d6ee3840507ffda29fecd37b71/yarl-1.24.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8", size = 111030, upload-time = "2026-05-19T21:29:00.148Z" }, + { url = "https://files.pythonhosted.org/packages/82/5a/6f4cd081e5f4934d2ae3a8ef4abe3afacc010d26f0035ee91b35cd7d7c37/yarl-1.24.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7", size = 110392, upload-time = "2026-05-19T21:29:02.155Z" }, + { url = "https://files.pythonhosted.org/packages/7a/da/323a01c349bd5fb01bb6652e314d9bb218cee630a736bdb810ad50e4013f/yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c", size = 105612, upload-time = "2026-05-19T21:29:04.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/80/264ab684f181e1a876389374519ff05d10248725535ae2ac4e8ac4e563d6/yarl-1.24.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d", size = 104487, upload-time = "2026-05-19T21:29:06.491Z" }, + { url = "https://files.pythonhosted.org/packages/41/07/efabe5df87e96d7ad5959760b888344be48cd6884db127b407c6b5503adc/yarl-1.24.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db", size = 102333, upload-time = "2026-05-19T21:29:08.267Z" }, + { url = "https://files.pythonhosted.org/packages/44/0c/bcf7c42603e1009295f586d8890f2ba032c8b53310e815adf0a202c73d9f/yarl-1.24.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712", size = 99025, upload-time = "2026-05-19T21:29:10.682Z" }, + { url = "https://files.pythonhosted.org/packages/4f/82/84482ab1a57a0f21a08afe6a7004c61d741f8f2ecc3b05c321577c612164/yarl-1.24.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996", size = 110507, upload-time = "2026-05-19T21:29:12.954Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8d/a546ba1dfe1b0f290e05fef145cd07614c0f15df1a707195e512d1e39d1d/yarl-1.24.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b", size = 103719, upload-time = "2026-05-19T21:29:14.893Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b6/267f2a09213138473adfce6b8a6e17791d7fee70bd4d9003218e4dec58b0/yarl-1.24.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c", size = 110438, upload-time = "2026-05-19T21:29:16.485Z" }, + { url = "https://files.pythonhosted.org/packages/48/2d/1c8d89c7c5f9cad9fb2902445d94e2ab1d7aa35de029afbb8ae95c42d00f/yarl-1.24.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1", size = 105719, upload-time = "2026-05-19T21:29:18.367Z" }, + { url = "https://files.pythonhosted.org/packages/a7/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad", size = 92901, upload-time = "2026-05-19T21:29:20.014Z" }, + { url = "https://files.pythonhosted.org/packages/39/47/4486ccfb674c04854a1ef8aa77868b6a6f765feaf69633409d7ca4f02cb8/yarl-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30", size = 87229, upload-time = "2026-05-19T21:29:22.1Z" }, + { url = "https://files.pythonhosted.org/packages/82/62/fcf0ce677f17e5c471c06311dd25964be38a4c586993632910d2e75278bc/yarl-1.24.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536", size = 128978, upload-time = "2026-05-19T21:29:23.83Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/8e63299bb71ed61a834121d9d3fe6c9fcf2a6a5d09754ff4f20f2d20baf5/yarl-1.24.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607", size = 91733, upload-time = "2026-05-19T21:29:25.375Z" }, + { url = "https://files.pythonhosted.org/packages/c1/24/16748d5dab6daec8b0ed81ccec639a1cded0f18dcc62a4f696b4fe366c37/yarl-1.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1", size = 91113, upload-time = "2026-05-19T21:29:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/b63fff7b71211e866624b21432d5943cbb633eb0c2872d9ee3070648f22c/yarl-1.24.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986", size = 103899, upload-time = "2026-05-19T21:29:28.842Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/ba1974b8533909636f7733fe86cf677e3619527c3c2fa913e0ea89c48757/yarl-1.24.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488", size = 97862, upload-time = "2026-05-19T21:29:31.086Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a5/123ac993b5c2ba6f554a140305620cb8f150fa543711bbc49be3ec0a65a4/yarl-1.24.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b", size = 111060, upload-time = "2026-05-19T21:29:32.657Z" }, + { url = "https://files.pythonhosted.org/packages/23/37/c472d3af3509688392134a88a825276770a187f1daa4de3f6dc0a327a751/yarl-1.24.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592", size = 110613, upload-time = "2026-05-19T21:29:34.379Z" }, + { url = "https://files.pythonhosted.org/packages/df/88/09c28dad91e662ccfaa1b78f1c57badde74fc9d0b23e74aef644750ecd73/yarl-1.24.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617", size = 107012, upload-time = "2026-05-19T21:29:36.216Z" }, + { url = "https://files.pythonhosted.org/packages/07/ab/9d4f69d571a94f4d112fa7e2e007200f5a54d319f58c82ac7b7baa61f5c6/yarl-1.24.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92", size = 105887, upload-time = "2026-05-19T21:29:38.746Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9a/000b2b66c0d772a499fc531d21dab92dfeb73b640a12eed6ba89f49bb2d0/yarl-1.24.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a", size = 103620, upload-time = "2026-05-19T21:29:40.368Z" }, + { url = "https://files.pythonhosted.org/packages/41/7c/7c1050f73450fbdaa3f0c72017059f00ce5e13366692f3dba25275a1083d/yarl-1.24.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44", size = 100599, upload-time = "2026-05-19T21:29:42.66Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b1/29e5756b3926705f5f6089bd5b9f50a56eaac550da6e260bf713ead44d04/yarl-1.24.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a", size = 110604, upload-time = "2026-05-19T21:29:44.632Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4b/8415bc96e9b150cde942fbac9a8182985e58f40ce5c54c34ed015407d3ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf", size = 105161, upload-time = "2026-05-19T21:29:46.755Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d4/cde059abfa229553b7298a2eadde2752e723d50aeedaef86ce59da2718ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056", size = 110619, upload-time = "2026-05-19T21:29:48.972Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2c/d6a6c9a61549f7b6c7e6dc6937d195bcf069582b47b7200dcd0e7b256acf/yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992", size = 107362, upload-time = "2026-05-19T21:29:51Z" }, + { url = "https://files.pythonhosted.org/packages/92/dd/3ae5fe417e9d1c353a548553326eb9935e76b6b727161563b424cc296df3/yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656", size = 92667, upload-time = "2026-05-19T21:29:52.743Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/a7beb239f78f27fca1b053c8e8595e4179c02e62249b4687ec218c370c50/yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461", size = 87069, upload-time = "2026-05-19T21:29:54.442Z" }, + { url = "https://files.pythonhosted.org/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2", size = 129670, upload-time = "2026-05-19T21:29:56.631Z" }, + { url = "https://files.pythonhosted.org/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630", size = 91916, upload-time = "2026-05-19T21:29:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8", size = 91625, upload-time = "2026-05-19T21:30:00.412Z" }, + { url = "https://files.pythonhosted.org/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14", size = 104574, upload-time = "2026-05-19T21:30:02.544Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535", size = 97534, upload-time = "2026-05-19T21:30:04.319Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14", size = 111481, upload-time = "2026-05-19T21:30:05.988Z" }, + { url = "https://files.pythonhosted.org/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3", size = 111529, upload-time = "2026-05-19T21:30:07.738Z" }, + { url = "https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208", size = 107338, upload-time = "2026-05-19T21:30:09.713Z" }, + { url = "https://files.pythonhosted.org/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50", size = 106147, upload-time = "2026-05-19T21:30:11.365Z" }, + { url = "https://files.pythonhosted.org/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd", size = 104272, upload-time = "2026-05-19T21:30:12.978Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67", size = 99962, upload-time = "2026-05-19T21:30:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1", size = 111063, upload-time = "2026-05-19T21:30:16.683Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1", size = 105438, upload-time = "2026-05-19T21:30:18.769Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b", size = 111458, upload-time = "2026-05-19T21:30:21.024Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8", size = 107589, upload-time = "2026-05-19T21:30:23.412Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0", size = 94424, upload-time = "2026-05-19T21:30:25.425Z" }, + { url = "https://files.pythonhosted.org/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57", size = 88690, upload-time = "2026-05-19T21:30:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b", size = 136248, upload-time = "2026-05-19T21:30:29.297Z" }, + { url = "https://files.pythonhosted.org/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761", size = 95084, upload-time = "2026-05-19T21:30:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8", size = 95272, upload-time = "2026-05-19T21:30:33.062Z" }, + { url = "https://files.pythonhosted.org/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed", size = 101497, upload-time = "2026-05-19T21:30:34.842Z" }, + { url = "https://files.pythonhosted.org/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543", size = 94002, upload-time = "2026-05-19T21:30:37.724Z" }, + { url = "https://files.pythonhosted.org/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0", size = 106524, upload-time = "2026-05-19T21:30:40.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024", size = 106165, upload-time = "2026-05-19T21:30:41.888Z" }, + { url = "https://files.pythonhosted.org/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf", size = 103010, upload-time = "2026-05-19T21:30:43.985Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc", size = 101128, upload-time = "2026-05-19T21:30:46.291Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb", size = 101382, upload-time = "2026-05-19T21:30:48.085Z" }, + { url = "https://files.pythonhosted.org/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420", size = 95964, upload-time = "2026-05-19T21:30:49.785Z" }, + { url = "https://files.pythonhosted.org/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f", size = 106204, upload-time = "2026-05-19T21:30:51.862Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa", size = 101510, upload-time = "2026-05-19T21:30:53.62Z" }, + { url = "https://files.pythonhosted.org/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe", size = 105584, upload-time = "2026-05-19T21:30:55.962Z" }, + { url = "https://files.pythonhosted.org/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd", size = 103410, upload-time = "2026-05-19T21:30:57.962Z" }, + { url = "https://files.pythonhosted.org/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215", size = 102980, upload-time = "2026-05-19T21:30:59.735Z" }, + { url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219, upload-time = "2026-05-19T21:31:01.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, +] + [[package]] name = "zhipuai" version = "2.1.5.20250825"