diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 041d561..bafa726 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,7 +84,7 @@ jobs: "$smoke_root/wheel-venv/bin/python" -c "import experiments.local_agent_dispatch" "$smoke_root/wheel-venv/bin/python" -I -c "import dyro.continuation" "$smoke_root/wheel-venv/bin/python" -I -c "from dyro.console.assets import validate_assets; validate_assets()" - "$smoke_root/wheel-venv/bin/python" -I -c "from importlib.resources import files; root=files('dyro.integrations').joinpath('assets'); assert root.joinpath('dyro-control-plane','SKILL.md').is_file(); assert root.joinpath('dyro-control-plane','agents','openai.yaml').is_file(); assert not root.joinpath('dyro-readonly').is_dir()" + "$smoke_root/wheel-venv/bin/python" -I -c "from importlib.resources import files; root=files('dyro.integrations').joinpath('assets'); assert root.joinpath('dyro-control-plane','SKILL.md').is_file(); assert root.joinpath('dyro-control-plane','agents','openai.yaml').is_file(); assert root.joinpath('dyro-dispatch','SKILL.md').is_file(); assert root.joinpath('dyro-dispatch','agents','openai.yaml').is_file(); assert not root.joinpath('dyro-readonly').is_dir()" test -x "$smoke_root/wheel-venv/bin/dyro" test ! -e "$smoke_root/wheel-venv/bin/dyro-bridge" test ! -e "$smoke_root/wheel-venv/bin/dyro-mcp" @@ -94,7 +94,7 @@ jobs: "$smoke_root/sdist-venv/bin/pip" install "${sdist_artifact}" "$smoke_root/sdist-venv/bin/python" -I -c "import dyro.continuation" "$smoke_root/sdist-venv/bin/python" -I -c "from dyro.console.assets import validate_assets; validate_assets()" - "$smoke_root/sdist-venv/bin/python" -I -c "from importlib.resources import files; root=files('dyro.integrations').joinpath('assets'); assert root.joinpath('dyro-control-plane','SKILL.md').is_file(); assert root.joinpath('dyro-control-plane','agents','openai.yaml').is_file(); assert not root.joinpath('dyro-readonly').is_dir()" + "$smoke_root/sdist-venv/bin/python" -I -c "from importlib.resources import files; root=files('dyro.integrations').joinpath('assets'); assert root.joinpath('dyro-control-plane','SKILL.md').is_file(); assert root.joinpath('dyro-control-plane','agents','openai.yaml').is_file(); assert root.joinpath('dyro-dispatch','SKILL.md').is_file(); assert root.joinpath('dyro-dispatch','agents','openai.yaml').is_file(); assert not root.joinpath('dyro-readonly').is_dir()" test -x "$smoke_root/sdist-venv/bin/dyro" test ! -e "$smoke_root/sdist-venv/bin/dyro-bridge" test ! -e "$smoke_root/sdist-venv/bin/dyro-mcp" diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index 302b9f8..a1cd6e7 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -144,7 +144,7 @@ jobs: "$smoke_root/wheel-venv/bin/pip" install "$GITHUB_WORKSPACE"/dist/dyro-*.whl "$smoke_root/wheel-venv/bin/python" -c "import experiments.local_agent_dispatch" "$smoke_root/wheel-venv/bin/python" -I -c "from dyro.console.assets import validate_assets; validate_assets()" - "$smoke_root/wheel-venv/bin/python" -I -c "from importlib.resources import files; root=files('dyro.integrations').joinpath('assets'); assert root.joinpath('dyro-control-plane','SKILL.md').is_file(); assert root.joinpath('dyro-control-plane','agents','openai.yaml').is_file()" + "$smoke_root/wheel-venv/bin/python" -I -c "from importlib.resources import files; root=files('dyro.integrations').joinpath('assets'); assert root.joinpath('dyro-control-plane','SKILL.md').is_file(); assert root.joinpath('dyro-control-plane','agents','openai.yaml').is_file(); assert root.joinpath('dyro-dispatch','SKILL.md').is_file(); assert root.joinpath('dyro-dispatch','agents','openai.yaml').is_file()" "$smoke_root/wheel-venv/bin/python" -I -c "import importlib.util; assert importlib.util.find_spec('dyro.bridge') is None" test -x "$smoke_root/wheel-venv/bin/dyro" test ! -e "$smoke_root/wheel-venv/bin/dyro-bridge" @@ -155,7 +155,7 @@ jobs: "$smoke_root/sdist-venv/bin/pip" install "$GITHUB_WORKSPACE"/dist/dyro-*.tar.gz "$smoke_root/sdist-venv/bin/python" -c "import experiments.local_agent_dispatch" "$smoke_root/sdist-venv/bin/python" -I -c "from dyro.console.assets import validate_assets; validate_assets()" - "$smoke_root/sdist-venv/bin/python" -I -c "from importlib.resources import files; root=files('dyro.integrations').joinpath('assets'); assert root.joinpath('dyro-control-plane','SKILL.md').is_file(); assert root.joinpath('dyro-control-plane','agents','openai.yaml').is_file()" + "$smoke_root/sdist-venv/bin/python" -I -c "from importlib.resources import files; root=files('dyro.integrations').joinpath('assets'); assert root.joinpath('dyro-control-plane','SKILL.md').is_file(); assert root.joinpath('dyro-control-plane','agents','openai.yaml').is_file(); assert root.joinpath('dyro-dispatch','SKILL.md').is_file(); assert root.joinpath('dyro-dispatch','agents','openai.yaml').is_file()" "$smoke_root/sdist-venv/bin/python" -I -c "import importlib.util; assert importlib.util.find_spec('dyro.bridge') is None" test -x "$smoke_root/sdist-venv/bin/dyro" test ! -e "$smoke_root/sdist-venv/bin/dyro-bridge" diff --git a/CHANGELOG.md b/CHANGELOG.md index 871d3dc..a1893b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,45 @@ ## Unreleased +## 0.6.8 - 2026-08-15 + +- Fix Console inspection falsely timing out healthy workspaces by budgeting for + isolated Python process startup. Unavailable or missing workspace counts now + render as unknown instead of zero, and partial pages no longer claim that all + projects are healthy. +- Add executable dispatch adapters for Cursor Agent, OpenCode, Grok, Hermes, + Kimi, DeepSeek Harness (`dsh`), and Pi alongside Codex and Claude. Every + adapter now has bounded process supervision, an explicit read/edit tool or + sandbox policy, a backend-specific authentication probe, and a structured + result decoder. Cursor dispatch intentionally requires `CURSOR_API_KEY` so + it can run with an isolated home instead of loading user MCP processes; + Cursor edit dispatch remains fail-closed until its sandbox process lifecycle + can be proven. Provider credentials are scoped to the selected model, and + successful-looking CLIs cannot leave closed-stdio descendants running. + Hermes receives only projected task context: user rules, identity, memory, + fallback providers, background review, and session persistence are disabled. + Pi requires Node.js 22.19.0 or newer. +- Preserve a multi-harness panel board when one member fails instead of losing + the other members' terminal results; deduplicate and cap explicit members, + and let every ready integrated Provider participate in selection. Default + panels remain a cost-bounded three-Provider sample; explicit `--members all` + runs every ready Provider with at most four concurrent members. +- Add the separately managed `dyro-dispatch` Skill for explicit parallel, + delegated, and independent-agent work. It installs through + `dyro integration install dispatch`, keeps outbound Provider effects separate + from the read-only control plane, and preserves all gate, signoff, merge, and + push boundaries. A single setup opt-in now installs both first-party Skills; + existing managed control-plane installs automatically gain Dispatch, and both + stay synchronized across interactive launches and package updates. +- Add persistent Batch V1 orchestration for two to four heterogeneous roles: + side-effect-free planning, digest-bound idempotent start, compact status, + bounded partial-result recovery, and cooperative cancellation. Plans bind + Provider choices, guarded context, timeouts, and edit HEAD; all members pass + preflight before any Provider starts, at most one may edit, and cleanup that + cannot be proven remains visible instead of being reported as cancelled. + Workers revalidate planned context immediately before Provider use, and edit + worktrees are pinned to the reviewed object ID. + ## 0.6.7 - 2026-08-13 - Make bare `dyro update` check, confirm, and install (same path as diff --git a/README.md b/README.md index 92896e0..017da7c 100644 --- a/README.md +++ b/README.md @@ -283,11 +283,25 @@ To upgrade later, run `pipx upgrade dyro`. If your team manages Python packages python3 -m pip install --user --upgrade dyro ``` -Interactive `dyro setup` can install the control-plane Skill during personal -preferences. After package updates, already-managed Skills sync automatically; -interactive launches also repair an outdated managed Skill. First-time install -remains opt-in via setup or `dyro integration install skill --yes` (alias: -`codex`). +Interactive `dyro setup` can install the first-party Skill bundle during personal +preferences. That one opt-in installs both `dyro-control-plane` and the separate +`dyro-dispatch` Skill. Existing managed control-plane installations automatically +gain the Dispatch companion on the next interactive launch or package refresh, and +both managed Skills then stay synchronized with Dyro updates. Machines that have +never opted into a Dyro Skill are not modified silently. + +Multi-harness delegation remains separate from the read-only control plane so its +process/network effects are explicit. Manual installation is still available: +preview with `dyro integration install dispatch --dry-run`, then install with +`dyro integration install dispatch --yes`. + +For two to four different roles, `dyro dispatch batch-plan` produces a +side-effect-free, context-bound plan. Review its digest before +`batch-start --expect-plan-sha256 …`, then recover through `batch-status`, +`batch-result`, or `batch-cancel`. Batch V1 is independent fan-out with at most +one edit writer; it is not a dependency DAG, retry queue, or automatic judge. +Use explicit synchronous `panel --members all` only for a full-ready-Provider +same-task comparison. Interactive `dyro`, `dyro home`, and `dyro start` launches check the official PyPI endpoint at most once per local day. A failed or slow check never blocks diff --git a/README.zh-CN.md b/README.zh-CN.md index c851638..8a5fe31 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -281,9 +281,20 @@ dyro --version python3 -m pip install --user --upgrade dyro ``` -交互式 `dyro setup` 可在个人偏好步骤安装控制面 Skill。包升级后会自动同步**已托管** -的 Skill;交互启动时也会修复过期的托管安装。首次安装仍需 setup 勾选,或手动运行 -`dyro integration install skill --yes`(别名 `codex`)。 +交互式 `dyro setup` 可在个人偏好步骤一次启用第一方 Skill 套件,同时安装 +`dyro-control-plane` 与独立的 `dyro-dispatch`。已经托管控制面 Skill 的用户会在下次 +交互启动或包更新后自动补装 Dispatch,此后两项都会随 Dyro 自动同步;从未启用过 +Dyro Skill 的机器不会被后台静默写入。 + +多 Harness 委派仍与只读控制面隔离,确保 Provider 进程与网络副作用需要明确请求。 +也可手动运行 `dyro integration install dispatch --dry-run` 预览,再用 +`dyro integration install dispatch --yes` 安装。 + +需要 2–4 个不同角色时,先用 `dyro dispatch batch-plan` 生成不创建状态、绑定 +上下文的计划;审阅摘要后再执行 `batch-start --expect-plan-sha256 …`,随后可用 +`batch-status`、`batch-result` 或 `batch-cancel` 恢复生命周期。Batch V1 是最多 +一个编辑者的独立 fan-out,并非依赖 DAG、重试队列或自动终裁器。只有用户明确要求 +全 Harness 同题比较时,才使用同步的 `panel --members all`。 交互运行 `dyro`、`dyro home` 或 `dyro start` 时,Dyro 每个本地自然日最多访问一次官方 PyPI;断网、超时或状态目录不可写都不会阻塞进入工作区。默认仍由用户确认更新: diff --git a/docs/designs/local-web-console.md b/docs/designs/local-web-console.md index 9f721be..96249d2 100644 --- a/docs/designs/local-web-console.md +++ b/docs/designs/local-web-console.md @@ -226,8 +226,9 @@ C04 将真实工作区读取移出 HTTP 请求线程,并补齐概览的单工 - `create_console_http_server()` 默认装配 `IsolatedOverviewService` IPC client。它以固定 `python -m` argv、最小环境和新 session 启动 inspection process;worker 不继承 bearer、bootstrap、编码工具 配置或其它宿主环境值,异常 stderr 不会进入 API; -- inspection outer worker 对单次请求施加 5 秒硬 deadline。它在内部最多运行 4 个 daemon 子进程, - 每个 workspace 750ms;超时或崩溃只返回该 workspace 的 `unavailable`/`WORKSPACE_TIMEOUT` 卡片, +- inspection outer worker 对单次请求施加 8 秒硬 deadline。它在内部最多运行 4 个 daemon 子进程, + 每个 workspace 3 秒、整页内部采集最多 6 秒;超时或崩溃只返回该 workspace 的 + `unavailable`/`WORKSPACE_TIMEOUT` 卡片, 并在父 deadline 后终止整个 process group; - 当前 process-tree 回收仅在 POSIX 平台启用;Windows 在具备经验证的 Job Object 回收实现前对 inspection fail closed,不会以“只终止 outer process”的方式留下读取子进程; @@ -591,7 +592,8 @@ CLI Browser Server - registry 结构损坏是一个全局 `registry_unavailable` 状态;Console 保持可打开并提供恢复说明,但绝不覆盖文件。 - Profile 或 Objective 损坏只影响对应 workspace card。 -- summary 默认最多 4 个 workspace 并行读取,单 workspace 预算 750 ms,总预算 5 秒。 +- summary 默认最多 4 个 workspace 并行读取,单 workspace 预算 3 秒,内部总预算 6 秒,外层 + inspection process group 硬预算 8 秒。该预算包含隔离 Python 子进程的启动成本。 - detail 中每个 Git 子进程必须传显式 timeout;超时转换为 `GIT_PROBE_TIMEOUT`。 - HTTP 主进程把 registry 和 workspace inspection 交给 `exec` 启动的内部 worker process;父进程 使用单调时钟硬 deadline、有界队列和最多 4 个 worker。协作式 cancellation 只用于普通步骤, diff --git a/docs/designs/optional-local-agent-dispatch.md b/docs/designs/optional-local-agent-dispatch.md index eb95630..49bd681 100644 --- a/docs/designs/optional-local-agent-dispatch.md +++ b/docs/designs/optional-local-agent-dispatch.md @@ -191,7 +191,7 @@ DetachedWorker ## 9. 异步生命周期与并发 ```text -accepted → running → completed|failed|timeout +accepted → running → completed|failed|timeout|cancelled ↑ lease heartbeat ``` @@ -204,7 +204,7 @@ accepted → running → completed|failed|timeout | 租约 | `pid` + `process_started_at` + 随机 owner token;续租/释放必须匹配所有权 | | init grace | 槽位新建后短窗口内禁止判死 | | 僵死回收 | rename 抢占 + 删除;失败则下轮重试 | -| GC | 超龄 run、影子目录、不活跃 thread 可回收 | +| GC | 活跃/未过期 orchestration 保护成员;终态过期后与 run 一致回收,但保留 request tombstone 防重复计费 | worker 记录会持久化 `pid + process_started_at + owner token`。短命的派发 CLI 退出后,新 Supervisor、`result --wait` 与 GC 都会重建监控:只有操作系统证明该 @@ -213,10 +213,48 @@ worker 记录会持久化 `pid + process_started_at + owner token`。短命的 相对「仅 TTL 锁」:增加 **进程启动时刻** 防 PID 复用;相对「无 grace」:避免 init 竞态误杀。 -当前 `run` / `panel` / worker 的进程树监管限定 POSIX(Linux/macOS);Windows +当前 `run` / `panel` / worker 的专用进程组监管限定 POSIX(Linux/macOS)。它不是 +OS 容器:主动 `setsid` 脱离该进程组的恶意后代不在终止证明内,因此真实 Provider +仍必须标记为 unconfined 并由调用方显式确认。Windows 允许导入与只读 discovery,但执行会 fail-closed,直到提供并验证 Windows 原生 process-tree 与 pipe 后端。 +### 9.1 持久 Batch V1 + +不同角色或不同仓库切片使用独立的持久编排,而不是让宿主临时维护多个 +`run_id`: + +```text +batch-plan → batch-start → batch-status → batch-result + └──→ batch-cancel +``` + +- 请求包含 2–4 个 `strategy=independent` 成员、唯一 `role_id` 和完整 + TaskContract;最多一个 edit writer。 +- `batch-plan` 不创建状态,也不启动 Provider/鉴权 CLI;它只解析已安装候选。 + JCS + SHA-256 摘要绑定规范化合同、canonical project root、确定的 Provider、 + 非秘密执行 profile(含适配器内层 Provider/model)、守卫后的 context digest、 + timeout,以及 edit 成员的 Git HEAD。 +- `batch-start` 必须携带已审阅摘要并重新计划;先主动验证全部登录态,再创建 + 状态。任何 context、HEAD 或 Provider execution profile 漂移都会拒绝启动。 + 全部成员预检并创建确定性 run 后才开始 Provider。 +- 每个 run 继续持有 planned context digest;异步 worker 在调用 Provider 前重新 + 读取并核对,关闭 start 与真正执行之间的 TOCTOU。edit context 必须是已审阅 + HEAD 中的 clean tracked files,detached worktree 直接固定到该 object ID。 +- `request_id + plan_sha256` 确定 orchestration ID;orchestration ID + member + index 确定 run ID。相同请求和计划可幂等续建,不重复启动已运行/终态成员。 +- GC 删除终态过期 manifest/run 后保留有界 request tombstone;旧 + `request_id` 不得再次启动,显式的新执行必须使用新的 request ID。 +- `batch-status` 只返回小型状态投影;`batch-result` 只在成员终态后聚合有界 + summary/evidence/warnings/patch_ref,且不会因单个失败抹掉健康结果。 +- `batch-cancel` 先持久化取消意图。accepted 成员可直接取消;running 成员由 + 精确 worker generation 协作终止,只有 backend cleanup 获得证明后才成为 + cancelled。证明不足时保留 running/attention,不做跨进程猜测式 `killpg`。 + +Batch V1 不提供依赖 DAG、超过四成员的异步队列、自动 retry/resume、自动 +judge 或多 edit writer。显式 `panel --members all` 仍是同步的全 ready Provider +同题比较,不能伪装成 Batch 队列。 + ## 10. Edit 模式:worktree + patch 1. 从当前 Git `HEAD` 创建 detached 隔离 worktree;源工作区未提交变更不会隐式复制 @@ -238,8 +276,8 @@ process-tree 与 pipe 后端。 安装/刷新时: -1. `dyro dispatch backends` 探测本机命令与登录态;当前受审计、可执行的集成为 `codex` 和 `claude`。 -2. `cursor-agent`、`opencode`、`grok`、`hermes`、`kimi` 会被展示为“已发现但未集成”,不得被自动或手动路由,直到各自拥有经过审计的非交互协议 adapter。 +1. `dyro dispatch --dry-run backends` 仅被动发现本机命令并标记 `authentication_probe=not_run`,不会启动第三方鉴权 CLI;授权执行前再用非 dry-run 的 `dyro dispatch backends` 主动核验登录态。当前受审计、可执行的集成为 `codex`、`claude`、`cursor-agent`、`opencode`、`grok`、`hermes`、`kimi`、`dsh` 和 `pi`。其中 Cursor 仅支持只读派发;其 edit 模式在沙箱进程生命周期获得证明前保持 fail-closed。 +2. 每个 Provider 只有在命令存在、精确鉴权通过且相应非交互 adapter 就绪时才可路由;未登录或运行时布局不受支持时继续展示,但不得进入自动选择。Hermes 额外关闭用户规则、身份、记忆、fallback、后台复核与会话持久化,仅接收 Dyro 投影上下文。Kimi 只绑定一个已选择的 Provider/model 路由;文件型 OAuth 仅复制该路由对应的 token 到单次运行目录,keyring 型 OAuth 因无法证明隔离而 fail-closed。DSH 通过受审查的 headless patch 固定为 `deepseek-official/deepseek-v4-flash`。 3. 当只有一个已认证 Provider 时,`backend: auto` 可以选择它;当有多个时,用户必须使用 `dyro dispatch route add default ` 选择默认路由;一个也没有时 fail-closed 并给出发现结果。 4. `echo` 仅用于显式 `--backend echo --allow-offline-simulation` 的确定性测试,输出的 `execution_kind=offline-simulation`、低置信度和非生产警告不得被上游当作真实模型结论。 5. 渲染 skill 正文只含已准备 Provider、发现但未集成的命令、路由表与上述限制,再分发到各宿主 skills 目录。 @@ -262,6 +300,7 @@ process-tree 与 pipe 后端。 | **L1** | `RunStore` + 双层槽位租约 + strict 影子接入 | 单测绿 | | **L2** | `echo`/`codex`/`claude` 适配器 + CLI `run`/`result` | 单测(echo)+ 本机可选真 CLI | | **L3** | `panel`、skill 渲染、routes、`gc` | 单测绿 | +| **L4** | 持久 Batch V1:plan/start/status/result/cancel | 单测 + 对抗生命周期绿 | ## 15. 测试矩阵(L0) diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 1f08be3..f8f05be 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -77,11 +77,16 @@ page. Current catalog entries include Antigravity CLI, Codex CLI, Codex App, Claude Code CLI, Claude Code Desktop, Cursor Desktop, Cursor CLI, Grok, OpenCode, -OpenClaw, Hermes, Kimi Code, Qoder CLI, ZCode, and Shell. Antigravity uses the +OpenClaw, Hermes, Kimi Code, DeepSeek Harness, Pi, Qoder CLI, ZCode, and Shell. Antigravity uses the official `agy` command; Qoder uses `qodercli`; ZCode is opened as a desktop workspace tool. An entry can be known without having an audited installation recipe; such entries fail closed with an actionable message. +DeepSeek Harness and Pi remain Home launch-only tools and are not offered as +Profile presets until their non-interactive task adapters have been audited. +Pi also requires Node.js 22.19.0 or newer; discovery and guided installation +fail closed when the local runtime does not satisfy that requirement. + ## Compact Home picker Home starts by showing at most three common choices: the most recent or diff --git a/docs/updates.md b/docs/updates.md index cf55d4e..5c4f9f4 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -38,9 +38,8 @@ Cancelling setup leaves both project files and update preferences untouched. which keeps scripts and CI free of user-level side effects. Set `DYRO_NO_UPDATE_CHECK=1` for a process-level opt-out without changing the -saved preference. The same gate currently also skips the interactive startup -repair of an outdated managed Skill (they share the daily-update launch -filter). +saved preference. The same gate currently also skips interactive startup repair +of the managed Skill bundle (they share the daily-update launch filter). ## Installing an update @@ -54,15 +53,16 @@ instructions returned by the network. The requirement is pinned to the version that was checked and Dyro verifies the installed distribution version after the command succeeds. -## Control-plane Skill +## First-party Skill bundle -Interactive `dyro setup` can install the Skill during personal preferences -(preview in the plan, applied only after confirmation). After a successful -`dyro update` (or patch auto-update), Dyro best-effort syncs an **already -managed** Skill via the fresh `dyro` entry point. On interactive -`dyro` / `dyro home` / `dyro start` launches, an **outdated** managed Skill is -repaired automatically. First-time install is never forced on upgrade or -startup; use setup or: +Interactive `dyro setup` can install the first-party Skill bundle during +personal preferences (preview in the plan, applied only after confirmation). +After a successful `dyro update` or patch auto-update, Dyro best-effort syncs +the managed bundle through the fresh `dyro` entry point. Interactive `dyro`, +`dyro home`, and `dyro start` launches also repair outdated managed Skills. +The control-plane opt-in covers first-party companions, so an existing managed +control plane automatically gains `dyro-dispatch`. A machine with no prior Dyro +Skill ownership remains untouched. Manual control-plane commands are: ```bash dyro integration install skill --dry-run @@ -72,6 +72,19 @@ dyro integration sync skill --yes # upgrade-only; skips absent installs (`codex` is an alias for `skill`.) +The outbound `dyro-dispatch` Skill keeps independent ownership state from the +read-only control-plane Skill. One `dyro setup` opt-in installs both. An existing +managed control-plane installation also gains the Dispatch companion on the next +interactive launch or post-update refresh; after that, both stay synchronized. +Hosts with no prior Dyro Skill opt-in remain untouched. Manual lifecycle commands +are still available: + +```bash +dyro integration install dispatch --dry-run +dyro integration install dispatch --yes +dyro integration sync dispatch --yes +``` + Editable source installations are deliberately rejected. Update those through their Git checkout so a convenience command cannot replace a development environment with a published wheel. diff --git a/experiments/local_agent_dispatch/README.md b/experiments/local_agent_dispatch/README.md index 3e13425..701e61f 100644 --- a/experiments/local_agent_dispatch/README.md +++ b/experiments/local_agent_dispatch/README.md @@ -13,8 +13,9 @@ Discipline: [`docs/agent-orchestration-discipline.md`](../../docs/agent-orchestr | --- | --- | | L0 | TaskContract, ContextGuard, LocatorVerify, process identity | | L1 | RunStore, dual-scope slot leases, strict shadow integration | -| L2 | `echo` / `codex` / `claude` adapters + CLI `run` / `result` | +| L2 | bounded adapters for Codex, Claude, Cursor, OpenCode, Grok, Hermes, Kimi, DSH, and Pi + CLI `run` / `result` | | L3 | `panel`, skill render, routes, `gc` | +| L4 | persistent Batch V1 `plan` / `start` / `status` / `result` / `cancel` | ## CLI @@ -29,32 +30,80 @@ dyro dispatch run --project . --file task.json --wait --backend claude --allow-u # Echo is a deliberate test simulation, never an automatic fallback: dyro dispatch run --project . --file task.json --backend echo --allow-offline-simulation dyro dispatch result -dyro dispatch panel --project . --file task.json --members echo +dyro dispatch panel --project . --file task.json --members codex,claude +# Explicit full-harness comparison; all ready Providers, at most four at once: +dyro dispatch panel --project . --file task.json --members all + +# Different independent roles: plan without state, review the digest, then start. +dyro dispatch batch-plan --project . --file batch.json +dyro dispatch batch-start --project . --file batch.json --expect-plan-sha256 +dyro dispatch batch-status +dyro dispatch batch-result --wait --timeout 300 +dyro dispatch batch-cancel dyro dispatch skill-render --write dyro dispatch gc --dry-run +# Install the managed host Skill (separate from dyro-control-plane): +dyro integration install dispatch --dry-run +dyro integration install dispatch --yes + # Equivalent module form: python3 -m experiments.local_agent_dispatch doctor ``` State home: `~/.dyro/local-agent-dispatch/` (override with `--home` or `DYRO_LOCAL_AGENT_DISPATCH_HOME`). +Batch V1 accepts two to four independent member contracts, resolves installed +Provider candidates without starting authentication CLIs, and allows at most one +edit writer. `batch-start` actively authenticates every selected Provider before +creating state. Its reviewed +plan digest binds the canonical project root, normalized contracts, Provider +choices and non-secret execution profiles, guarded context digests, timeouts, and +the Git HEAD for an edit member. +The worker rechecks its planned context digest immediately before Provider use; +edit members require clean tracked context files and create their detached +worktree at the reviewed object ID. Starting the same live request and plan is +idempotent. GC retains a small request tombstone and rejects later reuse of a +garbage-collected `request_id`, preventing an old retry from silently starting +another billed batch. Batch V1 intentionally has no +DAG dependencies, queue beyond four members, automatic retry, or automatic +judge. `panel --members all` remains the explicit synchronous full-ready-Provider +comparison. + Process supervision for `run`, `panel`, and the internal worker is supported on -POSIX hosts (Linux and macOS). Windows can import the shipped package and use +POSIX hosts (Linux and macOS). This supervision covers the dedicated process +group, not a container; an intentionally daemonized child that escapes the group +is outside the guarantee, which is one reason real Providers remain explicitly +unconfined. Windows can import the shipped package and use read-only discovery such as `dispatch --dry-run doctor`, but execution fails closed until a Windows process-tree backend is implemented. +Global `--dry-run` backend and doctor output is passive: it reports command +availability with `authentication_probe=not_run` and never starts a Provider +authentication CLI. Run non-dry `dyro dispatch backends` only at an authorized +execution boundary to verify active login readiness. + ## Task JSON shape See design §4 (five-part contract). `auto` considers only integrated, authenticated -Providers; with several it requires `dyro dispatch route add default `, and -with none it fails closed. `cursor-agent`, `opencode`, `grok`, `hermes`, and `kimi` -may be discovered, but cannot run until an audited adapter exists. Backend `echo` is +Providers in a deterministic preference order and fails closed when none are ready. +Integrated Provider IDs are `codex`, `claude`, +`cursor-agent`, `opencode`, `grok`, `hermes`, `kimi`, `dsh`, and `pi`. A Provider +is routable only when its backend-specific authentication probe succeeds. Cursor +requires `CURSOR_API_KEY` for dispatch so Dyro can give it an isolated home without +loading user MCP/plugin processes; an interactive Cursor OAuth login alone is not +reported as dispatch-ready. Cursor currently supports read-only dispatch only; +edit mode fails closed until its sandbox process lifecycle can be proven. +Kimi binds one selected Provider/model route into the execution-profile digest; +file-backed OAuth copies only that selected token into a per-run home. Keyring-backed +Kimi OAuth is discovered but fails closed because Dyro cannot isolate it. DSH runs +with a reviewed headless patch that pins `deepseek-official/deepseek-v4-flash`. +Backend `echo` is an explicit offline simulation: task JSON must set `allow_offline_simulation: true` and callers must not treat its low-confidence result as a Provider conclusion. Real non-strict Provider calls require `allow_unconfined_provider: true`; read-only calls receive a guarded context projection, which is not OS-level isolation. -`strict: true` is fail-closed: the selected adapter must declare a verified strict-isolation capability. The shipped external Codex and Claude CLI adapters do not; use `echo` only for protocol validation, and reject strict work until an adapter can prove the required isolation. +`strict: true` is fail-closed: the selected adapter must declare a verified strict-isolation capability. The shipped external Provider adapters do not; use `echo` only for protocol validation, and reject strict work until an adapter can prove the required isolation. Edit runs execute in a detached Git worktree and return a hash-bound patch reference; they do not mutate, commit, or push the source worktree. ## Tests diff --git a/experiments/local_agent_dispatch/adapters/base.py b/experiments/local_agent_dispatch/adapters/base.py index 02c77ef..5b38688 100644 --- a/experiments/local_agent_dispatch/adapters/base.py +++ b/experiments/local_agent_dispatch/adapters/base.py @@ -11,7 +11,7 @@ @dataclass class AdapterResult: - status: str # ok | error | timeout + status: str # ok | error | timeout | cancelled summary: str evidence: list[dict[str, object]] = field(default_factory=list) confidence: str = "medium" @@ -28,11 +28,20 @@ class BackendAdapter(Protocol): id: str command: str strict_isolation: bool + supported_modes: frozenset[str] def available(self) -> bool: ... def authenticated(self) -> bool: ... + def readiness_reason(self) -> str: ... + + def worker_environment( + self, *, isolated_home: Path | None = None + ) -> dict[str, str]: ... + + def execution_profile(self) -> Mapping[str, str]: ... + def run( self, *, diff --git a/experiments/local_agent_dispatch/adapters/echo.py b/experiments/local_agent_dispatch/adapters/echo.py index 0810422..850a8da 100644 --- a/experiments/local_agent_dispatch/adapters/echo.py +++ b/experiments/local_agent_dispatch/adapters/echo.py @@ -20,6 +20,12 @@ def available(self) -> bool: def authenticated(self) -> bool: return True + def execution_profile(self) -> Mapping[str, str]: + return {"backend": self.id, "command": self.command} + + def readiness_reason(self) -> str: + return "" + def run( self, *, diff --git a/experiments/local_agent_dispatch/adapters/registry.py b/experiments/local_agent_dispatch/adapters/registry.py index 87f0cbd..35634e6 100644 --- a/experiments/local_agent_dispatch/adapters/registry.py +++ b/experiments/local_agent_dispatch/adapters/registry.py @@ -2,23 +2,41 @@ from __future__ import annotations +import hashlib import os +from pathlib import Path import shutil +from typing import Mapping +from dyro.canonical import canonical_json_bytes from ..errors import DispatchValidationError +from ..context_guard import assert_content_allowed from .base import BackendAdapter from .echo import EchoAdapter -from .subprocess_cli import claude_adapter, codex_adapter - - -REAL_PROVIDER_IDS = ("codex", "claude") -DISCOVER_ONLY_PROVIDERS = { - "cursor-agent": "cursor-agent", - "opencode": "opencode", - "grok": "grok", - "hermes": "hermes", - "kimi": "kimi", -} +from .subprocess_cli import ( + claude_adapter, + codex_adapter, + cursor_adapter, + dsh_adapter, + grok_adapter, + hermes_adapter, + kimi_adapter, + opencode_adapter, + pi_adapter, +) + + +REAL_PROVIDER_IDS = ( + "codex", + "claude", + "cursor-agent", + "opencode", + "grok", + "hermes", + "kimi", + "dsh", + "pi", +) def _all() -> dict[str, BackendAdapter]: @@ -26,6 +44,13 @@ def _all() -> dict[str, BackendAdapter]: EchoAdapter(), codex_adapter(), claude_adapter(), + cursor_adapter(), + opencode_adapter(), + grok_adapter(), + hermes_adapter(), + kimi_adapter(), + dsh_adapter(), + pi_adapter(), ] return {a.id: a for a in adapters} @@ -44,12 +69,66 @@ def adapter_is_authenticated(adapter: BackendAdapter) -> bool: return adapter.id == "echo" authenticated = getattr(adapter, "authenticated", None) if callable(authenticated): - return bool(authenticated()) + try: + return bool(authenticated()) + except DispatchValidationError: + return False # Preserve the original adapter-test/extension compatibility contract: # adapters predating the explicit auth probe use availability as the probe. return adapter.available() +def adapter_execution_profile(adapter: BackendAdapter) -> dict[str, str]: + """Return a bounded, non-secret identity for the selected execution route.""" + provider = getattr(adapter, "execution_profile", None) + raw: Mapping[str, str] + if callable(provider): + raw = provider() + else: + command = getattr(adapter, "command", "") + resolved = shutil.which(command) if command else None + raw = { + "backend": str(getattr(adapter, "id", "")), + "command_path": str(Path(resolved).resolve()) if resolved else command, + } + return normalize_execution_profile( + raw, + backend=str(getattr(adapter, "id", "")), + ) + + +def normalize_execution_profile( + raw: Mapping[str, str], + *, + backend: str, +) -> dict[str, str]: + if not isinstance(raw, Mapping) or not raw: + raise DispatchValidationError("backend execution profile must be an object") + normalized: dict[str, str] = {} + for name, value in raw.items(): + if ( + type(name) is not str + or not name + or len(name) > 64 + or type(value) is not str + or len(value) > 4096 + ): + raise DispatchValidationError("backend execution profile is invalid") + normalized[name] = value + assert_content_allowed(value, label=f"backend execution profile {name}") + if normalized.get("backend") != backend: + raise DispatchValidationError("backend execution profile identity mismatch") + return dict(sorted(normalized.items())) + + +def adapter_execution_profile_sha256(adapter: BackendAdapter) -> str: + return execution_profile_sha256(adapter_execution_profile(adapter)) + + +def execution_profile_sha256(profile: Mapping[str, str]) -> str: + return hashlib.sha256(canonical_json_bytes(dict(profile))).hexdigest() + + def get_adapter( backend_id: str, *, @@ -79,34 +158,30 @@ def get_adapter( return adapter -def probe_backends() -> list[dict[str, object]]: +def probe_backends(*, passive: bool = False) -> list[dict[str, object]]: + """Describe adapters; passive mode never starts third-party auth CLIs.""" rows: list[dict[str, object]] = [] for adapter_id, adapter in sorted(_all().items()): available = adapter.available() - rows.append( - { - "id": adapter_id, - "command": adapter.command, - "available": available, - "authenticated": adapter_is_authenticated(adapter), - "strict_isolation": adapter.strict_isolation, - "supported": adapter.id != "echo", - "execution_kind": ( - "offline-simulation" if adapter.id == "echo" else "provider" - ), - } - ) - for provider_id, command in sorted(DISCOVER_ONLY_PROVIDERS.items()): - rows.append( - { - "id": provider_id, - "command": command, - "available": shutil.which(command) is not None, - "authenticated": False, - "strict_isolation": False, - "supported": False, - "execution_kind": "unintegrated", - "reason": "command discovery only; no audited non-interactive adapter", - } - ) + authenticated = False if passive else adapter_is_authenticated(adapter) + row: dict[str, object] = { + "id": adapter_id, + "command": adapter.command, + "available": available, + "authenticated": authenticated, + "strict_isolation": adapter.strict_isolation, + "supported": adapter.id != "echo", + "execution_kind": ( + "offline-simulation" if adapter.id == "echo" else "provider" + ), + "authentication_probe": "not_run" if passive else "completed", + } + if passive and available and adapter.id != "echo": + row["reason"] = "authentication probe not run in dry-run mode" + elif available and adapter.id != "echo" and not authenticated: + reason = getattr(adapter, "readiness_reason", None) + row["reason"] = ( + reason() if callable(reason) else "authentication probe failed" + ) + rows.append(row) return rows diff --git a/experiments/local_agent_dispatch/adapters/subprocess_cli.py b/experiments/local_agent_dispatch/adapters/subprocess_cli.py index c3d8e28..b74f15b 100644 --- a/experiments/local_agent_dispatch/adapters/subprocess_cli.py +++ b/experiments/local_agent_dispatch/adapters/subprocess_cli.py @@ -1,12 +1,19 @@ -"""Fail-closed adapters for local Codex and Claude command-line backends.""" +"""Fail-closed adapters for supported local command-line agent harnesses.""" from __future__ import annotations +from contextlib import contextmanager +from dataclasses import dataclass +import hashlib import json import os from pathlib import Path +import re import shutil -from typing import Callable, Mapping, Sequence +import stat +import tempfile +import tomllib +from typing import Callable, Iterator, Mapping, Sequence from ..bounded_process import BoundedCompletedProcess, run_bounded from ..context_guard import assert_content_allowed, safe_error_text @@ -39,13 +46,101 @@ ), "claude": frozenset( { + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "CLAUDE_CODE_OAUTH_TOKEN", + "CLAUDE_CONFIG_DIR", + "XDG_CONFIG_HOME", + } + ), + "cursor-agent": frozenset( + {"CURSOR_API_KEY", "CURSOR_API_ENDPOINT", "XDG_CONFIG_HOME"} + ), + "opencode": frozenset( + { + "OPENCODE_CONFIG_DIR", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + } + ), + "grok": frozenset({"GROK_HOME", "XAI_API_KEY"}), + "hermes": frozenset( + { + "HERMES_HOME", + "HERMES_INFERENCE_MODEL", + } + ), + "kimi": frozenset( + { + "KIMI_CODE_HOME", + } + ), + "dsh": frozenset({"DEEPSEEK_API_KEY", "DSH_HOME"}), + "pi": frozenset( + { + "PI_CODING_AGENT_DIR", + } + ), +} + +_PROVIDER_CREDENTIAL_NAMES = { + "anthropic": ( "ANTHROPIC_API_KEY", + "ANTHROPIC_TOKEN", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", - "CLAUDE_CONFIG_DIR", - "XDG_CONFIG_HOME", - } ), + "anthropiccompatible": ( + "ANTHROPIC_API_KEY", + "ANTHROPIC_TOKEN", + "ANTHROPIC_AUTH_TOKEN", + "CLAUDE_CODE_OAUTH_TOKEN", + ), + "deepinfra": ("DEEPINFRA_API_KEY", "DEEPINFRA_BASE_URL"), + "deepinfraai": ("DEEPINFRA_API_KEY", "DEEPINFRA_BASE_URL"), + "deepseek": ("DEEPSEEK_API_KEY", "DEEPSEEK_BASE_URL"), + "gemini": ("GOOGLE_API_KEY", "GEMINI_API_KEY", "GEMINI_BASE_URL"), + "google": ("GOOGLE_API_KEY", "GEMINI_API_KEY", "GEMINI_BASE_URL"), + "kimi": ( + "KIMI_API_KEY", + "KIMI_CODING_API_KEY", + "KIMI_BASE_URL", + ), + "kimicoding": ("KIMI_API_KEY", "KIMI_CODING_API_KEY", "KIMI_BASE_URL"), + "kimicodingcn": ("KIMI_CN_API_KEY",), + "moonshot": ("KIMI_API_KEY", "KIMI_CODING_API_KEY", "KIMI_BASE_URL"), + "minimax": ( + "MINIMAX_API_KEY", + "MINIMAX_BASE_URL", + ), + "minimaxcn": ("MINIMAX_CN_API_KEY", "MINIMAX_CN_BASE_URL"), + "minimaxoauth": (), + "nvidia": ("NVIDIA_API_KEY", "NVIDIA_BASE_URL"), + "nvidianim": ("NVIDIA_API_KEY", "NVIDIA_BASE_URL"), + "nousportal": (), + "openai": ("OPENAI_API_KEY", "OPENAI_BASE_URL"), + "openaiapi": ("OPENAI_API_KEY", "OPENAI_BASE_URL"), + "openaicodex": (), + "openaicompatible": ("OPENAI_API_KEY", "OPENAI_BASE_URL"), + "openrouter": ("OPENROUTER_API_KEY",), + "qwenoauth": (), + "qwen": (), + "nous": (), + "stepfun": ("STEPFUN_API_KEY", "STEPFUN_BASE_URL"), + "stepfunstep": ("STEPFUN_API_KEY", "STEPFUN_BASE_URL"), + "xai": ("XAI_API_KEY", "XAI_BASE_URL"), + "xaioauth": (), + "grok": ("XAI_API_KEY", "XAI_BASE_URL"), + "zai": ("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY", "GLM_BASE_URL"), +} + +_DEFAULT_BACKEND_HOMES = { + "cursor-agent": ("HOME", "."), + "grok": ("GROK_HOME", ".grok"), + "hermes": ("HERMES_HOME", ".hermes"), + "kimi": ("KIMI_CODE_HOME", ".kimi-code"), + "dsh": ("DSH_HOME", ".dsh"), + "pi": ("PI_CODING_AGENT_DIR", ".pi/agent"), } @@ -55,11 +150,770 @@ def _backend_environment(backend: str) -> dict[str, str]: backend, frozenset(), ) - return { + environment = { name: value for name, value in os.environ.items() if name in allowed and value } + default_home = _DEFAULT_BACKEND_HOMES.get(backend) + if default_home is not None: + variable, relative = default_home + default_path = Path.home() if relative == "." else Path.home() / relative + environment.setdefault(variable, str(default_path)) + if backend == "opencode": + environment.setdefault("XDG_CONFIG_HOME", str(Path.home() / ".config")) + environment.setdefault("XDG_DATA_HOME", str(Path.home() / ".local/share")) + return environment + + +def _read_small_regular_bytes( + path: Path, + *, + max_bytes: int = 1024 * 1024, + require_private: bool = False, +) -> bytes | None: + flags = ( + os.O_RDONLY + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NONBLOCK", 0) + ) + flags |= getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(path, flags) + except OSError: + return None + try: + opened = os.fstat(descriptor) + if ( + not stat.S_ISREG(opened.st_mode) + or opened.st_size > max_bytes + or (require_private and opened.st_mode & 0o077) + ): + return None + linked = os.stat(path, follow_symlinks=False) + if stat.S_ISLNK(linked.st_mode) or not os.path.samestat(opened, linked): + return None + with os.fdopen(descriptor, "rb", closefd=False) as handle: + raw = handle.read(max_bytes + 1) + return raw if len(raw) <= max_bytes else None + except OSError: + return None + finally: + os.close(descriptor) + + +def _dotenv_credentials(path: Path, names: Sequence[str]) -> dict[str, str]: + """Read only selected simple assignments from one Provider dotenv file.""" + wanted = set(names) + values: dict[str, str] = {} + raw = _read_small_regular_bytes(path) + if raw is None: + return values + try: + lines = raw.decode("utf-8").splitlines() + except UnicodeDecodeError: + return values + for raw in lines: + line = raw.strip() + if not line or line.startswith("#"): + continue + if line.startswith("export "): + line = line[7:].lstrip() + key, separator, value = line.partition("=") + key = key.strip() + if not separator or key not in wanted or key in values: + continue + value = value.strip() + if not value: + continue + if value.startswith("'"): + if len(value) < 2 or not value.endswith("'"): + continue + value = value[1:-1] + elif value.startswith('"'): + try: + decoded = json.loads(value) + except (json.JSONDecodeError, TypeError): + continue + if not isinstance(decoded, str): + continue + value = decoded + else: + value = value.split(" #", 1)[0].strip() + if value and "\x00" not in value: + values[key] = value + return values + + +def _provider_credentials( + provider: str | None, + *, + dotenv_path: Path | None = None, +) -> dict[str, str]: + """Return only credentials belonging to the selected model Provider.""" + if not provider: + return {} + normalized = re.sub(r"[^a-z0-9]+", "", provider.lower()) + names = _PROVIDER_CREDENTIAL_NAMES.get(normalized, ()) + credentials = { + name: os.environ[name] + for name in names + if os.environ.get(name) + } + if dotenv_path is not None: + for name, value in _dotenv_credentials(dotenv_path, names).items(): + credentials.setdefault(name, value) + return credentials + + +def _read_small_private_json(path: Path) -> dict[str, object] | None: + raw = _read_small_regular_bytes(path) + if raw is None: + return None + try: + payload = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return None + return payload if isinstance(payload, dict) else None + + +def _read_small_private_toml(path: Path) -> dict[str, object] | None: + raw = _read_small_regular_bytes(path) + if raw is None: + return None + try: + payload = tomllib.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, tomllib.TOMLDecodeError): + return None + return payload if isinstance(payload, dict) else None + + +@dataclass(frozen=True) +class _KimiRoute: + provider: str + model: str + route_sha256: str + config: dict[str, object] + credential_files: tuple[tuple[str, bytes], ...] = () + + +_KIMI_PROVIDER_FIELDS = frozenset( + { + "api_key", + "base_url", + "custom_headers", + "default_model", + "env", + "model_source", + "oauth", + "type", + } +) +_KIMI_MODEL_FIELDS = frozenset( + { + "adaptive_thinking", + "beta_api", + "capabilities", + "default_effort", + "display_name", + "max_context_size", + "max_input_size", + "max_output_size", + "model", + "off_effort", + "overrides", + "provider", + "reasoning_key", + "support_efforts", + } +) + + +def _kimi_route_digest( + config: Mapping[str, object], + credential_files: Sequence[tuple[str, bytes]] = (), +) -> str: + digest_payload = { + "config": dict(config), + "credential_files": { + name: hashlib.sha256(content).hexdigest() + for name, content in credential_files + }, + } + try: + encoded = json.dumps( + digest_payload, + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + except (TypeError, ValueError) as exc: + raise DispatchValidationError( + "selected Kimi provider configuration is not serializable" + ) from exc + return hashlib.sha256(encoded).hexdigest() + + +def _positive_kimi_integer( + environment: Mapping[str, str], + name: str, + default: int | None = None, +) -> int | None: + raw = environment.get(name, "").strip() + if not raw: + return default + if not raw.isdigit() or int(raw) <= 0: + raise DispatchValidationError(f"{name} must be a positive integer") + return int(raw) + + +def _kimi_env_route(environment: Mapping[str, str]) -> _KimiRoute | None: + model_name = environment.get("KIMI_MODEL_NAME", "").strip() + if not model_name: + return None + api_key = environment.get("KIMI_MODEL_API_KEY", "").strip() + if not api_key: + raise DispatchValidationError( + "KIMI_MODEL_NAME requires KIMI_MODEL_API_KEY" + ) + provider_type = environment.get("KIMI_MODEL_PROVIDER_TYPE", "kimi").strip().lower() + if provider_type not in {"kimi", "anthropic", "openai"}: + raise DispatchValidationError("KIMI_MODEL_PROVIDER_TYPE is unsupported") + default_base_urls = { + "kimi": "https://api.moonshot.ai/v1", + "openai": "https://api.openai.com/v1", + } + provider: dict[str, object] = { + "type": provider_type, + "api_key": api_key, + } + base_url = environment.get("KIMI_MODEL_BASE_URL", "").strip() + if base_url or provider_type in default_base_urls: + provider["base_url"] = base_url or default_base_urls[provider_type] + alias: dict[str, object] = { + "provider": "__kimi_env__", + "model": model_name, + "max_context_size": _positive_kimi_integer( + environment, + "KIMI_MODEL_MAX_CONTEXT_SIZE", + 262144, + ), + } + capabilities = [ + item.strip().lower() + for item in environment.get( + "KIMI_MODEL_CAPABILITIES", + "image_in,thinking", + ).split(",") + if item.strip() + ] + if capabilities: + alias["capabilities"] = capabilities + optional_strings = { + "display_name": "KIMI_MODEL_DISPLAY_NAME", + "reasoning_key": "KIMI_MODEL_REASONING_KEY", + } + for target, source in optional_strings.items(): + value = environment.get(source, "").strip() + if value: + alias[target] = value + max_output_size = _positive_kimi_integer( + environment, + "KIMI_MODEL_MAX_OUTPUT_SIZE", + ) + if max_output_size is not None: + alias["max_output_size"] = max_output_size + adaptive = environment.get("KIMI_MODEL_ADAPTIVE_THINKING", "").strip().lower() + if adaptive: + if adaptive not in {"1", "0", "true", "false", "yes", "no", "on", "off"}: + raise DispatchValidationError( + "KIMI_MODEL_ADAPTIVE_THINKING must be boolean" + ) + alias["adaptive_thinking"] = adaptive in {"1", "true", "yes", "on"} + config: dict[str, object] = { + "default_provider": "__kimi_env__", + "default_model": "__kimi_env_model__", + "providers": {"__kimi_env__": provider}, + "models": {"__kimi_env_model__": alias}, + } + return _KimiRoute( + provider="__kimi_env__", + model="__kimi_env_model__", + route_sha256=_kimi_route_digest(config), + config=config, + ) + + +def _filtered_kimi_mapping( + value: object, + *, + allowed: frozenset[str], +) -> dict[str, object] | None: + if not isinstance(value, dict): + return None + return {key: item for key, item in value.items() if key in allowed} + + +def _kimi_config_route(source_home: Path) -> _KimiRoute | None: + payload = _read_small_private_toml(source_home / "config.toml") or {} + model = payload.get("default_model") + if not isinstance(model, str) or not model: + return None + models = payload.get("models") + selected_model = models.get(model) if isinstance(models, dict) else None + alias = _filtered_kimi_mapping( + selected_model, + allowed=_KIMI_MODEL_FIELDS, + ) + if alias is None: + raise DispatchValidationError( + "Kimi default_model has no selectable model configuration" + ) + alias_provider = alias.get("provider") + default_provider = payload.get("default_provider") + provider = ( + alias_provider + if isinstance(alias_provider, str) and alias_provider + else default_provider + ) + if not isinstance(provider, str) or not provider: + raise DispatchValidationError( + "Kimi default model has no selected provider" + ) + providers = payload.get("providers") + selected_provider = providers.get(provider) if isinstance(providers, dict) else None + provider_config = _filtered_kimi_mapping( + selected_provider, + allowed=_KIMI_PROVIDER_FIELDS, + ) + if provider_config is None: + raise DispatchValidationError( + "Kimi selected provider has no configuration" + ) + credential_files: tuple[tuple[str, bytes], ...] = () + oauth = selected_provider.get("oauth") + if oauth is not None: + if not isinstance(oauth, dict) or oauth.get("storage") != "file": + raise DispatchValidationError( + "Kimi dispatch supports only file-backed OAuth profiles" + ) + oauth_key = oauth.get("key") + if not isinstance(oauth_key, str) or not oauth_key: + raise DispatchValidationError("Kimi OAuth profile has no token key") + if oauth_key in {"kimi-code", "oauth/kimi-code"}: + storage_name = "kimi-code" + elif oauth_key.startswith("oauth/") and oauth_key[6:]: + storage_name = oauth_key[6:] + elif "/" not in oauth_key and not oauth_key.startswith("."): + storage_name = oauth_key + else: + raise DispatchValidationError("Kimi OAuth token key is unsafe") + if Path(storage_name).name != storage_name or not storage_name: + raise DispatchValidationError("Kimi OAuth token key is unsafe") + credential_name = f"{storage_name}.json" + credential = _read_small_regular_bytes( + source_home / "credentials" / credential_name, + require_private=True, + ) + if credential is None: + raise DispatchValidationError( + "Kimi selected OAuth credential is unavailable or not private" + ) + credential_files = ((credential_name, credential),) + api_key = provider_config.get("api_key") + provider_env = provider_config.get("env") + if oauth is None and not (isinstance(api_key, str) and api_key) and not ( + isinstance(provider_env, dict) + and any(isinstance(value, str) and value for value in provider_env.values()) + ): + raise DispatchValidationError( + "Kimi selected provider has no scoped non-OAuth credential" + ) + config = { + "default_provider": provider, + "default_model": model, + "providers": {provider: provider_config}, + "models": {model: alias}, + } + return _KimiRoute( + provider=provider, + model=model, + route_sha256=_kimi_route_digest(config, credential_files), + config=config, + credential_files=credential_files, + ) + + +def _kimi_route( + *, + source_home: Path | None = None, + environment: Mapping[str, str] | None = None, +) -> _KimiRoute | None: + source_environment = environment if environment is not None else os.environ + env_route = _kimi_env_route(source_environment) + if env_route is not None: + return env_route + root = source_home or Path( + source_environment.get( + "KIMI_CODE_HOME", + str(Path.home() / ".kimi-code"), + ) + ) + return _kimi_config_route(root) + + +def _configured_model(backend: str) -> tuple[str, str] | None: + if os.environ.get("DYRO_DISPATCH_PROFILE_BACKEND") == backend: + provider = os.environ.get("DYRO_DISPATCH_PROFILE_PROVIDER", "") + model = os.environ.get("DYRO_DISPATCH_PROFILE_MODEL", "") + if provider and model: + return provider, model + environment = _backend_environment(backend) + if backend == "codex": + root = Path(environment.get("CODEX_HOME", str(Path.home() / ".codex"))) + payload = _read_small_private_toml(root / "config.toml") or {} + model = payload.get("model") + return ("openai", model) if isinstance(model, str) and model else None + if backend == "claude": + root = Path( + environment.get("CLAUDE_CONFIG_DIR", str(Path.home() / ".claude")) + ) + payload = _read_small_private_json(root / "settings.json") or {} + model = payload.get("model") + return ("anthropic", model) if isinstance(model, str) and model else None + if backend == "cursor-agent": + return ("cursor", os.environ.get("DYRO_CURSOR_MODEL", "gpt-5")) + if backend == "opencode": + root = Path( + environment.get("XDG_CONFIG_HOME", str(Path.home() / ".config")) + ) + payload = _read_small_private_json(root / "opencode" / "opencode.json") or {} + model = payload.get("model") + if isinstance(model, str) and "/" in model: + return model.partition("/")[0], model + return None + if backend == "grok": + root = Path(environment.get("GROK_HOME", str(Path.home() / ".grok"))) + payload = _read_small_private_toml(root / "config.toml") or {} + models = payload.get("models") + model = models.get("default") if isinstance(models, dict) else None + return ("xai", model) if isinstance(model, str) and model else None + if backend == "kimi": + route = _kimi_route() + return (route.provider, route.model) if route is not None else None + if backend == "dsh": + return "deepseek-official", "deepseek-v4-flash" + return None + + +def _write_private_json(path: Path, payload: Mapping[str, object]) -> None: + encoded = ( + json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False) + "\n" + ).encode("utf-8") + if len(encoded) > 1024 * 1024: + raise DispatchValidationError("isolated worker profile exceeds byte limit") + descriptor = os.open( + path, + os.O_WRONLY + | os.O_CREAT + | os.O_EXCL + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0), + 0o600, + ) + try: + with os.fdopen(descriptor, "wb", closefd=False) as handle: + handle.write(encoded) + handle.flush() + os.fsync(handle.fileno()) + finally: + os.close(descriptor) + + +def _write_private_bytes(path: Path, content: bytes) -> None: + if len(content) > 1024 * 1024: + raise DispatchValidationError("isolated worker profile exceeds byte limit") + descriptor = os.open( + path, + os.O_WRONLY + | os.O_CREAT + | os.O_EXCL + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0), + 0o600, + ) + try: + with os.fdopen(descriptor, "wb", closefd=False) as handle: + handle.write(content) + handle.flush() + os.fsync(handle.fileno()) + finally: + os.close(descriptor) + + +def _toml_key(value: str) -> str: + return json.dumps(value, ensure_ascii=False) + + +def _toml_value(value: object) -> str: + if isinstance(value, str): + return json.dumps(value, ensure_ascii=False) + if value is True: + return "true" + if value is False: + return "false" + if type(value) is int: + return str(value) + if isinstance(value, list) and all( + isinstance(item, (str, bool)) or type(item) is int for item in value + ): + return "[" + ", ".join(_toml_value(item) for item in value) + "]" + raise DispatchValidationError( + "selected Kimi provider configuration contains an unsupported value" + ) + + +def _toml_document(payload: Mapping[str, object]) -> bytes: + lines: list[str] = [] + + def emit_table(path: tuple[str, ...], table: Mapping[str, object]) -> None: + scalars = { + key: value for key, value in table.items() if not isinstance(value, dict) + } + children = { + key: value for key, value in table.items() if isinstance(value, dict) + } + if path: + if lines and lines[-1] != "": + lines.append("") + lines.append("[" + ".".join(_toml_key(item) for item in path) + "]") + for key in sorted(scalars): + lines.append(f"{_toml_key(key)} = {_toml_value(scalars[key])}") + for key in sorted(children): + emit_table(path + (key,), children[key]) + + emit_table((), payload) + encoded = ("\n".join(lines) + "\n").encode("utf-8") + if len(encoded) > 1024 * 1024: + raise DispatchValidationError("isolated worker profile exceeds byte limit") + return encoded + + +def _materialize_kimi_worker_home( + *, + source_home: Path, + isolated_home: Path, + provider: str, + model: str, + route_sha256: str, +) -> None: + route = _kimi_route(source_home=source_home) + if route is None or ( + route.provider != provider + or route.model != model + or route.route_sha256 != route_sha256 + ): + raise DispatchValidationError( + "Kimi execution route changed before worker profile materialization" + ) + _write_private_bytes( + isolated_home / "config.toml", + _toml_document(route.config), + ) + if route.credential_files: + credentials = isolated_home / "credentials" + credentials.mkdir(mode=0o700) + credentials.chmod(0o700) + for name, content in route.credential_files: + _write_private_bytes(credentials / name, content) + + +def _copy_private_file( + source: Path, + destination: Path, + *, + require_private: bool = False, +) -> bool: + content = _read_small_regular_bytes( + source, + require_private=require_private, + ) + if content is None: + return False + _write_private_bytes(destination, content) + return True + + +def _create_worker_home(path: Path) -> None: + try: + path.mkdir(mode=0o700) + except OSError as exc: + raise DispatchValidationError( + "isolated async worker home cannot be created" + ) from exc + if path.is_symlink() or not path.is_dir(): + raise DispatchValidationError("isolated async worker home is unsafe") + path.chmod(0o700) + + +def _materialize_basic_worker_home( + *, + backend: str, + source_home: Path, + isolated_home: Path, + provider: str, + model: str, + route_sha256: str = "", +) -> None: + _create_worker_home(isolated_home) + if backend == "codex": + _copy_private_file(source_home / "auth.json", isolated_home / "auth.json") + return + if backend == "claude": + _copy_private_file( + source_home / "config.json", isolated_home / "config.json" + ) + return + if backend == "cursor-agent": + return + if backend == "grok": + _copy_private_file(source_home / "auth.json", isolated_home / "auth.json") + config = ( + "[models]\n" + f"default = {json.dumps(model)}\n" + "default_reasoning_effort = \"high\"\n" + ).encode("utf-8") + _write_private_bytes(isolated_home / "config.toml", config) + return + if backend == "kimi": + _materialize_kimi_worker_home( + source_home=source_home, + isolated_home=isolated_home, + provider=provider, + model=model, + route_sha256=route_sha256, + ) + return + if backend == "dsh": + _copy_private_file( + source_home / ".credentials.yaml", + isolated_home / ".credentials.yaml", + require_private=True, + ) + return + raise DispatchValidationError( + f"backend does not support an isolated async worker home: {backend}" + ) + + +def _materialize_opencode_worker_home( + *, + source_config_root: Path, + source_data_root: Path, + isolated_home: Path, + provider: str, + model: str, +) -> None: + _create_worker_home(isolated_home) + config_dir = isolated_home / "config" / "opencode" + data_dir = isolated_home / "data" / "opencode" + config_dir.mkdir(parents=True, mode=0o700) + data_dir.mkdir(parents=True, mode=0o700) + source_config = _read_small_private_json( + source_config_root / "opencode" / "opencode.json" + ) or {} + providers = source_config.get("provider") + selected_provider = ( + {provider: providers[provider]} + if isinstance(providers, dict) and provider in providers + else {} + ) + _write_private_json( + config_dir / "opencode.json", + {"model": model, "provider": selected_provider}, + ) + source_auth = _read_small_private_json( + source_data_root / "opencode" / "auth.json" + ) or {} + if provider in source_auth: + _write_private_json( + data_dir / "auth.json", + {provider: source_auth[provider]}, + ) + + +def _materialize_pi_worker_home( + *, + source_home: Path, + isolated_home: Path, + provider: str, + model: str, +) -> None: + _create_worker_home(isolated_home) + _write_private_json( + isolated_home / "settings.json", + {"defaultProvider": provider, "defaultModel": model}, + ) + auth = _read_small_private_json(source_home / "auth.json") + if auth is not None and provider in auth: + _write_private_json( + isolated_home / "auth.json", + {provider: auth[provider]}, + ) + + +def _materialize_hermes_worker_home( + *, + source_home: Path, + isolated_home: Path, + provider: str, + model: str, +) -> None: + _create_worker_home(isolated_home) + config = f"model:\n default: {json.dumps(model)}\n provider: {json.dumps(provider)}\n" + descriptor = os.open( + isolated_home / "config.yaml", + os.O_WRONLY + | os.O_CREAT + | os.O_EXCL + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0), + 0o600, + ) + try: + with os.fdopen(descriptor, "w", encoding="utf-8", closefd=False) as handle: + handle.write(config) + handle.flush() + os.fsync(handle.fileno()) + finally: + os.close(descriptor) + auth = _read_small_private_json(source_home / "auth.json") + if auth is None: + return + providers = auth.get("providers") + pool = auth.get("credential_pool") + selected_providers = ( + {provider: providers[provider]} + if isinstance(providers, dict) and provider in providers + else {} + ) + selected_pool = ( + {provider: pool[provider]} + if isinstance(pool, dict) and provider in pool + else {} + ) + if not selected_providers and not selected_pool: + return + _write_private_json( + isolated_home / "auth.json", + { + "version": auth.get("version", 1), + "active_provider": provider, + "providers": selected_providers, + "credential_pool": selected_pool, + }, + ) def _build_prompt(contract: TaskContract, context_files: Mapping[str, str]) -> str: @@ -135,11 +989,99 @@ def _parse_model_json(text: str) -> dict[str, object]: } +def _message_content_text(value: object) -> str: + if isinstance(value, str): + return value + if not isinstance(value, list): + return "" + parts: list[str] = [] + for item in value: + if not isinstance(item, dict): + continue + text = item.get("text") + if isinstance(text, str): + parts.append(text) + return "".join(parts) + + +def _parse_wrapped_model_json(text: str, *, backend: str) -> dict[str, object]: + """Extract the final assistant text from one backend's JSON protocol.""" + try: + return _parse_model_json(text) + except DispatchValidationError: + pass + + decoded: list[dict[str, object]] = [] + try: + whole = json.loads(text) + except json.JSONDecodeError: + whole = None + if isinstance(whole, dict): + decoded.append(whole) + for line in text.splitlines(): + try: + value = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(value, dict) and value not in decoded: + decoded.append(value) + if not decoded: + raise DispatchValidationError(f"{backend} returned invalid JSON output") + + candidates: list[str] = [] + for value in decoded: + if backend == "cursor-agent": + result = value.get("result") + if isinstance(result, str): + candidates.append(result) + elif backend == "grok": + result = value.get("text") + if isinstance(result, str): + candidates.append(result) + elif backend == "opencode": + part = value.get("part") + if isinstance(part, dict) and part.get("type") == "text": + result = part.get("text") + if isinstance(result, str): + candidates.append(result) + elif value.get("type") == "text": + result = value.get("text") or value.get("data") + if isinstance(result, str): + candidates.append(result) + elif backend == "kimi" and value.get("role") == "assistant": + result = _message_content_text(value.get("content")) + if result: + candidates.append(result) + elif backend == "pi" and value.get("type") == "message_end": + message = value.get("message") + if isinstance(message, dict) and message.get("role") == "assistant": + result = _message_content_text(message.get("content")) + if result: + candidates.append(result) + + for candidate in reversed(candidates): + try: + return _parse_model_json(candidate) + except DispatchValidationError: + continue + raise DispatchValidationError( + f"{backend} output did not contain a valid final assistant result" + ) + + def _completed_to_result( completed: BoundedCompletedProcess, *, backend: str, + parser: Callable[[str], dict[str, object]] = _parse_model_json, ) -> AdapterResult: + if completed.cancelled: + return AdapterResult( + status="cancelled", + summary="", + error_code="cancelled", + warnings=["backend process group was cancelled and terminated"], + ) if completed.timed_out: return AdapterResult( status="timeout", @@ -162,7 +1104,7 @@ def _completed_to_result( warnings=[f"{backend} process exited with code {completed.returncode}"], ) try: - parsed = _parse_model_json(completed.stdout) + parsed = parser(completed.stdout) except DispatchValidationError as exc: return AdapterResult( status="error", @@ -181,12 +1123,36 @@ def _completed_to_result( class SubprocessCliAdapter: strict_isolation = False + supported_modes = frozenset({"read-only", "edit"}) def __init__(self, *, backend_id: str, command: str) -> None: self.id = backend_id self.command = command self._process_observer: Callable[[int, int, str], None] | None = None self._lifetime_lock_path: Path | None = None + self._cancel_check: Callable[[], bool] | None = None + self._planned_execution_profile: dict[str, str] | None = None + + def configure_execution_profile( + self, + profile: Mapping[str, str], + ) -> None: + if profile.get("backend") != self.id: + raise DispatchValidationError( + "backend execution profile identity mismatch" + ) + self._planned_execution_profile = dict(profile) + + def _execution_value(self, name: str) -> str: + profile = self._planned_execution_profile or dict( + self.execution_profile() + ) + value = profile.get(name, "") + if not value: + raise DispatchValidationError( + f"backend execution profile is missing {name}: {self.id}" + ) + return value def configure_process_tracking( self, @@ -197,12 +1163,208 @@ def configure_process_tracking( self._process_observer = observer self._lifetime_lock_path = Path(lifetime_lock_path) + def configure_cancellation( + self, + *, + cancel_check: Callable[[], bool], + ) -> None: + self._cancel_check = cancel_check + + def worker_environment( + self, + *, + isolated_home: Path | None = None, + ) -> dict[str, str]: + """Build the minimal environment needed by this adapter's async worker.""" + environment = _backend_environment(self.id) + if isolated_home is not None and self.id in { + "codex", + "claude", + "cursor-agent", + "grok", + "kimi", + "dsh", + }: + source_spec = _DEFAULT_BACKEND_HOMES.get(self.id) + if source_spec is None: + source_spec = { + "codex": ("CODEX_HOME", ".codex"), + "claude": ("CLAUDE_CONFIG_DIR", ".claude"), + }[self.id] + variable, relative = source_spec + source_home = Path( + environment.get(variable, str(Path.home() / relative)) + ) + _materialize_basic_worker_home( + backend=self.id, + source_home=source_home, + isolated_home=isolated_home, + provider=self._execution_value("provider"), + model=self._execution_value("model"), + route_sha256=( + self._execution_value("route_sha256") + if self.id == "kimi" + else "" + ), + ) + environment[variable] = str(isolated_home) + if self.id == "cursor-agent": + environment["HOME"] = str(isolated_home) + environment["XDG_CONFIG_HOME"] = str(isolated_home / "config") + elif self.id == "claude": + environment["XDG_CONFIG_HOME"] = str(isolated_home / "xdg") + elif isolated_home is not None and self.id == "opencode": + source_config_root = Path( + environment.get("XDG_CONFIG_HOME", str(Path.home() / ".config")) + ) + source_data_root = Path( + environment.get( + "XDG_DATA_HOME", str(Path.home() / ".local/share") + ) + ) + _materialize_opencode_worker_home( + source_config_root=source_config_root, + source_data_root=source_data_root, + isolated_home=isolated_home, + provider=self._execution_value("provider"), + model=self._execution_value("model"), + ) + environment["XDG_CONFIG_HOME"] = str(isolated_home / "config") + environment["XDG_DATA_HOME"] = str(isolated_home / "data") + environment["OPENCODE_CONFIG_DIR"] = str( + isolated_home / "config" / "opencode" + ) + if self.id == "hermes": + try: + provider = self._execution_value("provider") + model = self._execution_value("model") + except DispatchValidationError: + if isolated_home is not None: + raise + provider = "" + model = "" + if provider and model: + source_home = Path( + environment.get("HERMES_HOME", str(Path.home() / ".hermes")) + ) + environment.update( + _provider_credentials( + provider, + dotenv_path=source_home / ".env", + ) + ) + if isolated_home is not None: + _materialize_hermes_worker_home( + source_home=source_home, + isolated_home=isolated_home, + provider=provider, + model=model, + ) + environment["HERMES_HOME"] = str(isolated_home) + elif self.id == "pi": + # Pi can route many Providers. Propagate only the current default's + # credentials so an async worker cannot inherit lateral keys. + try: + provider = self._execution_value("provider") + model = self._execution_value("model") + except DispatchValidationError: + if isolated_home is not None: + raise + provider = "" + model = "" + if provider: + environment.update(_provider_credentials(provider)) + if isolated_home is not None: + source_home = Path( + environment.get( + "PI_CODING_AGENT_DIR", str(Path.home() / ".pi/agent") + ) + ) + _materialize_pi_worker_home( + source_home=source_home, + isolated_home=isolated_home, + provider=provider, + model=model, + ) + environment["PI_CODING_AGENT_DIR"] = str(isolated_home) + return environment + + def execution_profile(self) -> Mapping[str, str]: + resolved = shutil.which(self.command) + profile = { + "backend": self.id, + "command_path": ( + str(Path(resolved).resolve()) if resolved else self.command + ), + } + configured = _configured_model(self.id) + if configured is not None: + profile["provider"], profile["model"] = configured + if self.id == "kimi": + route = _kimi_route() + if route is not None: + profile["provider"] = route.provider + profile["model"] = route.model + profile["route_sha256"] = route.route_sha256 + if self.id == "hermes": + selection = _hermes_model_selection() + if selection is not None: + profile["provider"], profile["model"] = selection + elif self.id == "pi": + selection = _pi_default_selection() + if selection is not None: + profile["provider"], profile["model"] = selection + if self.id in { + "codex", + "claude", + "cursor-agent", + "opencode", + "grok", + "hermes", + "kimi", + "dsh", + "pi", + } and (not profile.get("provider") or not profile.get("model")): + raise DispatchValidationError( + f"backend has no statically selectable provider/model: {self.id}" + ) + return profile + def available(self) -> bool: return shutil.which(self.command) is not None def authenticated(self) -> bool: return self.available() + def readiness_reason(self) -> str: + return "authentication probe failed" + + def _probe( + self, + argv: Sequence[str], + *, + timeout_seconds: float = 5.0, + environment_overrides: Mapping[str, str] | None = None, + ): + if not self.available(): + return None + try: + environment = _backend_environment(self.id) + if environment_overrides: + environment.update(environment_overrides) + completed = run_bounded( + argv, + cwd=Path.cwd(), + timeout_seconds=timeout_seconds, + env=environment, + max_output_bytes=64 * 1024, + ) + except OSError: + return None + if completed.returncode != 0 or completed.timed_out or completed.output_limited: + return None + return completed + def _run( self, argv: Sequence[str], @@ -210,6 +1372,8 @@ def _run( cwd: Path, prompt: str, timeout_seconds: float, + parser: Callable[[str], dict[str, object]] = _parse_model_json, + environment_overrides: Mapping[str, str] | None = None, ) -> AdapterResult: on_spawn: Callable[[int], None] | None = None if self._process_observer is not None: @@ -234,14 +1398,18 @@ def observe(pid: int) -> None: on_spawn = observe try: + environment = _backend_environment(self.id) + if environment_overrides: + environment.update(environment_overrides) completed = run_bounded( argv, cwd=cwd, timeout_seconds=timeout_seconds, - env=_backend_environment(self.id), + env=environment, input_text=prompt, on_spawn=on_spawn, lifetime_lock_path=self._lifetime_lock_path, + cancel_check=self._cancel_check, ) except OSError as exc: return AdapterResult( @@ -250,7 +1418,35 @@ def observe(pid: int) -> None: error_code="spawn_failed", warnings=[safe_error_text(exc, fallback="backend process could not start")], ) - return _completed_to_result(completed, backend=self.id) + return _completed_to_result(completed, backend=self.id, parser=parser) + + +@contextmanager +def _temporary_text_file( + cwd: Path, + *, + suffix: str, + content: str, +) -> Iterator[Path]: + descriptor, raw_path = tempfile.mkstemp( + prefix=".dyro-dispatch-", + suffix=suffix, + dir=cwd, + text=True, + ) + path = Path(raw_path) + try: + os.fchmod(descriptor, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + handle.write(content) + yield path + finally: + try: + path.unlink(missing_ok=True) + except OSError as exc: + raise DispatchValidationError( + "temporary dispatch input could not be removed" + ) from exc class CodexAdapter(SubprocessCliAdapter): @@ -290,6 +1486,8 @@ def run( argv = [ "codex", "exec", + "--model", + self._execution_value("model"), "--sandbox", sandbox, "--ephemeral", @@ -356,6 +1554,8 @@ def run( "-p", "--output-format", "text", + "--model", + self._execution_value("model"), "--permission-mode", "acceptEdits" if edit_mode else "plan", "--safe-mode", @@ -375,9 +1575,940 @@ def run( ) +class CursorAdapter(SubprocessCliAdapter): + supported_modes = frozenset({"read-only"}) + + def readiness_reason(self) -> str: + return ( + "CURSOR_API_KEY is required so dispatch can isolate Cursor from " + "user MCP and plugin processes" + ) + + def authenticated(self) -> bool: + # Browser OAuth shares the user's full Cursor home, including MCP and + # plugin startup. Dyro only routes Cursor when an API key lets the run + # use an empty, ephemeral HOME. + if not os.environ.get("CURSOR_API_KEY"): + return False + completed = self._probe( + ["cursor-agent", "status"], timeout_seconds=15.0 + ) + if completed is None: + return False + plain = re.sub(r"\x1b\[[0-9;]*m", "", completed.stdout) + return "Logged in" in plain + + def run( + self, + *, + contract: TaskContract, + cwd: Path, + context_files: Mapping[str, str], + timeout_seconds: float, + ) -> AdapterResult: + if not self.available(): + return _not_installed(self.id, self.command) + if contract.mode == "edit": + return AdapterResult( + status="error", + summary="", + error_code="backend_mode_unsupported", + warnings=[ + "Cursor edit dispatch is disabled because its sandbox " + "lifecycle cannot yet be proven" + ], + usage={"backend": self.id}, + ) + prompt = _build_prompt(contract, context_files) + with ( + _temporary_text_file(cwd, suffix=".md", content=prompt) as task_file, + tempfile.TemporaryDirectory(prefix="dyro-cursor-home-") as cursor_home, + ): + argv = [ + "cursor-agent", + "--print", + "--output-format", + "json", + # Cursor's sandbox helper currently daemonizes after the CLI + # exits, which breaks Dyro's prove-before-release process-group + # contract. The task still runs only after the caller accepts + # best-effort unconfined read-only execution in a projection. + "--sandbox", + "disabled", + "--workspace", + str(cwd), + "--model", + self._execution_value("model"), + "--trust", + ] + argv.extend(["--mode", "ask"]) + argv.append( + f"Read and follow the complete task in @{task_file.name}." + ) + return self._run( + argv, + cwd=cwd, + prompt="", + timeout_seconds=timeout_seconds, + parser=lambda text: _parse_wrapped_model_json( + text, backend=self.id + ), + environment_overrides={"HOME": cursor_home}, + ) + + +def _opencode_permissions(*, edit_mode: bool) -> str: + permission = { + "*": "deny", + "read": "allow", + "glob": "allow", + "grep": "allow", + "list": "allow", + "edit": "allow" if edit_mode else "deny", + "bash": "deny", + "task": "deny", + "external_directory": "deny", + "todowrite": "deny", + "webfetch": "deny", + "websearch": "deny", + "lsp": "deny", + "skill": "deny", + "question": "deny", + } + return json.dumps( + { + "$schema": "https://opencode.ai/config.json", + "permission": permission, + "share": "disabled", + }, + separators=(",", ":"), + ) + + +class OpenCodeAdapter(SubprocessCliAdapter): + def _authenticated_provider_ids(self) -> list[str]: + completed = self._probe( + ["opencode", "auth", "list"], timeout_seconds=15.0 + ) + if completed is None: + return [] + match = re.search(r"\b([1-9][0-9]*) credentials?\b", completed.stdout) + if match is None: + return [] + plain = re.sub(r"\x1b\[[0-9;]*m", "", completed.stdout) + aliases = { + "anthropic": "anthropic", + "google": "google", + "openai": "openai", + "openrouter": "openrouter", + "xai": "xai", + } + providers: list[str] = [] + for line in plain.splitlines(): + provider_match = re.search(r"^[●*]\s+(.+?)\s+(?:oauth|api)", line.strip()) + if provider_match is None: + continue + normalized = re.sub( + r"[^a-z0-9]+", "", provider_match.group(1).lower() + ) + for label, provider_id in aliases.items(): + if normalized.startswith(label): + providers.append(provider_id) + break + return providers + + def authenticated(self) -> bool: + selection = _configured_model("opencode") + if selection is None: + return False + provider = selection[0] + if provider in self._authenticated_provider_ids(): + return True + config_root = Path( + _backend_environment("opencode").get( + "XDG_CONFIG_HOME", str(Path.home() / ".config") + ) + ) + config = _read_small_private_json( + config_root / "opencode" / "opencode.json" + ) or {} + providers = config.get("provider") + selected = providers.get(provider) if isinstance(providers, dict) else None + options = selected.get("options") if isinstance(selected, dict) else None + api_key = options.get("apiKey") if isinstance(options, dict) else None + return isinstance(api_key, str) and bool(api_key) + + def run( + self, + *, + contract: TaskContract, + cwd: Path, + context_files: Mapping[str, str], + timeout_seconds: float, + ) -> AdapterResult: + if not self.available(): + return _not_installed(self.id, self.command) + prompt = _build_prompt(contract, context_files) + selected_model = self._execution_value("model") + provider = self._execution_value("provider") + source_environment = _backend_environment(self.id) + source_config_root = Path( + source_environment.get( + "XDG_CONFIG_HOME", str(Path.home() / ".config") + ) + ) + source_data_root = Path( + source_environment.get( + "XDG_DATA_HOME", str(Path.home() / ".local/share") + ) + ) + with ( + tempfile.TemporaryDirectory(prefix="dyro-opencode-") as temporary_root, + _temporary_text_file(cwd, suffix=".md", content=prompt) as task_file, + ): + isolated_home = Path(temporary_root) / "home" + _materialize_opencode_worker_home( + source_config_root=source_config_root, + source_data_root=source_data_root, + isolated_home=isolated_home, + provider=provider, + model=selected_model, + ) + argv = [ + "opencode", + "--pure", + "run", + "Follow the attached Dyro dispatch task exactly.", + "--format", + "json", + "--dir", + str(cwd), + "--file", + str(task_file), + "--model", + selected_model, + ] + return self._run( + argv, + cwd=cwd, + prompt="", + timeout_seconds=timeout_seconds, + parser=lambda text: _parse_wrapped_model_json( + text, backend=self.id + ), + environment_overrides={ + **_provider_credentials(provider), + "XDG_CONFIG_HOME": str(isolated_home / "config"), + "XDG_DATA_HOME": str(isolated_home / "data"), + "OPENCODE_CONFIG_DIR": str( + isolated_home / "config" / "opencode" + ), + "OPENCODE_CONFIG_CONTENT": _opencode_permissions( + edit_mode=contract.mode == "edit" + ), + "OPENCODE_DISABLE_AUTOUPDATE": "1", + "OPENCODE_DISABLE_LSP_DOWNLOAD": "1", + }, + ) + + +class GrokAdapter(SubprocessCliAdapter): + def authenticated(self) -> bool: + completed = self._probe(["grok", "models"], timeout_seconds=10.0) + if completed is None: + return False + plain = re.sub(r"\x1b\[[0-9;]*m", "", completed.stdout) + return "You are logged in with" in plain + + def run( + self, + *, + contract: TaskContract, + cwd: Path, + context_files: Mapping[str, str], + timeout_seconds: float, + ) -> AdapterResult: + if not self.available(): + return _not_installed(self.id, self.command) + prompt = _build_prompt(contract, context_files) + with _temporary_text_file(cwd, suffix=".md", content=prompt) as task_file: + edit_mode = contract.mode == "edit" + argv = [ + "grok", + "--model", + self._execution_value("model"), + "--prompt-file", + str(task_file), + "--output-format", + "json", + "--verbatim", + "--no-memory", + "--no-subagents", + "--disable-web-search", + "--cwd", + str(cwd), + "--sandbox", + "workspace-write" if edit_mode else "read-only", + "--permission-mode", + "acceptEdits" if edit_mode else "plan", + ] + if not edit_mode: + argv.extend(["--agent", "explore"]) + return self._run( + argv, + cwd=cwd, + prompt="", + timeout_seconds=timeout_seconds, + parser=lambda text: _parse_wrapped_model_json( + text, backend=self.id + ), + ) + + +def _hermes_model_selection() -> tuple[str, str] | None: + home = Path( + _backend_environment("hermes").get( + "HERMES_HOME", str(Path.home() / ".hermes") + ) + ) + config = home / "config.yaml" + raw = _read_small_regular_bytes(config) + if raw is None: + return None + try: + lines = raw.decode("utf-8").splitlines() + except UnicodeDecodeError: + return None + in_model = False + model = "" + provider = "" + for line in lines: + if line == "model:": + in_model = True + continue + if in_model and line and not line.startswith((" ", "\t")): + break + if not in_model: + continue + stripped = line.strip() + if stripped.startswith("default:"): + model = stripped.partition(":")[2].strip().strip("'\"") + elif stripped.startswith("provider:"): + provider = stripped.partition(":")[2].strip().strip("'\"") + if model and provider: + return provider, model + return None + + +def _hermes_python_runtime() -> Path | None: + command = shutil.which("hermes") + if not command: + return None + command_path = Path(command).resolve() + for name in ("python3", "python"): + candidate = command_path.parent / name + if candidate.is_file() and os.access(candidate, os.X_OK): + return candidate + return None + + +_HERMES_ONESHOT_BOOTSTRAP = """ +import os +import sys + +os.environ["HERMES_SAFE_MODE"] = "1" +os.environ["HERMES_IGNORE_USER_CONFIG"] = "1" +os.environ["HERMES_IGNORE_RULES"] = "1" + +prompt = sys.stdin.read() +model = sys.argv[1] or None +provider = sys.argv[2] or None +toolset = sys.argv[3] +source_hermes_home = sys.argv[4] +isolated_hermes_home = sys.argv[5] + +def _dyro_safe_config(*args, **kwargs): + return { + "model": {"default": model or "", "provider": provider or ""}, + "context": {"engine": "compressor"}, + "memory": {"memory_enabled": False, "user_profile_enabled": False}, + } + +from hermes_cli import config as _hermes_config +from hermes_cli import env_loader as _hermes_env_loader + +_hermes_config.load_config = _dyro_safe_config +_hermes_config.load_config_readonly = _dyro_safe_config +_hermes_env_loader.load_hermes_dotenv = lambda *args, **kwargs: [] + +os.environ["HERMES_HOME"] = source_hermes_home +from hermes_cli.runtime_provider import resolve_runtime_provider + +runtime = resolve_runtime_provider(requested=provider, target_model=model) +if runtime.get("api_mode") not in { + "anthropic_messages", + "chat_completions", + "codex_responses", +}: + raise RuntimeError("Hermes selected an unsupported runtime mode") + +os.environ["HERMES_HOME"] = isolated_hermes_home +agent = None +try: + from run_agent import AIAgent + + agent = AIAgent( + api_key=runtime.get("api_key"), + base_url=runtime.get("base_url"), + provider=runtime.get("provider"), + requested_provider=runtime.get("requested_provider"), + api_mode=runtime.get("api_mode"), + model=model or "", + enabled_toolsets=[toolset], + quiet_mode=True, + platform="cli", + session_db=None, + credential_pool=None, + fallback_model=None, + clarify_callback=lambda *args, **kwargs: ( + "Non-interactive dispatch: make a bounded assumption and continue." + ), + skip_context_files=True, + load_soul_identity=False, + skip_memory=True, + skip_background_review=True, + checkpoints_enabled=False, + ) + agent.suppress_status_output = True + agent.stream_delta_callback = None + agent.tool_gen_callback = None + result = agent.run_conversation(prompt) + response = result.get("final_response") or "" + if not response.strip(): + raise RuntimeError("Hermes returned an empty response") + print(response) + raise SystemExit(0) +finally: + if agent is not None: + agent.close() +""" + + +def _hermes_provider_is_ready(status: str, provider: str) -> bool: + canonical = provider.strip().lower().replace("_", "-") + labels = { + "anthropic": "Anthropic", + "deepinfra": "DeepInfra", + "deep-infra": "DeepInfra", + "deepinfra-ai": "DeepInfra", + "deepseek": "DeepSeek", + "gemini": "Google / Gemini", + "google": "Google / Gemini", + "kimi": "Kimi / Moonshot", + "kimi-coding": "Kimi / Moonshot", + "kimi-coding-cn": "Kimi / Moonshot (China)", + "moonshot": "Kimi / Moonshot", + "minimax": "MiniMax", + "minimax-cn": "MiniMax (China)", + "minimax-oauth": "MiniMax OAuth", + "nvidia": "NVIDIA NIM", + "nvidia-nim": "NVIDIA NIM", + "nous": "Nous Portal", + "nous-portal": "Nous Portal", + "openai": "OpenAI", + "openai-api": "OpenAI", + "openai-codex": "OpenAI Codex", + "openrouter": "OpenRouter", + "qwen": "Qwen OAuth", + "qwen-oauth": "Qwen OAuth", + "stepfun": "StepFun Step Plan", + "stepfun-step": "StepFun Step Plan", + "xai": "xAI / Grok", + "grok": "xAI / Grok", + "xai-oauth": "xAI OAuth", + "z-ai": "Z.AI / GLM", + "zai": "Z.AI / GLM", + } + label = labels.get(canonical) + if label is None: + return False + plain = re.sub(r"\x1b\[[0-9;]*m", "", status) + return re.search( + rf"^\s*{re.escape(label)}\s+✓(?:\s|$)", + plain, + flags=re.MULTILINE, + ) is not None + + +class HermesAdapter(SubprocessCliAdapter): + def authenticated(self) -> bool: + if _hermes_python_runtime() is None: + return False + selection = _hermes_model_selection() + if selection is None: + return False + provider = selection[0] + completed = self._probe( + ["hermes", "status"], + environment_overrides=_provider_credentials(provider), + ) + if completed is None: + return False + return _hermes_provider_is_ready(completed.stdout, provider) + + def run( + self, + *, + contract: TaskContract, + cwd: Path, + context_files: Mapping[str, str], + timeout_seconds: float, + ) -> AdapterResult: + if not self.available(): + return _not_installed(self.id, self.command) + prompt = _build_prompt(contract, context_files) + runtime = _hermes_python_runtime() + if runtime is None: + return AdapterResult( + status="error", + summary="", + error_code="backend_runtime_unavailable", + warnings=["Hermes isolated Python runtime was not found"], + usage={"backend": self.id}, + ) + provider = self._execution_value("provider") + model = self._execution_value("model") + toolset = "file" if contract.mode == "edit" else "clarify" + hermes_home = Path( + _backend_environment("hermes").get( + "HERMES_HOME", str(Path.home() / ".hermes") + ) + ) + with tempfile.TemporaryDirectory(prefix="dyro-hermes-") as raw_home: + isolated_home = Path(raw_home) + isolated_home.chmod(0o700) + argv = [ + str(runtime), + "-I", + "-c", + _HERMES_ONESHOT_BOOTSTRAP, + model, + provider, + toolset, + str(hermes_home), + str(isolated_home), + ] + return self._run( + argv, + cwd=cwd, + prompt=prompt, + timeout_seconds=timeout_seconds, + environment_overrides={ + **_provider_credentials( + provider, + dotenv_path=hermes_home / ".env", + ), + "HERMES_HOME": str(isolated_home), + "HERMES_SAFE_MODE": "1", + "HERMES_IGNORE_USER_CONFIG": "1", + "HERMES_IGNORE_RULES": "1", + }, + ) + + +def _kimi_agent_markdown(*, edit_mode: bool, prompt: str) -> str: + tools = ["Read", "Grep", "Glob"] + if edit_mode: + tools.extend(["Write", "Edit"]) + tool_lines = "\n".join(f" - {name}" for name in tools) + return ( + "---\n" + "name: dyro-dispatch\n" + "description: Execute one bounded Dyro dispatch task\n" + "tools:\n" + f"{tool_lines}\n" + "disallowedTools:\n" + " - Bash\n" + " - Agent\n" + " - AgentSwarm\n" + " - Skill\n" + " - WebSearch\n" + " - WebFetch\n" + "subagents: []\n" + "---\n\n" + "Complete this self-contained task and emit only its required JSON.\n\n" + f"{prompt}\n" + ) + + +class KimiAdapter(SubprocessCliAdapter): + def readiness_reason(self) -> str: + return "no configured Kimi provider passed the local readiness probe" + + def authenticated(self) -> bool: + try: + if _kimi_env_route(os.environ) is not None: + return self.available() + except DispatchValidationError: + return False + completed = self._probe(["kimi", "provider", "list"]) + if completed is None: + return False + return "No providers configured" not in completed.stdout + + def run( + self, + *, + contract: TaskContract, + cwd: Path, + context_files: Mapping[str, str], + timeout_seconds: float, + ) -> AdapterResult: + if not self.available(): + return _not_installed(self.id, self.command) + provider = self._execution_value("provider") + model = self._execution_value("model") + route_sha256 = self._execution_value("route_sha256") + agent = _kimi_agent_markdown( + edit_mode=contract.mode == "edit", + prompt=_build_prompt(contract, context_files), + ) + source_environment = _backend_environment(self.id) + source_home = Path( + source_environment.get( + "KIMI_CODE_HOME", + str(Path.home() / ".kimi-code"), + ) + ) + with ( + tempfile.TemporaryDirectory(prefix="dyro-kimi-") as temporary_root, + _temporary_text_file(cwd, suffix=".md", content=agent) as agent_file, + ): + isolated_home = Path(temporary_root) / "home" + _create_worker_home(isolated_home) + _materialize_kimi_worker_home( + source_home=source_home, + isolated_home=isolated_home, + provider=provider, + model=model, + route_sha256=route_sha256, + ) + argv = [ + "kimi", + "--model", + model, + "--prompt", + "Execute the complete Dyro dispatch task in your agent instructions.", + "--output-format", + "stream-json", + "--agent-file", + str(agent_file), + ] + return self._run( + argv, + cwd=cwd, + prompt="", + timeout_seconds=timeout_seconds, + parser=lambda text: _parse_wrapped_model_json( + text, backend=self.id + ), + environment_overrides={ + "KIMI_CODE_HOME": str(isolated_home), + "KIMI_CODE_NO_AUTO_UPDATE": "1", + "KIMI_DISABLE_TELEMETRY": "1", + "KIMI_DISABLE_CRON": "1", + "KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY": "1", + }, + ) + + +def _dsh_patch(*, edit_mode: bool, provider: str, model: str) -> str: + disabled = ( + "tool-bash", + "tool-pwsh", + "tool-jobs", + "tool-skill", + "tool-subagent-control", + "tool-subagent-list-agents", + "tool-subagent", + "tool-subagent-fork", + "tool-subagent-report", + "tool-workflow", + "tool-ralph", + "tool-web", + ) + lines = [f"- id: {name}\n disabled: true" for name in disabled] + lines.append( + "- id: agent-default-model\n" + " config:\n" + f" provider: {json.dumps(provider)}\n" + f" model: {json.dumps(model)}" + ) + if edit_mode: + lines.append("- id: approval\n config:\n policy: never") + return "\n".join(lines) + "\n" + + +def _dsh_has_default_credential() -> bool: + environment = _backend_environment("dsh") + if environment.get("DEEPSEEK_API_KEY"): + return True + home = Path(environment.get("DSH_HOME", str(Path.home() / ".dsh"))) + credentials = home / ".credentials.yaml" + raw = _read_small_regular_bytes(credentials, require_private=True) + if raw is None: + return False + try: + lines = raw.decode("utf-8").splitlines() + except UnicodeDecodeError: + return False + return any( + re.match(r"^DEEPSEEK_API_KEY\s*:\s*\S+", line) is not None + for line in lines + ) + + +class DshAdapter(SubprocessCliAdapter): + def readiness_reason(self) -> str: + return "DEEPSEEK_API_KEY is not configured for the headless DSH profile" + + def authenticated(self) -> bool: + return self.available() and _dsh_has_default_credential() + + def run( + self, + *, + contract: TaskContract, + cwd: Path, + context_files: Mapping[str, str], + timeout_seconds: float, + ) -> AdapterResult: + if not self.available(): + return _not_installed(self.id, self.command) + if ( + self._execution_value("provider") != "deepseek-official" + or self._execution_value("model") != "deepseek-v4-flash" + ): + raise DispatchValidationError( + "DSH execution profile must use the reviewed pinned model" + ) + provider = self._execution_value("provider") + model = self._execution_value("model") + prompt = _build_prompt(contract, context_files) + source_environment = _backend_environment(self.id) + source_home = Path( + source_environment.get("DSH_HOME", str(Path.home() / ".dsh")) + ) + with ( + tempfile.TemporaryDirectory(prefix="dyro-dsh-") as temporary_root, + _temporary_text_file(cwd, suffix=".md", content=prompt) as task_file, + _temporary_text_file( + cwd, + suffix=".yml", + content=_dsh_patch( + edit_mode=contract.mode == "edit", + provider=provider, + model=model, + ), + ) as patch_file, + ): + isolated_home = Path(temporary_root) / "home" + _materialize_basic_worker_home( + backend=self.id, + source_home=source_home, + isolated_home=isolated_home, + provider=provider, + model=model, + ) + argv = [ + "dsh", + "--profile", + "headless", + "--patch", + str(patch_file), + f"Read {task_file.name} and execute that complete task.", + ] + return self._run( + argv, + cwd=cwd, + prompt="", + timeout_seconds=timeout_seconds, + environment_overrides={ + "DSH_HOME": str(isolated_home), + "DSH_PERMISSION_MODE": ( + "workspace-write" + if contract.mode == "edit" + else "read-only" + ), + "DSH_TELEMETRY_MODE": "DISABLED", + }, + ) + + +def _pi_default_selection() -> tuple[str, str] | None: + environment = _backend_environment("pi") + root = Path( + environment.get("PI_CODING_AGENT_DIR", str(Path.home() / ".pi/agent")) + ) + payload = _read_small_private_json(root / "settings.json") + if payload is None: + return None + if not isinstance(payload, dict): + return None + provider = payload.get("defaultProvider") + model = payload.get("defaultModel") + if not isinstance(provider, str) or not provider: + return None + return provider, model if isinstance(model, str) else "" + + +def _pi_default_provider() -> str | None: + selection = _pi_default_selection() + return selection[0] if selection is not None else None + + +class PiAdapter(SubprocessCliAdapter): + def authenticated(self) -> bool: + if not self.available(): + return False + try: + provider = self._execution_value("provider") + except DispatchValidationError: + return False + if not provider: + return False + completed = self._probe( + [ + "pi", + "auth", + "check", + "--provider", + provider, + "--json", + "--no-refresh", + ], + environment_overrides=_provider_credentials(provider), + ) + if completed is None: + return False + try: + payload = json.loads(completed.stdout) + except json.JSONDecodeError: + return False + return isinstance(payload, dict) and payload.get("status") == "ready" + + def run( + self, + *, + contract: TaskContract, + cwd: Path, + context_files: Mapping[str, str], + timeout_seconds: float, + ) -> AdapterResult: + if not self.available(): + return _not_installed(self.id, self.command) + prompt = _build_prompt(contract, context_files) + provider = self._execution_value("provider") + model = self._execution_value("model") + source_environment = _backend_environment(self.id) + source_home = Path( + source_environment.get( + "PI_CODING_AGENT_DIR", str(Path.home() / ".pi/agent") + ) + ) + with ( + tempfile.TemporaryDirectory(prefix="dyro-pi-") as temporary_root, + _temporary_text_file(cwd, suffix=".md", content=prompt) as task_file, + ): + isolated_home = Path(temporary_root) / "home" + _materialize_pi_worker_home( + source_home=source_home, + isolated_home=isolated_home, + provider=provider, + model=model, + ) + tools = ( + "read,grep,find,ls,edit,write" + if contract.mode == "edit" + else "read,grep,find,ls" + ) + argv = [ + "pi", + "--mode", + "json", + "--print", + "--no-session", + "--no-extensions", + "--no-skills", + "--no-prompt-templates", + "--no-context-files", + "--no-approve", + "--provider", + provider, + "--model", + model, + "--tools", + tools, + f"@{task_file.name}", + "Follow the attached Dyro dispatch task exactly.", + ] + return self._run( + argv, + cwd=cwd, + prompt="", + timeout_seconds=timeout_seconds, + parser=lambda text: _parse_wrapped_model_json( + text, backend=self.id + ), + environment_overrides={ + **_provider_credentials(provider), + "PI_CODING_AGENT_DIR": str(isolated_home), + "PI_TELEMETRY": "0", + }, + ) + + +def _not_installed(backend: str, command: str) -> AdapterResult: + return AdapterResult( + status="error", + summary="", + error_code="backend_not_installed", + warnings=[f"command not found: {command}"], + usage={"backend": backend}, + ) + + def codex_adapter() -> SubprocessCliAdapter: return CodexAdapter(backend_id="codex", command="codex") def claude_adapter() -> SubprocessCliAdapter: return ClaudeAdapter(backend_id="claude", command="claude") + + +def cursor_adapter() -> SubprocessCliAdapter: + return CursorAdapter(backend_id="cursor-agent", command="cursor-agent") + + +def opencode_adapter() -> SubprocessCliAdapter: + return OpenCodeAdapter(backend_id="opencode", command="opencode") + + +def grok_adapter() -> SubprocessCliAdapter: + return GrokAdapter(backend_id="grok", command="grok") + + +def hermes_adapter() -> SubprocessCliAdapter: + return HermesAdapter(backend_id="hermes", command="hermes") + + +def kimi_adapter() -> SubprocessCliAdapter: + return KimiAdapter(backend_id="kimi", command="kimi") + + +def dsh_adapter() -> SubprocessCliAdapter: + return DshAdapter(backend_id="dsh", command="dsh") + + +def pi_adapter() -> SubprocessCliAdapter: + return PiAdapter(backend_id="pi", command="pi") diff --git a/experiments/local_agent_dispatch/batch_contract.py b/experiments/local_agent_dispatch/batch_contract.py new file mode 100644 index 0000000..9b7ad07 --- /dev/null +++ b/experiments/local_agent_dispatch/batch_contract.py @@ -0,0 +1,560 @@ +"""Strict Batch V1 request and normalized execution-plan contracts.""" + +from __future__ import annotations + +from dataclasses import dataclass +import hashlib +import math +from pathlib import Path +import re +from typing import Any, Mapping, Sequence + +from dyro.canonical import canonical_json_bytes + +from .context_guard import assert_content_allowed +from .errors import DispatchValidationError +from .task_contract import TASK_FIELDS, TaskContract, parse_task_contract + + +BATCH_SCHEMA_VERSION = 1 +BATCH_PLAN_KIND = "local-agent-dispatch-batch-plan" +MIN_BATCH_MEMBERS = 2 +MAX_BATCH_MEMBERS = 4 +MAX_TIMEOUT_SECONDS = 3600.0 +MAX_BATCH_REQUEST_BYTES = 1024 * 1024 +MAX_REQUEST_ID_LENGTH = 128 +MAX_ROLE_ID_LENGTH = 64 + +_SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") +_SHA256 = re.compile(r"^[0-9a-f]{64}$") +_GIT_HEAD = re.compile(r"^(?:[0-9a-f]{40}|[0-9a-f]{64})$") +_BATCH_FIELDS = frozenset( + {"schema_version", "request_id", "strategy", "members"} +) +_MEMBER_FIELDS = frozenset({"role_id", "timeout_seconds", "contract"}) +_CONTRACT_FIELDS = frozenset( + { + "schema_version", + "backend", + "mode", + "strict", + "allow_unconfined_provider", + "allow_offline_simulation", + "files", + "task", + } +) +_TASK_FIELDS = frozenset(TASK_FIELDS) +_CANONICAL_PLAN_FIELDS = frozenset( + { + "schema_version", + "kind", + "project_root", + "request_id", + "strategy", + "effects", + "members", + } +) +_DISPLAY_PLAN_FIELDS = _CANONICAL_PLAN_FIELDS | {"plan_sha256"} +_PLAN_MEMBER_FIELDS = frozenset( + { + "role_id", + "resolved_backend", + "context_file_count", + "context_sha256", + "base_head", + "execution_profile", + "timeout_seconds", + "normalized_contract", + } +) +_EFFECT_FIELDS = frozenset( + { + "creates_local_state", + "starts_provider_processes", + "may_use_network_or_bill", + "writes_source_worktree", + "returns_patch_only", + } +) + + +def _require_exact_fields( + payload: Mapping[str, Any], + expected: frozenset[str], + *, + label: str, +) -> None: + actual = set(payload) + unknown = sorted(actual - expected) + if unknown: + raise DispatchValidationError( + f"{label} contains unknown fields: {', '.join(unknown)}" + ) + missing = sorted(expected - actual) + if missing: + raise DispatchValidationError( + f"{label} is missing required fields: {', '.join(missing)}" + ) + + +def _require_safe_id(value: object, *, label: str, maximum: int) -> str: + if type(value) is not str or not value or len(value) > maximum: + raise DispatchValidationError( + f"{label} must be a non-empty string of at most {maximum} characters" + ) + if _SAFE_ID.fullmatch(value) is None: + raise DispatchValidationError( + f"{label} may contain only ASCII letters, digits, '.', '_', and '-' " + "and must start with a letter or digit" + ) + return value + + +def _require_timeout(value: object, *, label: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise DispatchValidationError(f"{label} must be a finite positive number") + timeout = float(value) + if not math.isfinite(timeout) or timeout <= 0 or timeout > MAX_TIMEOUT_SECONDS: + raise DispatchValidationError( + f"{label} must be finite, positive, and at most {MAX_TIMEOUT_SECONDS:g}" + ) + return timeout + + +def _require_sha256(value: object, *, label: str) -> str: + if type(value) is not str or _SHA256.fullmatch(value) is None: + raise DispatchValidationError(f"{label} must be a lowercase SHA-256 hex digest") + return value + + +def _strict_task_contract(payload: object, *, label: str) -> TaskContract: + if not isinstance(payload, Mapping): + raise DispatchValidationError(f"{label} must be an object") + _require_exact_fields(payload, _CONTRACT_FIELDS, label=label) + if ( + type(payload.get("schema_version")) is not int + or payload.get("schema_version") != BATCH_SCHEMA_VERSION + ): + raise DispatchValidationError(f"{label}.schema_version must be 1") + task = payload.get("task") + if not isinstance(task, Mapping): + raise DispatchValidationError(f"{label}.task must be an object") + _require_exact_fields(task, _TASK_FIELDS, label=f"{label}.task") + contract = parse_task_contract(payload) + if contract.backend == "echo": + raise DispatchValidationError("batch members cannot use the echo backend") + if contract.allow_offline_simulation: + raise DispatchValidationError( + "batch members cannot allow offline simulation" + ) + return contract + + +@dataclass(frozen=True) +class BatchMemberRequest: + role_id: str + timeout_seconds: float + contract: TaskContract + + def to_mapping(self) -> dict[str, object]: + return { + "role_id": self.role_id, + "timeout_seconds": self.timeout_seconds, + "contract": self.contract.to_mapping(), + } + + +@dataclass(frozen=True) +class BatchRequest: + schema_version: int + request_id: str + strategy: str + members: tuple[BatchMemberRequest, ...] + + def to_mapping(self) -> dict[str, object]: + return { + "schema_version": self.schema_version, + "request_id": self.request_id, + "strategy": self.strategy, + "members": [member.to_mapping() for member in self.members], + } + + +def parse_batch_request(payload: Mapping[str, Any]) -> BatchRequest: + """Parse a complete, fail-closed Batch V1 request.""" + if not isinstance(payload, Mapping): + raise DispatchValidationError("batch request must be an object") + _require_exact_fields(payload, _BATCH_FIELDS, label="batch request") + if ( + type(payload.get("schema_version")) is not int + or payload.get("schema_version") != BATCH_SCHEMA_VERSION + ): + raise DispatchValidationError("batch schema_version must be 1") + request_id = _require_safe_id( + payload.get("request_id"), + label="batch request_id", + maximum=MAX_REQUEST_ID_LENGTH, + ) + strategy = payload.get("strategy") + if strategy != "independent": + raise DispatchValidationError("batch strategy must be independent") + raw_members = payload.get("members") + if not isinstance(raw_members, Sequence) or isinstance( + raw_members, (str, bytes) + ): + raise DispatchValidationError("batch members must be a list") + if not MIN_BATCH_MEMBERS <= len(raw_members) <= MAX_BATCH_MEMBERS: + raise DispatchValidationError("batch must contain between 2 and 4 members") + + members: list[BatchMemberRequest] = [] + roles: set[str] = set() + edit_count = 0 + for index, raw_member in enumerate(raw_members): + label = f"batch members[{index}]" + if not isinstance(raw_member, Mapping): + raise DispatchValidationError(f"{label} must be an object") + _require_exact_fields(raw_member, _MEMBER_FIELDS, label=label) + role_id = _require_safe_id( + raw_member.get("role_id"), + label=f"{label}.role_id", + maximum=MAX_ROLE_ID_LENGTH, + ) + if role_id in roles: + raise DispatchValidationError(f"batch role_id must be unique: {role_id}") + roles.add(role_id) + timeout = _require_timeout( + raw_member.get("timeout_seconds"), + label=f"{label}.timeout_seconds", + ) + contract = _strict_task_contract( + raw_member.get("contract"), label=f"{label}.contract" + ) + if contract.mode == "edit": + edit_count += 1 + if edit_count > 1: + raise DispatchValidationError( + "batch may contain at most one edit-mode member" + ) + members.append( + BatchMemberRequest( + role_id=role_id, + timeout_seconds=timeout, + contract=contract, + ) + ) + request = BatchRequest( + schema_version=BATCH_SCHEMA_VERSION, + request_id=request_id, + strategy="independent", + members=tuple(members), + ) + if len(canonical_json_bytes(request.to_mapping())) > MAX_BATCH_REQUEST_BYTES: + raise DispatchValidationError( + f"batch request exceeds {MAX_BATCH_REQUEST_BYTES} bytes" + ) + return request + + +def effects_for_members( + members: Sequence[BatchMemberRequest | BatchMemberPlan], +) -> dict[str, object]: + edit = any( + ( + member.contract.mode + if isinstance(member, BatchMemberRequest) + else str(member.normalized_contract.get("mode") or "") + ) + == "edit" + for member in members + ) + return { + "creates_local_state": True, + "starts_provider_processes": len(members), + "may_use_network_or_bill": True, + "writes_source_worktree": False, + "returns_patch_only": edit, + } + + +def canonical_project_root(project_root: str | Path) -> str: + text = str(project_root) + if not text.strip(): + raise DispatchValidationError("batch project_root must be non-empty") + return str(Path(project_root).expanduser().resolve()) + + +@dataclass(frozen=True) +class BatchMemberPlan: + role_id: str + resolved_backend: str + context_file_count: int + context_sha256: str + base_head: str | None + execution_profile: Mapping[str, str] + timeout_seconds: float + normalized_contract: Mapping[str, object] + + def __post_init__(self) -> None: + role_id = _require_safe_id( + self.role_id, + label="batch plan member role_id", + maximum=MAX_ROLE_ID_LENGTH, + ) + backend = _require_safe_id( + self.resolved_backend, + label="batch plan member resolved_backend", + maximum=64, + ) + if backend in {"auto", "echo"}: + raise DispatchValidationError( + "batch plan requires a resolved real provider backend" + ) + if type(self.context_file_count) is not int or self.context_file_count < 0: + raise DispatchValidationError( + "batch plan context_file_count must be a non-negative integer" + ) + context_sha256 = _require_sha256( + self.context_sha256, label="batch plan context_sha256" + ) + if not isinstance(self.execution_profile, Mapping) or not self.execution_profile: + raise DispatchValidationError( + "batch plan execution_profile must be a non-empty object" + ) + execution_profile: dict[str, str] = {} + if len(self.execution_profile) > 16: + raise DispatchValidationError( + "batch plan execution_profile contains too many fields" + ) + for name, value in self.execution_profile.items(): + if ( + type(name) is not str + or not name + or len(name) > 64 + or type(value) is not str + or len(value) > 4096 + ): + raise DispatchValidationError( + "batch plan execution_profile is invalid" + ) + execution_profile[name] = value + assert_content_allowed( + value, + label=f"batch plan execution_profile.{name}", + ) + if execution_profile.get("backend") != backend: + raise DispatchValidationError( + "batch plan execution_profile backend mismatch" + ) + timeout = _require_timeout( + self.timeout_seconds, label="batch plan timeout_seconds" + ) + contract = _strict_task_contract( + self.normalized_contract, + label="batch plan normalized_contract", + ) + if contract.mode == "read-only": + if self.base_head is not None: + raise DispatchValidationError( + "read-only batch plan member base_head must be null" + ) + elif ( + type(self.base_head) is not str + or _GIT_HEAD.fullmatch(self.base_head) is None + ): + raise DispatchValidationError( + "edit batch plan member base_head must be a 40- or " + "64-character lowercase Git hash" + ) + object.__setattr__(self, "role_id", role_id) + object.__setattr__(self, "resolved_backend", backend) + object.__setattr__(self, "context_sha256", context_sha256) + object.__setattr__(self, "execution_profile", dict(sorted(execution_profile.items()))) + object.__setattr__(self, "timeout_seconds", timeout) + object.__setattr__(self, "normalized_contract", contract.to_mapping()) + + def to_mapping(self) -> dict[str, object]: + return { + "role_id": self.role_id, + "resolved_backend": self.resolved_backend, + "context_file_count": self.context_file_count, + "context_sha256": self.context_sha256, + "base_head": self.base_head, + "execution_profile": dict(self.execution_profile), + "timeout_seconds": self.timeout_seconds, + "normalized_contract": dict(self.normalized_contract), + } + + @classmethod + def from_mapping(cls, payload: Mapping[str, Any]) -> BatchMemberPlan: + _require_exact_fields(payload, _PLAN_MEMBER_FIELDS, label="batch plan member") + return cls( + role_id=payload.get("role_id"), # type: ignore[arg-type] + resolved_backend=payload.get("resolved_backend"), # type: ignore[arg-type] + context_file_count=payload.get("context_file_count"), # type: ignore[arg-type] + context_sha256=payload.get("context_sha256"), # type: ignore[arg-type] + base_head=payload.get("base_head"), # type: ignore[arg-type] + execution_profile=payload.get("execution_profile"), # type: ignore[arg-type] + timeout_seconds=payload.get("timeout_seconds"), # type: ignore[arg-type] + normalized_contract=payload.get("normalized_contract"), # type: ignore[arg-type] + ) + + +# Keep both natural name orders available to callers during Batch V1 integration. +BatchPlanMember = BatchMemberPlan + + +def _validate_effects( + effects: Mapping[str, object], members: Sequence[BatchMemberPlan] +) -> dict[str, object]: + _require_exact_fields(effects, _EFFECT_FIELDS, label="batch plan effects") + expected = effects_for_members(members) + normalized = dict(effects) + booleans = ( + "creates_local_state", + "may_use_network_or_bill", + "writes_source_worktree", + "returns_patch_only", + ) + if any(type(normalized.get(field)) is not bool for field in booleans): + raise DispatchValidationError( + "batch plan effect flags must be booleans" + ) + if type(normalized.get("starts_provider_processes")) is not int: + raise DispatchValidationError( + "batch plan starts_provider_processes must be an integer" + ) + if normalized != expected: + raise DispatchValidationError( + "batch plan effects do not match the normalized member contracts" + ) + return expected + + +@dataclass(frozen=True) +class BatchPlan: + project_root: str | Path + request_id: str + strategy: str + effects: Mapping[str, object] + members: tuple[BatchMemberPlan, ...] + schema_version: int = BATCH_SCHEMA_VERSION + + def __post_init__(self) -> None: + if ( + type(self.schema_version) is not int + or self.schema_version != BATCH_SCHEMA_VERSION + ): + raise DispatchValidationError("batch plan schema_version must be 1") + root = canonical_project_root(self.project_root) + request_id = _require_safe_id( + self.request_id, + label="batch plan request_id", + maximum=MAX_REQUEST_ID_LENGTH, + ) + if self.strategy != "independent": + raise DispatchValidationError("batch plan strategy must be independent") + members = tuple(self.members) + if not MIN_BATCH_MEMBERS <= len(members) <= MAX_BATCH_MEMBERS: + raise DispatchValidationError( + "batch plan must contain between 2 and 4 members" + ) + if not all(isinstance(member, BatchMemberPlan) for member in members): + raise DispatchValidationError("batch plan members must be normalized") + roles = [member.role_id for member in members] + if len(set(roles)) != len(roles): + raise DispatchValidationError("batch plan role_id values must be unique") + if sum( + member.normalized_contract.get("mode") == "edit" for member in members + ) > 1: + raise DispatchValidationError( + "batch plan may contain at most one edit-mode member" + ) + if not isinstance(self.effects, Mapping): + raise DispatchValidationError("batch plan effects must be an object") + effects = _validate_effects(self.effects, members) + object.__setattr__(self, "project_root", root) + object.__setattr__(self, "request_id", request_id) + object.__setattr__(self, "members", members) + object.__setattr__(self, "effects", effects) + + @property + def kind(self) -> str: + return BATCH_PLAN_KIND + + @property + def plan_sha256(self) -> str: + return plan_sha256(self) + + def to_canonical_mapping(self) -> dict[str, object]: + return { + "schema_version": self.schema_version, + "kind": self.kind, + "project_root": str(self.project_root), + "request_id": self.request_id, + "strategy": self.strategy, + "effects": dict(self.effects), + "members": [member.to_mapping() for member in self.members], + } + + def to_mapping(self) -> dict[str, object]: + return { + **self.to_canonical_mapping(), + "plan_sha256": self.plan_sha256, + } + + @classmethod + def from_mapping(cls, payload: Mapping[str, Any]) -> BatchPlan: + _require_exact_fields(payload, _DISPLAY_PLAN_FIELDS, label="batch plan") + if payload.get("kind") != BATCH_PLAN_KIND: + raise DispatchValidationError( + f"batch plan kind must be {BATCH_PLAN_KIND}" + ) + raw_members = payload.get("members") + if not isinstance(raw_members, list): + raise DispatchValidationError("batch plan members must be a list") + members: list[BatchMemberPlan] = [] + for index, raw_member in enumerate(raw_members): + if not isinstance(raw_member, Mapping): + raise DispatchValidationError( + f"batch plan members[{index}] must be an object" + ) + members.append(BatchMemberPlan.from_mapping(raw_member)) + effects = payload.get("effects") + if not isinstance(effects, Mapping): + raise DispatchValidationError("batch plan effects must be an object") + plan = cls( + schema_version=payload.get("schema_version"), # type: ignore[arg-type] + project_root=payload.get("project_root"), # type: ignore[arg-type] + request_id=payload.get("request_id"), # type: ignore[arg-type] + strategy=payload.get("strategy"), # type: ignore[arg-type] + effects=effects, + members=tuple(members), + ) + supplied_digest = _require_sha256( + payload.get("plan_sha256"), label="batch plan plan_sha256" + ) + if plan.plan_sha256 != supplied_digest: + raise DispatchValidationError( + "batch plan plan_sha256 does not match its canonical fields" + ) + return plan + + +def canonical_plan_bytes(plan: BatchPlan) -> bytes: + """Return the JCS bytes bound by a Batch V1 plan digest. + + Display timestamps live on the orchestration manifest, not in this mapping. + """ + if not isinstance(plan, BatchPlan): + raise DispatchValidationError("plan must be a BatchPlan") + return canonical_json_bytes(plan.to_canonical_mapping()) + + +def plan_sha256(plan: BatchPlan) -> str: + return hashlib.sha256(canonical_plan_bytes(plan)).hexdigest() + + +canonical_batch_plan_bytes = canonical_plan_bytes +batch_plan_sha256 = plan_sha256 diff --git a/experiments/local_agent_dispatch/bounded_process.py b/experiments/local_agent_dispatch/bounded_process.py index ec2bb80..204dba7 100644 --- a/experiments/local_agent_dispatch/bounded_process.py +++ b/experiments/local_agent_dispatch/bounded_process.py @@ -19,6 +19,7 @@ DEFAULT_MAX_OUTPUT_BYTES = 1024 * 1024 TERMINATION_GRACE_SECONDS = 0.5 +LIFETIME_ANCHOR_GUARD_SECONDS = 2.0 @dataclass(frozen=True) @@ -29,11 +30,16 @@ class BoundedCompletedProcess: stderr: str timed_out: bool = False output_limited: bool = False + cancelled: bool = False stdout_bytes: bytes = b"" stderr_bytes: bytes = b"" -def _terminate_process_group(process: subprocess.Popen[bytes]) -> None: +def _terminate_process_group( + process: subprocess.Popen[bytes], + *, + grace_seconds: float = TERMINATION_GRACE_SECONDS, +) -> None: if process.returncode is not None: return try: @@ -45,7 +51,8 @@ def _terminate_process_group(process: subprocess.Popen[bytes]) -> None: process.terminate() # Keep the leader unreaped until both signals have been sent. Its live or # zombie pid pins the dedicated pgid, preventing reuse between phases. - time.sleep(TERMINATION_GRACE_SECONDS) + if grace_seconds: + time.sleep(grace_seconds) try: os.killpg(process.pid, signal.SIGKILL) except ProcessLookupError: @@ -69,6 +76,7 @@ def run_bounded( max_output_bytes: int = DEFAULT_MAX_OUTPUT_BYTES, on_spawn: Callable[[int], None] | None = None, lifetime_lock_path: Path | None = None, + cancel_check: Callable[[], bool] | None = None, ) -> BoundedCompletedProcess: if not argv: raise ValueError("argv must not be empty") @@ -102,7 +110,9 @@ def run_bounded( } timed_out = False output_limited = False + cancelled = False streaming_complete = False + process_group_terminated = False with tempfile.TemporaryFile() as stdin_file: stdin_file.write(input_text.encode("utf-8")) @@ -117,13 +127,18 @@ def run_bounded( gate_read, gate_write = os.pipe() os.set_inheritable(gate_read, True) bootstrap = ( - "import os,subprocess,sys;" + "import os,signal,subprocess,sys,time;" + "termination_requested=[False];" + "signal.signal(signal.SIGTERM,lambda *_:" + "termination_requested.__setitem__(0,True));" "fd=int(sys.argv[1]);" "token=os.read(fd,1);" "os.close(fd);" "os._exit(125) if token!=b'1' else None;" "child=subprocess.Popen(sys.argv[2:],close_fds=True);" "returncode=child.wait();" + f"time.sleep({LIFETIME_ANCHOR_GUARD_SECONDS!r}) " + "if termination_requested[0] else None;" "os._exit(returncode if returncode>=0 else 128-returncode)" ) process_argv = [ @@ -178,10 +193,16 @@ def run_bounded( captured = 0 deadline = time.monotonic() + timeout_seconds while selector.get_map(): + if cancel_check is not None and cancel_check(): + cancelled = True + _terminate_process_group(process) + process_group_terminated = True + break remaining = deadline - time.monotonic() if remaining <= 0: timed_out = True _terminate_process_group(process) + process_group_terminated = True break events = selector.select(timeout=min(remaining, 0.1)) if not events: @@ -202,6 +223,7 @@ def run_bounded( captured = max_output_bytes output_limited = True _terminate_process_group(process) + process_group_terminated = True break buffers[key.data].extend(chunk) captured += len(chunk) @@ -215,10 +237,18 @@ def run_bounded( os.close(gate_write) if lifetime_handle is not None: lifetime_handle.close() - if process is not None and ( - not streaming_complete or process.poll() is None - ): - _terminate_process_group(process) + if process is not None and not process_group_terminated: + # EOF only proves that every descendant closed or redirected + # the captured streams. Always signal the still-pinned + # dedicated group before reaping the leader so a daemonized + # descendant cannot outlive a successful-looking CLI exit. + _terminate_process_group( + process, + grace_seconds=( + 0.0 if streaming_complete else TERMINATION_GRACE_SECONDS + ), + ) + process_group_terminated = True if selector is not None: for key in list(selector.get_map().values()): try: @@ -244,6 +274,7 @@ def run_bounded( stderr=stderr_bytes.decode("utf-8", errors="replace"), timed_out=timed_out, output_limited=output_limited, + cancelled=cancelled, stdout_bytes=stdout_bytes, stderr_bytes=stderr_bytes, ) diff --git a/experiments/local_agent_dispatch/cli.py b/experiments/local_agent_dispatch/cli.py index 1e40d50..c1837de 100644 --- a/experiments/local_agent_dispatch/cli.py +++ b/experiments/local_agent_dispatch/cli.py @@ -14,6 +14,13 @@ from .errors import DispatchValidationError from .context_guard import safe_error_text from .gc import gc +from .orchestration import ( + cancel_batch, + get_batch_result, + get_batch_status, + plan_batch, + start_batch, +) from .panel import run_panel from .paths import dispatch_home, dispatch_home_path from .run_store import RunRecord, RunStore @@ -60,8 +67,8 @@ def _load_payload(args: argparse.Namespace) -> dict[str, Any]: return payload -def cmd_backends(_: argparse.Namespace) -> int: - _print_json({"backends": probe_backends()}) +def cmd_backends(args: argparse.Namespace) -> int: + _print_json({"backends": probe_backends(passive=args.dry_run)}) return 0 @@ -195,6 +202,79 @@ def cmd_panel(args: argparse.Namespace) -> int: return 0 +def cmd_batch_plan(args: argparse.Namespace) -> int: + payload = _load_payload(args) + home = Path(args.home) if args.home else None + plan = plan_batch( + payload, + project_root=Path(args.project).resolve(), + home=home, + ) + output = plan.to_mapping() + if args.dry_run: + output = {"dry_run": True, **output} + _print_json(output) + return 0 + + +def cmd_batch_start(args: argparse.Namespace) -> int: + payload = _load_payload(args) + home = Path(args.home) if args.home else None + project_root = Path(args.project).resolve() + if args.dry_run: + plan = plan_batch(payload, project_root=project_root, home=home) + if plan.plan_sha256 != args.expect_plan_sha256: + raise DispatchValidationError( + "batch plan digest changed; run batch-plan again before starting" + ) + _print_json({"dry_run": True, **plan.to_mapping()}) + return 0 + status = start_batch( + payload, + expected_plan_sha256=args.expect_plan_sha256, + project_root=project_root, + home=home, + ) + _print_json(status) + return 0 + + +def cmd_batch_status(args: argparse.Namespace) -> int: + home = Path(args.home) if args.home else None + _print_json( + get_batch_status( + args.orchestration_id, + home=home, + reconcile=not args.dry_run, + ) + ) + return 0 + + +def cmd_batch_result(args: argparse.Namespace) -> int: + home = Path(args.home) if args.home else None + _print_json( + get_batch_result( + args.orchestration_id, + home=home, + wait=args.wait and not args.dry_run, + timeout_seconds=args.timeout, + reconcile=not args.dry_run, + ) + ) + return 0 + + +def cmd_batch_cancel(args: argparse.Namespace) -> int: + if args.dry_run: + raise DispatchValidationError( + "dry-run cannot request cancellation; use batch-status to inspect state" + ) + home = Path(args.home) if args.home else None + _print_json(cancel_batch(args.orchestration_id, home=home)) + return 0 + + def cmd_gc(args: argparse.Namespace) -> int: home = Path(args.home) if args.home else None report = gc( @@ -224,7 +304,7 @@ def cmd_skill_render(args: argparse.Namespace) -> int: ) _print_json({"written": str(path)}) else: - print(render_skill_markdown(home=home)) + print(render_skill_markdown(home=home, passive=args.dry_run)) return 0 @@ -262,7 +342,7 @@ def cmd_doctor(args: argparse.Namespace) -> int: _print_json( { "home": str(home), - "backends": probe_backends(), + "backends": probe_backends(passive=args.dry_run), "notes": [ "This experimental tool is shipped in the dyro package.", "Never merge/push/signoff from dispatch results.", @@ -358,10 +438,47 @@ def build_parser() -> argparse.ArgumentParser: p.add_argument("--project", required=True) p.add_argument("--stdin", action="store_true") p.add_argument("--file", default="") - p.add_argument("--members", default="", help="Comma backends, e.g. echo,codex") + p.add_argument( + "--members", + default="", + help="Comma backends, e.g. codex,claude; use all for every ready Provider", + ) p.add_argument("--timeout", type=_positive_finite_float, default=120.0) p.set_defaults(func=cmd_panel) + p = sub.add_parser( + "batch-plan", + help="Plan a persistent multi-role batch without creating state", + ) + p.add_argument("--project", required=True) + p.add_argument("--stdin", action="store_true") + p.add_argument("--file", default="") + p.set_defaults(func=cmd_batch_plan) + + p = sub.add_parser( + "batch-start", + help="Start a previously reviewed batch plan", + ) + p.add_argument("--project", required=True) + p.add_argument("--stdin", action="store_true") + p.add_argument("--file", default="") + p.add_argument("--expect-plan-sha256", required=True) + p.set_defaults(func=cmd_batch_start) + + p = sub.add_parser("batch-status", help="Inspect a persistent batch") + p.add_argument("orchestration_id") + p.set_defaults(func=cmd_batch_status) + + p = sub.add_parser("batch-result", help="Fetch bounded batch results") + p.add_argument("orchestration_id") + p.add_argument("--wait", action="store_true") + p.add_argument("--timeout", type=_positive_finite_float, default=300.0) + p.set_defaults(func=cmd_batch_result) + + p = sub.add_parser("batch-cancel", help="Request safe batch cancellation") + p.add_argument("orchestration_id") + p.set_defaults(func=cmd_batch_cancel) + p = sub.add_parser("gc", help="Garbage-collect aged state") p.add_argument( "--max-age-days", diff --git a/experiments/local_agent_dispatch/context_guard.py b/experiments/local_agent_dispatch/context_guard.py index e000118..1b0fa9a 100644 --- a/experiments/local_agent_dispatch/context_guard.py +++ b/experiments/local_agent_dispatch/context_guard.py @@ -37,6 +37,22 @@ re.compile(r"\bgithub_pat_[A-Za-z0-9_]{20,}\b"), re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b"), re.compile(r"\bAKIA[0-9A-Z]{16}\b"), + re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b"), + re.compile(r"\b(?:sk|rk)_live_[0-9A-Za-z]{16,}\b"), + re.compile( + r"\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\b" + ), + re.compile( + r"(?im)\b(?:ANTHROPIC(?:_AUTH)?_TOKEN|ANTHROPIC_API_KEY|" + r"CLAUDE_CODE_OAUTH_TOKEN|CURSOR_API_KEY|DEEPINFRA_API_KEY|" + r"DEEPSEEK_API_KEY|GEMINI_API_KEY|GOOGLE_API_KEY|GROK_API_KEY|" + r"KIMI(?:_CODING|_CN|_MODEL)?_API_KEY|MINIMAX(?:_CN)?_API_KEY|" + r"NVIDIA_API_KEY|OPENAI_API_KEY|OPENROUTER_API_KEY|" + r"STEPFUN_API_KEY|XAI_API_KEY|Z_AI_API_KEY|ZAI_API_KEY|" + r"GLM_API_KEY)\b\s*(?:=|:)\s*" + r"(?:['\"][^'\"\r\n]{4,}['\"]|[A-Za-z0-9_./+=-]{4,})" + r"\s*(?:[,#}]|$)" + ), ) MAX_CONTEXT_FILE_BYTES = 512 * 1024 @@ -95,6 +111,16 @@ def assert_content_allowed(content: str, *, label: str) -> None: raise DispatchValidationError(f"secret-like content is not allowed in {label}") +def is_credential_field_name(name: str) -> bool: + """Return whether a mapping key names a credential-bearing field.""" + if type(name) is not str or not name: + return False + return not check_content( + f"{name}=credential-value", + file_label="credential field name", + ).allowed + + def safe_error_text(error: object, *, fallback: str = "dispatch failure") -> str: """Return bounded diagnostic text without persisting a detected credential.""" detail = str(error).strip() diff --git a/experiments/local_agent_dispatch/edit_workspace.py b/experiments/local_agent_dispatch/edit_workspace.py index 3f0415a..5850412 100644 --- a/experiments/local_agent_dispatch/edit_workspace.py +++ b/experiments/local_agent_dispatch/edit_workspace.py @@ -6,7 +6,9 @@ import hashlib import os from pathlib import Path +import re import shutil +from typing import Sequence from .bounded_process import run_bounded from .errors import DispatchValidationError @@ -15,6 +17,7 @@ GIT_TIMEOUT_SECONDS = 30.0 MAX_PATCH_BYTES = 8 * 1024 * 1024 +_GIT_HEAD = re.compile(r"^(?:[0-9a-f]{40}|[0-9a-f]{64})$") def _git( @@ -25,16 +28,21 @@ def _git( max_output_bytes: int = MAX_PATCH_BYTES, ): environment = { - "PATH": os.environ.get("PATH", ""), - "HOME": os.environ.get("HOME", ""), - "LANG": os.environ.get("LANG", "C.UTF-8"), + "PATH": "/usr/bin:/bin", + "LANG": "C", + "GIT_CONFIG_GLOBAL": "/dev/null", + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_OPTIONAL_LOCKS": "0", "GIT_TERMINAL_PROMPT": "0", } completed = run_bounded( [ "git", + "--no-optional-locks", "-c", "core.hooksPath=/dev/null", + "-c", + "core.fsmonitor=false", "-C", str(project_root), *arguments, @@ -56,6 +64,56 @@ def _git( return completed +def review_edit_snapshot( + project_root: Path, + relative_paths: Sequence[str], +) -> str: + """Return a clean reviewed HEAD for the exact tracked edit context.""" + root = Path(project_root).resolve(strict=True) + top_level = Path( + _git(root, ["rev-parse", "--show-toplevel"]).stdout.strip() + ).resolve(strict=True) + if top_level != root: + raise DispatchValidationError( + "edit mode requires project_root to be the Git worktree root" + ) + head = _git(root, ["rev-parse", "--verify", "HEAD"]).stdout.strip() + if _GIT_HEAD.fullmatch(head) is None: + raise DispatchValidationError( + "edit mode requires a Git project with a valid HEAD" + ) + paths = sorted(relative_paths) + tracked = _git( + root, + ["ls-files", "-z", "--cached", "--", *paths], + ).stdout_bytes + tracked_paths = { + item.decode("utf-8") for item in tracked.split(b"\0") if item + } + if tracked_paths != set(paths): + raise DispatchValidationError( + "edit context must contain only files tracked at the reviewed HEAD" + ) + status = _git( + root, + [ + "status", + "--porcelain=v1", + "-z", + "--untracked-files=all", + "--", + *paths, + ], + ).stdout_bytes + if status: + raise DispatchValidationError( + "edit context differs from the reviewed Git HEAD" + ) + if _git(root, ["rev-parse", "--verify", "HEAD"]).stdout.strip() != head: + raise DispatchValidationError("edit Git HEAD changed during review") + return head + + @dataclass class EditWorkspace: project_root: Path @@ -70,6 +128,7 @@ def create( project_root: Path, home: Path | None, run_id: str, + base_head: str = "HEAD", ) -> EditWorkspace: project_root = Path(project_root).resolve(strict=True) top_level = Path( @@ -93,7 +152,13 @@ def create( try: _git( project_root, - ["worktree", "add", "--detach", str(worktree_root), "HEAD"], + [ + "worktree", + "add", + "--detach", + str(worktree_root), + base_head, + ], ) except Exception: try: @@ -116,6 +181,14 @@ def create( def seal_patch(self) -> str | None: if not self._created: raise DispatchValidationError("edit workspace was not created") + status = _git( + self.worktree_root, + ["status", "--porcelain=v1", "-z", "--untracked-files=all"], + ) + if b".dyro-dispatch-" in status.stdout_bytes: + raise DispatchValidationError( + "temporary dispatch input remains in edit workspace" + ) _git(self.worktree_root, ["add", "--intent-to-add", "--all"]) completed = _git( self.worktree_root, diff --git a/experiments/local_agent_dispatch/fileset.py b/experiments/local_agent_dispatch/fileset.py index a7e6f06..0ae76a1 100644 --- a/experiments/local_agent_dispatch/fileset.py +++ b/experiments/local_agent_dispatch/fileset.py @@ -2,8 +2,11 @@ from __future__ import annotations +import hashlib from pathlib import Path -from typing import Iterable +from typing import Iterable, Mapping + +from dyro.canonical import canonical_json_bytes from .context_guard import ( MAX_CONTEXT_FILE_BYTES, @@ -150,6 +153,20 @@ def collect_guarded_context( return context +def guarded_context_sha256(context: Mapping[str, str]) -> str: + """Digest one sealed context snapshot without retaining its full contents.""" + entries = [ + { + "path": path, + "sha256": hashlib.sha256(content.encode("utf-8")).hexdigest(), + } + for path, content in sorted(context.items()) + ] + return hashlib.sha256( + canonical_json_bytes({"files": entries}) + ).hexdigest() + + def filter_readable(paths: Iterable[Path], project_root: Path) -> list[Path]: root = Path(project_root).resolve() allowed: list[Path] = [] diff --git a/experiments/local_agent_dispatch/gc.py b/experiments/local_agent_dispatch/gc.py index e966612..7124b5b 100644 --- a/experiments/local_agent_dispatch/gc.py +++ b/experiments/local_agent_dispatch/gc.py @@ -7,12 +7,74 @@ import shutil import time +from .edit_workspace import EditWorkspace from .errors import DispatchValidationError +from .file_lock import file_lock_is_held from .json_store import read_json -from .paths import dispatch_home, dispatch_home_path, existing_managed_dir +from .orchestration_store import OrchestrationManifest, OrchestrationStore +from .paths import ( + dispatch_home, + dispatch_home_path, + existing_managed_dir, +) from .run_store import RunRecord, RunStore +def _cleanup_edit_worktree( + *, + run_id: str, + project_root: Path, + edit_root: Path, + patch_root: Path, +) -> bool: + worktree = edit_root / run_id + if not worktree.exists() and not worktree.is_symlink(): + return True + if worktree.is_symlink() or not worktree.is_dir(): + return False + try: + if worktree.resolve(strict=True).parent != edit_root.resolve(strict=True): + return False + workspace = EditWorkspace( + project_root=Path(project_root).resolve(strict=True), + worktree_root=worktree, + patch_path=patch_root / run_id / "changes.patch", + _created=True, + ) + workspace.cleanup() + except (OSError, DispatchValidationError): + return False + return not worktree.exists() + + +def _remove_file(path: Path) -> bool: + if not path.exists() and not path.is_symlink(): + return True + if not path.is_symlink() and not path.is_file(): + return False + try: + path.unlink() + except OSError: + return False + return True + + +def _remove_tree(path: Path, *, parent: Path) -> bool: + if not path.exists() and not path.is_symlink(): + return True + if path.is_symlink(): + return _remove_file(path) + if not path.is_dir(): + return False + try: + if path.resolve(strict=True).parent != parent.resolve(strict=True): + return False + shutil.rmtree(path) + except OSError: + return False + return not path.exists() + + def gc( *, home: Path | None = None, @@ -32,12 +94,62 @@ def gc( removed_shadows: list[str] = [] shadow_root = existing_managed_dir(home, "shadow") removed_panels: list[str] = [] + removed_orchestrations: list[str] = [] protected_shadows: set[Path] = set() store = RunStore(home, create=not dry_run) if not dry_run: store.reconcile_orphaned_workers() records = store.list_runs() + records_by_id = {record.run_id: record for record in records} + protected_runs: set[str] = set() + orchestration_root = existing_managed_dir(home, "orchestrations") + orchestration_store = OrchestrationStore(home, create=not dry_run) + orchestration_candidates: dict[Path, OrchestrationManifest] = {} + invalid_orchestrations: set[str] = set() + referenced_run_ids: set[str] = set() + for path in sorted(orchestration_root.glob("orch-*.json")): + if path.is_symlink() or not path.is_file(): + continue + try: + manifest = orchestration_store.load(path.stem) + except DispatchValidationError: + # Corrupt state cannot prove which deterministic member runs are + # safe to discard. Run records referring to it remain protected. + invalid_orchestrations.add(path.stem) + continue + referenced_run_ids.update(member.run_id for member in manifest.members) + member_records = [ + records_by_id.get(member.run_id) for member in manifest.members + ] + manifest_aged = ( + math.isfinite(manifest.updated_at) + and now - manifest.updated_at >= max_age_seconds + ) + members_removable = all( + record is None + or ( + record.status in { + "completed", + "failed", + "timeout", + "cancelled", + } + and math.isfinite(record.updated_at) + and now - record.updated_at >= max_age_seconds + ) + for record in member_records + ) + if manifest_aged and members_removable: + orchestration_candidates[path] = manifest + else: + protected_runs.update(member.run_id for member in manifest.members) + for record in records: + if ( + record.orchestration_id in invalid_orchestrations + ): + protected_runs.add(record.run_id) + record_states: list[tuple[RunRecord, bool]] = [] for record in records: terminal = record.status in { @@ -50,8 +162,9 @@ def gc( math.isfinite(record.updated_at) and now - record.updated_at >= max_age_seconds ) - record_states.append((record, terminal and aged)) - if not terminal or not aged: + removable = terminal and aged and record.run_id not in protected_runs + record_states.append((record, removable)) + if not removable: protected_shadows.add((shadow_root / record.run_id).resolve()) if record.shadow_path: try: @@ -62,10 +175,138 @@ def gc( else: if candidate != shadow_root: protected_shadows.add(candidate) + + edit_root = existing_managed_dir(home, "edit-worktrees") + patch_root = existing_managed_dir(home, "patches") + selected_orchestrations: dict[Path, OrchestrationManifest] = {} + blocked_run_ids: set[str] = set() + for path, manifest in orchestration_candidates.items(): + cleanup_proven = True + if not dry_run: + for member, planned in zip( + manifest.members, + manifest.plan.members, + strict=True, + ): + if planned.normalized_contract.get("mode") != "edit": + continue + if not _cleanup_edit_worktree( + run_id=member.run_id, + project_root=Path(manifest.plan.project_root), + edit_root=edit_root, + patch_root=patch_root, + ): + cleanup_proven = False + break + if cleanup_proven: + selected_orchestrations[path] = manifest + else: + blocked_run_ids.update(member.run_id for member in manifest.members) + + revised_states: list[tuple[RunRecord, bool]] = [] + for record, removable in record_states: + if removable and record.run_id not in blocked_run_ids and not dry_run: + try: + contract_mode = str(record.contract.get("mode") or "") + except AttributeError: + contract_mode = "" + if contract_mode == "edit" and not _cleanup_edit_worktree( + run_id=record.run_id, + project_root=Path(record.project_root), + edit_root=edit_root, + patch_root=patch_root, + ): + removable = False + revised_states.append((record, removable and record.run_id not in blocked_run_ids)) + record_states = revised_states + + removable_run_ids = { + record.run_id for record, removable in record_states if removable + } + for path, manifest in list(selected_orchestrations.items()): + if any( + member.run_id in records_by_id + and member.run_id not in removable_run_ids + for member in manifest.members + ): + selected_orchestrations.pop(path) + blocked_run_ids.update(member.run_id for member in manifest.members) + if blocked_run_ids: + record_states = [ + (record, removable and record.run_id not in blocked_run_ids) + for record, removable in record_states + ] + + # Remove the manifest before its member run records. If the process stops + # here, terminal aged runs remain self-identifying orphans and the next GC + # can continue instead of waiting for a now-incomplete manifest forever. + deleted_orchestrations: dict[Path, OrchestrationManifest] = {} + for path, manifest in selected_orchestrations.items(): + if dry_run: + removed_orchestrations.append(path.name) + deleted_orchestrations[path] = manifest + continue + with orchestration_store.mutation_locks( + orchestration_id=manifest.orchestration_id, + request_id=manifest.request_id, + ): + try: + fresh = orchestration_store.load(manifest.orchestration_id) + except DispatchValidationError: + blocked_run_ids.update( + member.run_id for member in manifest.members + ) + continue + fresh_records: list[RunRecord | None] = [] + for member in fresh.members: + try: + fresh_records.append(store.load(member.run_id)) + except DispatchValidationError: + fresh_records.append(None) + still_removable = ( + fresh.request_id == manifest.request_id + and fresh.plan_sha256 == manifest.plan_sha256 + and math.isfinite(fresh.updated_at) + and now - fresh.updated_at >= max_age_seconds + and all( + record is None + or ( + record.status + in {"completed", "failed", "timeout", "cancelled"} + and math.isfinite(record.updated_at) + and now - record.updated_at >= max_age_seconds + ) + for record in fresh_records + ) + ) + if not still_removable: + blocked_run_ids.update( + member.run_id for member in manifest.members + ) + continue + try: + # Heal manifests written by older versions or interrupted + # between the manifest and tombstone durability points. Never + # delete the only idempotency record unless this succeeds. + orchestration_store.bind_manifest_tombstone(fresh) + except (OSError, DispatchValidationError): + blocked_run_ids.update( + member.run_id for member in manifest.members + ) + continue + path.unlink() + removed_orchestrations.append(path.name) + deleted_orchestrations[path] = fresh + selected_orchestrations = deleted_orchestrations + if blocked_run_ids: + record_states = [ + (record, removable and record.run_id not in blocked_run_ids) + for record, removable in record_states + ] + for record, removable in record_states: if not removable: continue - removed_runs.append(record.run_id) deletable_shadow: Path | None = None if record.shadow_path: shadow = Path(record.shadow_path) @@ -86,11 +327,41 @@ def gc( and resolved.is_dir() ): deletable_shadow = resolved - removed_shadows.append(str(resolved)) if not dry_run: - store.delete(record.run_id) - if deletable_shadow is not None: - shutil.rmtree(deletable_shadow, ignore_errors=True) + if deletable_shadow is not None and not _remove_tree( + deletable_shadow, + parent=shadow_root, + ): + continue + if not _remove_tree( + patch_root / record.run_id, + parent=patch_root, + ): + continue + if not _remove_tree( + store.root / f".{record.run_id}.{record.backend}.home", + parent=store.root, + ): + continue + for artifact in ( + store.root / f"{record.run_id}.worker.log", + store.root / f"{record.run_id}.backend.lifetime", + ): + if not _remove_file(artifact): + break + else: + store.delete(record.run_id) + lock_path = store.root / f".{record.run_id}.lock" + if file_lock_is_held(lock_path) is False: + _remove_file(lock_path) + removed_runs.append(record.run_id) + if deletable_shadow is not None: + removed_shadows.append(str(deletable_shadow)) + continue + continue + removed_runs.append(record.run_id) + if deletable_shadow is not None: + removed_shadows.append(str(deletable_shadow)) # Orphan shadows if shadow_root.is_dir(): @@ -103,6 +374,11 @@ def gc( try: current_record = store.load(path.name) except DispatchValidationError: + state_path = store.root / f"{path.name}.json" + if state_path.exists() or state_path.is_symlink(): + # Damaged state cannot prove the backend is no longer using + # this directory as its cwd. + continue current_record = None if current_record is not None: current_terminal = current_record.status in { @@ -123,7 +399,8 @@ def gc( if str(path) not in removed_shadows: removed_shadows.append(str(path)) if not dry_run: - shutil.rmtree(path, ignore_errors=True) + if not _remove_tree(path, parent=shadow_root): + removed_shadows.remove(str(path)) panels_root = existing_managed_dir(home, "panels") for path in panels_root.glob("panel-*.json"): @@ -144,6 +421,72 @@ def gc( if not dry_run: path.unlink(missing_ok=True) + surviving_references = referenced_run_ids - { + member.run_id + for manifest in selected_orchestrations.values() + for member in manifest.members + } + if patch_root.is_dir(): + for path in patch_root.glob("run-*"): + run_id = path.name + state_path = store.root / f"{run_id}.json" + if ( + run_id in surviving_references + or state_path.exists() + or state_path.is_symlink() + ): + continue + try: + aged = now - path.lstat().st_mtime >= max_age_seconds + except OSError: + continue + if aged and not dry_run: + _remove_tree(path, parent=patch_root) + + for pattern, suffix in ( + ("run-*.worker.log", ".worker.log"), + ("run-*.backend.lifetime", ".backend.lifetime"), + (".run-*.lock", ".lock"), + ): + for path in store.root.glob(pattern): + name = path.name[1:] if path.name.startswith(".") else path.name + run_id = name[: -len(suffix)] + state_path = store.root / f"{run_id}.json" + if ( + run_id in surviving_references + or state_path.exists() + or state_path.is_symlink() + ): + continue + try: + aged = now - path.lstat().st_mtime >= max_age_seconds + except OSError: + continue + if not aged or dry_run: + continue + if suffix == ".lock" and file_lock_is_held(path) is not False: + continue + _remove_file(path) + + for path in store.root.glob(".run-*.home"): + identity = path.name[1:-len(".home")] + run_id, separator, _backend = identity.rpartition(".") + if not separator: + continue + state_path = store.root / f"{run_id}.json" + if ( + run_id in surviving_references + or state_path.exists() + or state_path.is_symlink() + ): + continue + try: + aged = now - path.lstat().st_mtime >= max_age_seconds + except OSError: + continue + if aged and not dry_run: + _remove_tree(path, parent=store.root) + return { "home": str(root), "dry_run": dry_run, @@ -151,4 +494,5 @@ def gc( "removed_runs": removed_runs, "removed_shadows": removed_shadows, "removed_panels": removed_panels, + "removed_orchestrations": removed_orchestrations, } diff --git a/experiments/local_agent_dispatch/orchestration.py b/experiments/local_agent_dispatch/orchestration.py new file mode 100644 index 0000000..1856d37 --- /dev/null +++ b/experiments/local_agent_dispatch/orchestration.py @@ -0,0 +1,469 @@ +"""Persistent, independently recoverable Batch V1 orchestration.""" + +from __future__ import annotations + +import math +from pathlib import Path +import time +from typing import Any, Mapping + +from .adapters.registry import ( + adapter_execution_profile, + adapter_is_authenticated, + execution_profile_sha256, + get_adapter, +) +from .batch_contract import ( + BatchMemberPlan, + BatchPlan, + effects_for_members, + parse_batch_request, +) +from .context_guard import safe_error_text +from .edit_workspace import review_edit_snapshot +from .errors import DispatchValidationError +from .fileset import collect_guarded_context, guarded_context_sha256 +from .orchestration_store import OrchestrationManifest, OrchestrationStore +from .panel import candidate_provider_ids +from .run_store import RunRecord, RunStore, TERMINAL_RUN_STATUSES +from .supervisor import DispatchSupervisor +from .task_contract import TaskContract, parse_task_contract + + +MAX_PER_BACKEND = 2 +MAX_RESULT_WAIT_SECONDS = 3600.0 +_RESULT_FIELDS = ( + "summary", + "confidence", + "verified_ratio", + "evidence", + "warnings", + "patch_ref", + "error_code", +) + + +def _select_backend( + requested: str, + *, + ready: tuple[str, ...], + counts: dict[str, int], + mode: str, + strict: bool, +) -> str: + candidates = ready if requested == "auto" else (requested,) + if requested != "auto" and requested not in ready: + raise DispatchValidationError( + f"batch backend is not installed: {requested}" + ) + for backend in sorted( + candidates, + key=lambda item: (counts.get(item, 0), ready.index(item)), + ): + if counts.get(backend, 0) >= MAX_PER_BACKEND: + continue + adapter = get_adapter(backend) + if strict and not getattr(adapter, "strict_isolation", False): + if requested != "auto": + raise DispatchValidationError( + f"backend does not provide strict isolation: {backend}" + ) + continue + supported_modes = getattr( + adapter, + "supported_modes", + frozenset({"read-only", "edit"}), + ) + if mode not in supported_modes: + if requested != "auto": + raise DispatchValidationError( + f"batch backend does not support mode={mode}: {backend}" + ) + continue + counts[backend] = counts.get(backend, 0) + 1 + return backend + if requested == "auto": + raise DispatchValidationError( + "no installed provider has capacity and supports this batch member" + ) + raise DispatchValidationError( + f"batch backend exceeds the {MAX_PER_BACKEND}-member limit: {requested}" + ) + + +def _normalized_contract( + contract: TaskContract, + *, + backend: str, +) -> TaskContract: + payload = contract.to_mapping() + payload["backend"] = backend + return parse_task_contract(payload) + + +def plan_batch( + payload: Mapping[str, Any], + *, + project_root: Path, + home: Path | None = None, +) -> BatchPlan: + """Build a side-effect-free plan bound to Providers, context, and edit HEAD.""" + del home # Planning never resolves or creates dispatch state. + request = parse_batch_request(payload) + try: + root = Path(project_root).expanduser().resolve(strict=True) + except OSError as exc: + raise DispatchValidationError( + f"batch project root does not exist: {project_root}" + ) from exc + if not root.is_dir(): + raise DispatchValidationError( + f"batch project root is not a directory: {root}" + ) + + ready = tuple(candidate_provider_ids()) + if not ready: + raise DispatchValidationError( + "no installed integrated provider is available for batch dispatch" + ) + counts: dict[str, int] = {} + planned: list[BatchMemberPlan] = [] + for member in request.members: + contract = member.contract + backend = _select_backend( + contract.backend, + ready=ready, + counts=counts, + mode=contract.mode, + strict=contract.strict, + ) + adapter = get_adapter(backend) + if ( + not contract.strict + and not getattr(adapter, "strict_isolation", False) + and not contract.allow_unconfined_provider + ): + raise DispatchValidationError( + "real provider access requires allow_unconfined_provider=true: " + f"{member.role_id}" + ) + context = collect_guarded_context(contract.files, root) + base_head = ( + review_edit_snapshot(root, tuple(context)) + if contract.mode == "edit" + else None + ) + normalized = _normalized_contract(contract, backend=backend) + planned.append( + BatchMemberPlan( + role_id=member.role_id, + resolved_backend=backend, + context_file_count=len(context), + context_sha256=guarded_context_sha256(context), + base_head=base_head, + execution_profile=adapter_execution_profile(adapter), + timeout_seconds=member.timeout_seconds, + normalized_contract=normalized.to_mapping(), + ) + ) + return BatchPlan( + project_root=root, + request_id=request.request_id, + strategy=request.strategy, + effects=effects_for_members(planned), + members=tuple(planned), + ) + + +def start_batch( + payload: Mapping[str, Any], + *, + expected_plan_sha256: str, + project_root: Path, + home: Path | None = None, +) -> dict[str, object]: + """Persist and asynchronously start an already reviewed Batch V1 plan.""" + plan = plan_batch(payload, project_root=project_root, home=home) + if plan.plan_sha256 != expected_plan_sha256: + raise DispatchValidationError( + "batch plan digest changed; run batch-plan again before starting" + ) + + # Active authentication probes are deferred until the explicit start + # boundary and complete before any manifest or run state is written. + for backend in dict.fromkeys( + member.resolved_backend for member in plan.members + ): + adapter = get_adapter(backend) + if not adapter.available() or not adapter_is_authenticated(adapter): + raise DispatchValidationError( + f"batch backend is not ready and authenticated: {backend}" + ) + for member in plan.members: + adapter = get_adapter(member.resolved_backend) + if adapter_execution_profile(adapter) != dict(member.execution_profile): + raise DispatchValidationError( + "batch backend execution profile changed; run batch-plan again: " + f"{member.resolved_backend}" + ) + + manifest_store = OrchestrationStore(home) + run_store = RunStore(home) + records: list[RunRecord] = [] + def initialize_members(manifest: OrchestrationManifest) -> None: + for member, planned in zip( + manifest.members, + plan.members, + strict=True, + ): + records.append( + run_store.ensure_created( + run_id=member.run_id, + contract=parse_task_contract(planned.normalized_contract), + project_root=Path(plan.project_root), + backend=member.backend, + orchestration_id=manifest.orchestration_id, + thread_id=member.role_id, + planned_context_sha256=planned.context_sha256, + planned_base_head=planned.base_head or "", + planned_execution_profile_sha256=( + execution_profile_sha256(planned.execution_profile) + ), + planned_execution_profile=planned.execution_profile, + ) + ) + + manifest = manifest_store.create_or_load_initialized( + plan, + initialize_members, + ) + + supervisor = DispatchSupervisor(home=home) + for member, record in zip(manifest.members, records, strict=True): + current_manifest = manifest_store.load(manifest.orchestration_id) + if current_manifest.cancel_requested: + supervisor.cancel( + member.run_id, + reason=f"batch cancelled: {manifest.orchestration_id}", + ) + continue + current = run_store.load(record.run_id) + if current.status != "accepted" or current.worker_token: + continue + try: + supervisor.execute( + current.run_id, + timeout_seconds=member.timeout_seconds, + sync=False, + ) + except Exception as exc: # noqa: BLE001 - preserve independent members + run_store.fail_if_accepted( + current.run_id, + error="batch worker start failed: " + safe_error_text(exc), + ) + return get_batch_status( + manifest.orchestration_id, + home=home, + reconcile=False, + ) + + +def _status_projection( + manifest: OrchestrationManifest, + records: list[RunRecord | None], + *, + reconciled_runs: list[str], +) -> dict[str, object]: + statuses = [record.status if record is not None else "unknown" for record in records] + counts: dict[str, int] = {} + for status in statuses: + counts[status] = counts.get(status, 0) + 1 + if "unknown" in counts: + overall = "attention_required" + elif any(status in {"accepted", "running"} for status in statuses): + overall = "cancelling" if manifest.cancel_requested else "running" + elif statuses and all(status == "completed" for status in statuses): + overall = "completed" + elif statuses and all(status == "cancelled" for status in statuses): + overall = "cancelled" + elif any(status == "completed" for status in statuses): + overall = "partial" + elif statuses and all(status in TERMINAL_RUN_STATUSES for status in statuses): + overall = "failed" + else: + overall = "attention_required" + members = [ + { + "role_id": member.role_id, + "backend": member.backend, + "run_id": member.run_id, + "status": statuses[index], + } + for index, member in enumerate(manifest.members) + ] + return { + "schema_version": 1, + "kind": "local-agent-dispatch-batch-status", + "orchestration_id": manifest.orchestration_id, + "request_id": manifest.request_id, + "plan_sha256": manifest.plan_sha256, + "status": overall, + "cancel_requested": manifest.cancel_requested, + "counts": counts, + "members": members, + "reconciled_runs": reconciled_runs, + } + + +def _load_batch_records( + manifest: OrchestrationManifest, + *, + home: Path | None, + reconcile: bool, +) -> tuple[list[RunRecord | None], list[str]]: + store = RunStore(home, create=reconcile) + reconciled = ( + store.reconcile_orphaned_workers( + run_ids={member.run_id for member in manifest.members} + ) + if reconcile + else [] + ) + records: list[RunRecord | None] = [] + for index, member in enumerate(manifest.members): + planned = manifest.plan.members[index] + try: + record = store.load(member.run_id) + if ( + record.orchestration_id != manifest.orchestration_id + or record.backend != member.backend + or record.thread_id != member.role_id + or record.project_root != str(manifest.plan.project_root) + or record.contract != planned.normalized_contract + or record.planned_context_sha256 != planned.context_sha256 + or record.planned_base_head != (planned.base_head or "") + or record.planned_execution_profile_sha256 + != execution_profile_sha256(planned.execution_profile) + or dict(record.planned_execution_profile or {}) + != dict(planned.execution_profile) + ): + raise DispatchValidationError( + f"batch member run identity mismatch: {member.run_id}" + ) + except DispatchValidationError: + record = None + records.append(record) + return records, reconciled + + +def get_batch_status( + orchestration_id: str, + *, + home: Path | None = None, + reconcile: bool = True, +) -> dict[str, object]: + manifest = OrchestrationStore(home, create=reconcile).load(orchestration_id) + records, reconciled = _load_batch_records( + manifest, + home=home, + reconcile=reconcile, + ) + return _status_projection( + manifest, + records, + reconciled_runs=reconciled, + ) + + +def _validate_wait_timeout(timeout_seconds: float) -> float: + if ( + isinstance(timeout_seconds, bool) + or not isinstance(timeout_seconds, (int, float)) + or not math.isfinite(float(timeout_seconds)) + or timeout_seconds <= 0 + or timeout_seconds > MAX_RESULT_WAIT_SECONDS + ): + raise DispatchValidationError( + f"batch result timeout must be positive and at most {MAX_RESULT_WAIT_SECONDS:g}" + ) + return float(timeout_seconds) + + +def get_batch_result( + orchestration_id: str, + *, + home: Path | None = None, + wait: bool = False, + timeout_seconds: float = 300.0, + reconcile: bool = True, +) -> dict[str, object]: + timeout = _validate_wait_timeout(timeout_seconds) + deadline = time.monotonic() + timeout + while True: + manifest = OrchestrationStore(home, create=reconcile).load(orchestration_id) + records, reconciled = _load_batch_records( + manifest, + home=home, + reconcile=reconcile, + ) + status = _status_projection( + manifest, + records, + reconciled_runs=reconciled, + ) + ready = bool(records) and all( + record is not None and record.status in TERMINAL_RUN_STATUSES + for record in records + ) + if ready or not wait or time.monotonic() >= deadline: + break + time.sleep(0.2) + + result_members: list[dict[str, object]] = [] + for member, record in zip(manifest.members, records, strict=True): + item: dict[str, object] = { + "role_id": member.role_id, + "backend": member.backend, + "run_id": member.run_id, + "status": record.status if record is not None else "unknown", + } + if ready and record is not None: + persisted = record.result or {} + for field in _RESULT_FIELDS: + if field in persisted: + item[field] = persisted[field] + if record.error: + item["error"] = safe_error_text(record.error) + result_members.append(item) + return { + "schema_version": 1, + "kind": "local-agent-dispatch-batch-result", + "orchestration_id": manifest.orchestration_id, + "status": status["status"], + "ready": ready, + "members": result_members, + } + + +def cancel_batch( + orchestration_id: str, + *, + home: Path | None = None, +) -> dict[str, object]: + manifest_store = OrchestrationStore(home) + manifest = manifest_store.request_cancel(orchestration_id) + supervisor = DispatchSupervisor(home=home) + for member in manifest.members: + try: + supervisor.cancel( + member.run_id, + reason=f"batch cancelled: {manifest.orchestration_id}", + ) + except DispatchValidationError: + # Missing or corrupt member state remains visible as attention_required. + continue + return get_batch_status( + manifest.orchestration_id, + home=home, + reconcile=False, + ) diff --git a/experiments/local_agent_dispatch/orchestration_store.py b/experiments/local_agent_dispatch/orchestration_store.py new file mode 100644 index 0000000..9ceb93e --- /dev/null +++ b/experiments/local_agent_dispatch/orchestration_store.py @@ -0,0 +1,718 @@ +"""Fail-closed persisted manifest store for Batch V1 orchestrations.""" + +from __future__ import annotations + +from contextlib import contextmanager +from dataclasses import dataclass +import errno +import hashlib +import json +import math +import os +from pathlib import Path +import re +import stat +import tempfile +import time +from typing import Any, Callable, Iterator, Mapping + +from dyro.canonical import canonical_json_bytes + +from .batch_contract import ( + BATCH_SCHEMA_VERSION, + BatchPlan, + MAX_ROLE_ID_LENGTH, + batch_plan_sha256, +) +from .errors import DispatchValidationError +from .file_lock import exclusive_file_lock +from .json_store import atomic_write_json +from .paths import dispatch_home, dispatch_home_path + + +MAX_MANIFEST_BYTES = 2 * 1024 * 1024 +_ORCHESTRATION_ID = re.compile(r"^orch-[0-9a-f]{16}$") +_RUN_ID = re.compile(r"^run-[0-9a-f]{16}$") +_SHA256 = re.compile(r"^[0-9a-f]{64}$") +_MANIFEST_FIELDS = frozenset( + { + "schema_version", + "orchestration_id", + "request_id", + "plan_sha256", + "created_at", + "updated_at", + "revision", + "cancel_requested", + "members", + "plan", + } +) +_MEMBER_FIELDS = frozenset( + {"role_id", "backend", "run_id", "timeout_seconds"} +) +_SAFE_ROLE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") +_REQUEST_TOMBSTONE_FIELDS = frozenset( + { + "schema_version", + "request_id", + "plan_sha256", + "orchestration_id", + "bound_at", + } +) + + +def _require_exact_fields( + payload: Mapping[str, Any], expected: frozenset[str], *, label: str +) -> None: + actual = set(payload) + unknown = sorted(actual - expected) + if unknown: + raise DispatchValidationError( + f"{label} contains unknown fields: {', '.join(unknown)}" + ) + missing = sorted(expected - actual) + if missing: + raise DispatchValidationError( + f"{label} is missing required fields: {', '.join(missing)}" + ) + + +def _require_digest(value: object, *, label: str) -> str: + if type(value) is not str or _SHA256.fullmatch(value) is None: + raise DispatchValidationError(f"{label} must be a lowercase SHA-256 digest") + return value + + +def orchestration_id_for(request_id: str, plan_sha256: str) -> str: + _require_digest(plan_sha256, label="plan_sha256") + if type(request_id) is not str or not request_id: + raise DispatchValidationError("request_id must be a non-empty string") + digest = hashlib.sha256(f"{request_id}{plan_sha256}".encode("utf-8")).hexdigest() + return f"orch-{digest[:16]}" + + +def run_id_for(orchestration_id: str, member_index: int) -> str: + if type(orchestration_id) is not str or _ORCHESTRATION_ID.fullmatch( + orchestration_id + ) is None: + raise DispatchValidationError("invalid orchestration_id") + if type(member_index) is not int or member_index < 0: + raise DispatchValidationError("member_index must be a non-negative integer") + digest = hashlib.sha256( + f"{orchestration_id}{member_index}".encode("utf-8") + ).hexdigest() + return f"run-{digest[:16]}" + + +@dataclass(frozen=True) +class OrchestrationMember: + role_id: str + backend: str + run_id: str + timeout_seconds: float + + def to_mapping(self) -> dict[str, object]: + return { + "role_id": self.role_id, + "backend": self.backend, + "run_id": self.run_id, + "timeout_seconds": self.timeout_seconds, + } + + +@dataclass(frozen=True) +class OrchestrationManifest: + orchestration_id: str + request_id: str + plan_sha256: str + created_at: float + updated_at: float + revision: int + cancel_requested: bool + members: tuple[OrchestrationMember, ...] + plan: BatchPlan + schema_version: int = BATCH_SCHEMA_VERSION + + def to_mapping(self) -> dict[str, object]: + return { + "schema_version": self.schema_version, + "orchestration_id": self.orchestration_id, + "request_id": self.request_id, + "plan_sha256": self.plan_sha256, + "created_at": self.created_at, + "updated_at": self.updated_at, + "revision": self.revision, + "cancel_requested": self.cancel_requested, + "members": [member.to_mapping() for member in self.members], + "plan": self.plan.to_mapping(), + } + + @classmethod + def from_mapping(cls, payload: Mapping[str, Any]) -> OrchestrationManifest: + _require_exact_fields(payload, _MANIFEST_FIELDS, label="orchestration manifest") + if ( + type(payload.get("schema_version")) is not int + or payload.get("schema_version") != BATCH_SCHEMA_VERSION + ): + raise DispatchValidationError("orchestration schema_version must be 1") + orchestration_id = payload.get("orchestration_id") + if type(orchestration_id) is not str or _ORCHESTRATION_ID.fullmatch( + orchestration_id + ) is None: + raise DispatchValidationError("invalid orchestration_id") + plan_digest = _require_digest( + payload.get("plan_sha256"), label="orchestration plan_sha256" + ) + created_at = payload.get("created_at") + updated_at = payload.get("updated_at") + if ( + isinstance(created_at, bool) + or not isinstance(created_at, (int, float)) + or not math.isfinite(float(created_at)) + or float(created_at) < 0 + ): + raise DispatchValidationError("orchestration created_at must be finite") + if ( + isinstance(updated_at, bool) + or not isinstance(updated_at, (int, float)) + or not math.isfinite(float(updated_at)) + or float(updated_at) < float(created_at) + ): + raise DispatchValidationError( + "orchestration updated_at must be finite and not precede created_at" + ) + revision = payload.get("revision") + if type(revision) is not int or revision < 0: + raise DispatchValidationError( + "orchestration revision must be a non-negative integer" + ) + cancel_requested = payload.get("cancel_requested") + if type(cancel_requested) is not bool: + raise DispatchValidationError( + "orchestration cancel_requested must be a boolean" + ) + raw_plan = payload.get("plan") + if not isinstance(raw_plan, Mapping): + raise DispatchValidationError("orchestration plan must be an object") + plan = BatchPlan.from_mapping(raw_plan) + if payload.get("request_id") != plan.request_id: + raise DispatchValidationError( + "orchestration request_id does not match its plan" + ) + if batch_plan_sha256(plan) != plan_digest: + raise DispatchValidationError( + "orchestration plan_sha256 does not match its plan" + ) + if orchestration_id_for(plan.request_id, plan_digest) != orchestration_id: + raise DispatchValidationError( + "orchestration_id does not match request_id and plan_sha256" + ) + + raw_members = payload.get("members") + if not isinstance(raw_members, list) or len(raw_members) != len(plan.members): + raise DispatchValidationError( + "orchestration members must match the plan member count" + ) + members: list[OrchestrationMember] = [] + for index, (raw_member, planned) in enumerate( + zip(raw_members, plan.members, strict=True) + ): + label = f"orchestration members[{index}]" + if not isinstance(raw_member, Mapping): + raise DispatchValidationError(f"{label} must be an object") + _require_exact_fields(raw_member, _MEMBER_FIELDS, label=label) + role_id = raw_member.get("role_id") + if ( + type(role_id) is not str + or len(role_id) > MAX_ROLE_ID_LENGTH + or _SAFE_ROLE.fullmatch(role_id) is None + or role_id != planned.role_id + ): + raise DispatchValidationError(f"{label}.role_id does not match the plan") + backend = raw_member.get("backend") + if type(backend) is not str or backend != planned.resolved_backend: + raise DispatchValidationError(f"{label}.backend does not match the plan") + run_id = raw_member.get("run_id") + if ( + type(run_id) is not str + or _RUN_ID.fullmatch(run_id) is None + or run_id != run_id_for(orchestration_id, index) + ): + raise DispatchValidationError(f"{label}.run_id is invalid") + timeout = raw_member.get("timeout_seconds") + if ( + isinstance(timeout, bool) + or not isinstance(timeout, (int, float)) + or not math.isfinite(float(timeout)) + or float(timeout) != planned.timeout_seconds + ): + raise DispatchValidationError( + f"{label}.timeout_seconds does not match the plan" + ) + members.append( + OrchestrationMember( + role_id=role_id, + backend=backend, + run_id=run_id, + timeout_seconds=float(timeout), + ) + ) + return cls( + schema_version=BATCH_SCHEMA_VERSION, + orchestration_id=orchestration_id, + request_id=plan.request_id, + plan_sha256=plan_digest, + created_at=float(created_at), + updated_at=float(updated_at), + revision=revision, + cancel_requested=cancel_requested, + members=tuple(members), + plan=plan, + ) + + +class OrchestrationStore: + def __init__( + self, + home: Path | None = None, + *, + create: bool = True, + ) -> None: + self._create = create + self.home = dispatch_home(home) if create else dispatch_home_path(home) + self.root = self.home / "orchestrations" + self._ensure_root() + + def _ensure_root(self) -> None: + if self.root.is_symlink(): + raise DispatchValidationError( + f"orchestration directory is a symbolic link: {self.root}" + ) + if self._create: + try: + self.root.mkdir(mode=0o700, exist_ok=True) + except OSError as exc: + raise DispatchValidationError( + f"orchestration directory cannot be created: {self.root}" + ) from exc + elif not self.root.exists(): + return + if ( + self.root.is_symlink() + or not self.root.is_dir() + or self.root.resolve(strict=True).parent != self.home + ): + raise DispatchValidationError( + f"orchestration directory escapes dispatch home: {self.root}" + ) + + def _path(self, orchestration_id: str) -> Path: + self._ensure_root() + if type(orchestration_id) is not str or _ORCHESTRATION_ID.fullmatch( + orchestration_id + ) is None: + raise DispatchValidationError("invalid orchestration_id") + return self.root / f"{orchestration_id}.json" + + def _manifest_lock_path(self, orchestration_id: str) -> Path: + self._path(orchestration_id) + return self.root / f".{orchestration_id}.lock" + + def _request_lock_path(self, request_id: str) -> Path: + digest = hashlib.sha256(request_id.encode("utf-8")).hexdigest()[:24] + return self.root / f".request-{digest}.lock" + + def _request_tombstone_path(self, request_id: str) -> Path: + digest = hashlib.sha256(request_id.encode("utf-8")).hexdigest() + return self.root / f"request-{digest}.json" + + @contextmanager + def mutation_locks( + self, + *, + orchestration_id: str, + request_id: str, + ) -> Iterator[None]: + """Serialize GC with create/idempotency and cancellation mutation.""" + with exclusive_file_lock(self._request_lock_path(request_id)): + with exclusive_file_lock( + self._manifest_lock_path(orchestration_id) + ): + yield + + def _read_payload(self, path: Path) -> dict[str, Any]: + flags = ( + os.O_RDONLY + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NONBLOCK", 0) + ) + flags |= getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(path, flags) + except FileNotFoundError as exc: + raise DispatchValidationError( + f"orchestration not found: {path.stem}" + ) from exc + except OSError as exc: + if exc.errno in {errno.ELOOP, errno.EMLINK} or path.is_symlink(): + raise DispatchValidationError( + f"orchestration manifest is a symbolic link: {path.stem}" + ) from exc + raise DispatchValidationError( + f"orchestration manifest cannot be opened safely: {path.stem}" + ) from exc + try: + opened = os.fstat(descriptor) + if not stat.S_ISREG(opened.st_mode): + raise DispatchValidationError( + f"orchestration manifest is not a regular file: {path.stem}" + ) + if opened.st_size > MAX_MANIFEST_BYTES: + raise DispatchValidationError( + f"orchestration manifest exceeds {MAX_MANIFEST_BYTES} bytes" + ) + linked = os.stat(path, follow_symlinks=False) + if stat.S_ISLNK(linked.st_mode) or not os.path.samestat(opened, linked): + raise DispatchValidationError( + f"orchestration manifest path changed while opening: {path.stem}" + ) + with os.fdopen(descriptor, "rb", closefd=False) as handle: + raw = handle.read(MAX_MANIFEST_BYTES + 1) + if len(raw) > MAX_MANIFEST_BYTES: + raise DispatchValidationError( + f"orchestration manifest exceeds {MAX_MANIFEST_BYTES} bytes" + ) + finally: + os.close(descriptor) + try: + payload = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise DispatchValidationError( + f"orchestration manifest is corrupt: {path.stem}" + ) from exc + if not isinstance(payload, dict): + raise DispatchValidationError( + f"orchestration manifest must be an object: {path.stem}" + ) + return payload + + def _create_only(self, path: Path, payload: dict[str, object]) -> None: + text = ( + json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False) + "\n" + ).encode("utf-8") + if len(text) > MAX_MANIFEST_BYTES: + raise DispatchValidationError( + f"orchestration manifest exceeds {MAX_MANIFEST_BYTES} bytes" + ) + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{path.name}.", suffix=".tmp", dir=str(self.root) + ) + temporary = Path(temporary_name) + try: + with os.fdopen(descriptor, "wb") as handle: + handle.write(text) + handle.flush() + os.fsync(handle.fileno()) + try: + os.link(temporary, path, follow_symlinks=False) + except FileExistsError: + raise + directory_fd = os.open(self.root, os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + finally: + try: + temporary.unlink() + except FileNotFoundError: + pass + + def _find_request(self, request_id: str) -> OrchestrationManifest | None: + for path in sorted(self.root.glob("orch-*.json")): + manifest = OrchestrationManifest.from_mapping(self._read_payload(path)) + if manifest.request_id == request_id: + return manifest + return None + + def _load_request_tombstone( + self, + request_id: str, + ) -> dict[str, object] | None: + path = self._request_tombstone_path(request_id) + if not path.exists() and not path.is_symlink(): + return None + payload = self._read_payload(path) + _require_exact_fields( + payload, + _REQUEST_TOMBSTONE_FIELDS, + label="orchestration request tombstone", + ) + if payload.get("schema_version") != BATCH_SCHEMA_VERSION: + raise DispatchValidationError( + "orchestration request tombstone schema_version must be 1" + ) + if payload.get("request_id") != request_id: + raise DispatchValidationError( + "orchestration request tombstone does not match request_id" + ) + _require_digest( + payload.get("plan_sha256"), + label="orchestration request tombstone plan_sha256", + ) + orchestration_id = payload.get("orchestration_id") + if ( + type(orchestration_id) is not str + or _ORCHESTRATION_ID.fullmatch(orchestration_id) is None + ): + raise DispatchValidationError( + "orchestration request tombstone has invalid orchestration_id" + ) + bound_at = payload.get("bound_at") + if ( + isinstance(bound_at, bool) + or not isinstance(bound_at, (int, float)) + or not math.isfinite(float(bound_at)) + or float(bound_at) <= 0 + ): + raise DispatchValidationError( + "orchestration request tombstone bound_at must be finite" + ) + return dict(payload) + + def _bind_request_tombstone( + self, + *, + request_id: str, + plan_sha256: str, + orchestration_id: str, + bound_at: float, + ) -> None: + expected: dict[str, object] = { + "schema_version": BATCH_SCHEMA_VERSION, + "request_id": request_id, + "plan_sha256": plan_sha256, + "orchestration_id": orchestration_id, + "bound_at": bound_at, + } + existing = self._load_request_tombstone(request_id) + if existing is not None: + if ( + existing["request_id"] != request_id + or existing["plan_sha256"] != plan_sha256 + or existing["orchestration_id"] != orchestration_id + ): + raise DispatchValidationError( + "batch request_id is already bound to a different plan" + ) + return + try: + self._create_only( + self._request_tombstone_path(request_id), + expected, + ) + except FileExistsError: + persisted = self._load_request_tombstone(request_id) + if persisted is None or ( + persisted["request_id"] != request_id + or persisted["plan_sha256"] != plan_sha256 + or persisted["orchestration_id"] != orchestration_id + ): + raise DispatchValidationError( + "batch request_id is already bound to a different plan" + ) + + @staticmethod + def orchestration_id_for(request_id: str, plan_sha256: str) -> str: + return orchestration_id_for(request_id, plan_sha256) + + @staticmethod + def run_id_for(orchestration_id: str, member_index: int) -> str: + return run_id_for(orchestration_id, member_index) + + def _create_or_load_locked(self, plan: BatchPlan) -> OrchestrationManifest: + """Create or load while the caller owns the request mutation lock.""" + if not self._create: + raise DispatchValidationError( + "read-only orchestration store cannot create manifests" + ) + if not isinstance(plan, BatchPlan): + raise DispatchValidationError("plan must be a BatchPlan") + digest = batch_plan_sha256(plan) + orchestration_id = orchestration_id_for(plan.request_id, digest) + plan_mapping = plan.to_mapping() + tombstone = self._load_request_tombstone(plan.request_id) + existing_for_request = self._find_request(plan.request_id) + if existing_for_request is not None: + if ( + existing_for_request.orchestration_id != orchestration_id + or existing_for_request.plan_sha256 != digest + or canonical_json_bytes(existing_for_request.plan.to_mapping()) + != canonical_json_bytes(plan_mapping) + ): + raise DispatchValidationError( + "batch request_id is already bound to a different plan" + ) + self._bind_request_tombstone( + request_id=plan.request_id, + plan_sha256=digest, + orchestration_id=orchestration_id, + bound_at=existing_for_request.created_at, + ) + return existing_for_request + + if tombstone is not None: + if ( + tombstone["plan_sha256"] != digest + or tombstone["orchestration_id"] != orchestration_id + ): + raise DispatchValidationError( + "batch request_id is already bound to a different plan" + ) + raise DispatchValidationError( + "batch request_id was already executed and garbage-collected; " + "use a new request_id" + ) + + now = time.time() + members = tuple( + OrchestrationMember( + role_id=member.role_id, + backend=member.resolved_backend, + run_id=run_id_for(orchestration_id, index), + timeout_seconds=member.timeout_seconds, + ) + for index, member in enumerate(plan.members) + ) + manifest = OrchestrationManifest( + orchestration_id=orchestration_id, + request_id=plan.request_id, + plan_sha256=digest, + created_at=now, + updated_at=now, + revision=0, + cancel_requested=False, + members=members, + plan=plan, + ) + path = self._path(orchestration_id) + try: + self._create_only(path, manifest.to_mapping()) + except FileExistsError: + persisted = self.load(orchestration_id) + if ( + persisted.request_id == plan.request_id + and persisted.plan_sha256 == digest + and canonical_json_bytes(persisted.plan.to_mapping()) + == canonical_json_bytes(plan_mapping) + ): + return persisted + raise DispatchValidationError( + "orchestration manifest already exists with different content" + ) + self._bind_request_tombstone( + request_id=plan.request_id, + plan_sha256=digest, + orchestration_id=orchestration_id, + bound_at=manifest.created_at, + ) + return manifest + + def create_or_load(self, plan: BatchPlan) -> OrchestrationManifest: + if not isinstance(plan, BatchPlan): + raise DispatchValidationError("plan must be a BatchPlan") + with exclusive_file_lock(self._request_lock_path(plan.request_id)): + return self._create_or_load_locked(plan) + + def create_or_load_initialized( + self, + plan: BatchPlan, + initializer: Callable[[OrchestrationManifest], None], + ) -> OrchestrationManifest: + """Create the manifest and all member state under GC's lock order.""" + if not isinstance(plan, BatchPlan): + raise DispatchValidationError("plan must be a BatchPlan") + if not callable(initializer): + raise DispatchValidationError("initializer must be callable") + with exclusive_file_lock(self._request_lock_path(plan.request_id)): + manifest = self._create_or_load_locked(plan) + with exclusive_file_lock( + self._manifest_lock_path(manifest.orchestration_id) + ): + fresh = self.load(manifest.orchestration_id) + initializer(fresh) + return fresh + + def bind_manifest_tombstone( + self, + manifest: OrchestrationManifest, + ) -> None: + """Create or validate a manifest's permanent idempotency binding.""" + if not self._create: + raise DispatchValidationError( + "read-only orchestration store cannot bind request tombstones" + ) + if not isinstance(manifest, OrchestrationManifest): + raise DispatchValidationError("manifest must be an orchestration") + self._bind_request_tombstone( + request_id=manifest.request_id, + plan_sha256=manifest.plan_sha256, + orchestration_id=manifest.orchestration_id, + bound_at=manifest.created_at, + ) + + def load(self, orchestration_id: str) -> OrchestrationManifest: + path = self._path(orchestration_id) + return OrchestrationManifest.from_mapping(self._read_payload(path)) + + def request_cancel( + self, + orchestration_id: str, + *, + expected_revision: int | None = None, + ) -> OrchestrationManifest: + if not self._create: + raise DispatchValidationError( + "read-only orchestration store cannot request cancellation" + ) + if expected_revision is not None and ( + type(expected_revision) is not int or expected_revision < 0 + ): + raise DispatchValidationError( + "expected_revision must be a non-negative integer" + ) + with exclusive_file_lock(self._manifest_lock_path(orchestration_id)): + manifest = self.load(orchestration_id) + if manifest.cancel_requested: + return manifest + if ( + expected_revision is not None + and manifest.revision != expected_revision + ): + raise DispatchValidationError( + "orchestration revision conflict while requesting cancellation" + ) + updated = OrchestrationManifest( + orchestration_id=manifest.orchestration_id, + request_id=manifest.request_id, + plan_sha256=manifest.plan_sha256, + created_at=manifest.created_at, + updated_at=time.time(), + revision=manifest.revision + 1, + cancel_requested=True, + members=manifest.members, + plan=manifest.plan, + ) + payload = updated.to_mapping() + encoded = ( + json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False) + + "\n" + ).encode("utf-8") + if len(encoded) > MAX_MANIFEST_BYTES: + raise DispatchValidationError( + f"orchestration manifest exceeds {MAX_MANIFEST_BYTES} bytes" + ) + atomic_write_json(self._path(orchestration_id), payload) + return self.load(orchestration_id) diff --git a/experiments/local_agent_dispatch/panel.py b/experiments/local_agent_dispatch/panel.py index 4af257f..72d55ed 100644 --- a/experiments/local_agent_dispatch/panel.py +++ b/experiments/local_agent_dispatch/panel.py @@ -9,10 +9,13 @@ from typing import Mapping, Sequence from .adapters.registry import ( + REAL_PROVIDER_IDS, + adapter_execution_profile, adapter_is_authenticated, get_adapter, probe_backends, ) +from .context_guard import safe_error_text from .errors import DispatchValidationError from .json_store import atomic_write_json from .paths import panels_dir @@ -20,30 +23,85 @@ from .task_contract import parse_task_contract +MAX_PANEL_MEMBERS = len(REAL_PROVIDER_IDS) +MAX_PANEL_PARALLEL = 4 +_DEFAULT_PROVIDER_ORDER = ( + "codex", + "claude", + "grok", + "opencode", + "hermes", + "pi", + "kimi", + "dsh", + "cursor-agent", +) + + +def ready_provider_ids() -> list[str]: + """Return ready real Providers in the stable dispatch preference order.""" + ready = { + str(row["id"]) + for row in probe_backends() + if ( + row["available"] + and row["authenticated"] + and row.get("supported") + and row.get("execution_kind") == "provider" + ) + } + return [backend for backend in _DEFAULT_PROVIDER_ORDER if backend in ready] + + +def candidate_provider_ids() -> list[str]: + """Return installed Providers without starting their authentication CLIs.""" + candidates: list[str] = [] + for backend in _DEFAULT_PROVIDER_ORDER: + adapter = get_adapter(backend) + if not adapter.available(): + continue + try: + adapter_execution_profile(adapter) + except DispatchValidationError: + continue + candidates.append(backend) + return candidates + + def resolve_panel_members(requested: Sequence[str] | None) -> list[str]: if requested: - members = [] + requested = [name.strip() for name in requested if name.strip()] + if "all" in requested: + if requested != ["all"]: + raise DispatchValidationError( + "panel member 'all' cannot be combined with backend IDs" + ) + preferred = ready_provider_ids() + if not preferred: + raise DispatchValidationError( + "no authenticated integrated provider is available" + ) + return preferred + members: list[str] = [] for name in requested: adapter = get_adapter(name) - if adapter.available() and adapter_is_authenticated(adapter): + if ( + adapter.available() + and adapter_is_authenticated(adapter) + and adapter.id not in members + ): members.append(adapter.id) if not members: raise DispatchValidationError("no panel members available") + if len(members) > MAX_PANEL_MEMBERS: + raise DispatchValidationError( + f"panel exceeds the {MAX_PANEL_MEMBERS}-member limit" + ) return members # Default panels must contain only ready, integrated Providers. An # explicitly requested echo member remains possible for deterministic test # simulations, but must be separately acknowledged by the task contract. - available = [ - row["id"] - for row in probe_backends() - if ( - row["available"] - and row["authenticated"] - and row.get("supported") - and row.get("execution_kind") == "provider" - ) - ] - preferred = [b for b in ("codex", "claude") if b in available] + preferred = ready_provider_ids() if not preferred: raise DispatchValidationError( "no authenticated integrated provider is available; " @@ -73,29 +131,56 @@ def run_panel( runs: list[dict[str, object]] = [] def dispatch_member(backend: str) -> dict[str, object]: - contract_payload = dict(base) - contract_payload["backend"] = backend - parse_task_contract(contract_payload) - record = supervisor.accept( - contract_payload, project_root=project_root, panel_id=panel_id - ) - finished = supervisor.execute( - record.run_id, timeout_seconds=timeout_seconds, sync=True - ) - result = finished.result or {} - return { - "backend": backend, - "run_id": finished.run_id, - "status": finished.status, - "summary": result.get("summary"), - "confidence": result.get("confidence"), - "verified_ratio": result.get("verified_ratio"), - "evidence": result.get("evidence"), - "error": finished.error, - } + run_id = "" + try: + contract_payload = dict(base) + contract_payload["backend"] = backend + parse_task_contract(contract_payload) + record = supervisor.accept( + contract_payload, project_root=project_root, panel_id=panel_id + ) + run_id = record.run_id + finished = supervisor.execute( + record.run_id, timeout_seconds=timeout_seconds, sync=True + ) + result = finished.result or {} + return { + "backend": backend, + "run_id": finished.run_id, + "status": finished.status, + "summary": result.get("summary"), + "confidence": result.get("confidence"), + "verified_ratio": result.get("verified_ratio"), + "evidence": result.get("evidence"), + "error": finished.error, + } + except Exception as exc: # noqa: BLE001 - isolate panel members + error = safe_error_text( + exc, fallback="panel member execution failed" + ) + persisted_status = "failed" + if run_id: + try: + persisted = supervisor.store.fail_if_accepted( + run_id, + error=error, + ) + persisted_status = persisted.status + except Exception: # noqa: BLE001 - preserve the board + persisted_status = "unknown" + return { + "backend": backend, + "run_id": run_id, + "status": persisted_status, + "summary": None, + "confidence": None, + "verified_ratio": None, + "evidence": None, + "error": error, + } with ThreadPoolExecutor( - max_workers=min(len(backends), 4), + max_workers=min(len(backends), MAX_PANEL_PARALLEL), thread_name_prefix="dyro-panel", ) as executor: runs.extend(executor.map(dispatch_member, backends)) diff --git a/experiments/local_agent_dispatch/paths.py b/experiments/local_agent_dispatch/paths.py index f391489..e81f364 100644 --- a/experiments/local_agent_dispatch/paths.py +++ b/experiments/local_agent_dispatch/paths.py @@ -16,6 +16,7 @@ "edit-worktrees", "skills", "panels", + "orchestrations", "patches", ) @@ -106,5 +107,9 @@ def panels_dir(home: Path | None = None) -> Path: return dispatch_home(home) / "panels" +def orchestrations_dir(home: Path | None = None) -> Path: + return dispatch_home(home) / "orchestrations" + + def patches_dir(home: Path | None = None) -> Path: return dispatch_home(home) / "patches" diff --git a/experiments/local_agent_dispatch/process_identity.py b/experiments/local_agent_dispatch/process_identity.py index 9196b35..d0f748a 100644 --- a/experiments/local_agent_dispatch/process_identity.py +++ b/experiments/local_agent_dispatch/process_identity.py @@ -88,6 +88,49 @@ def process_state(pid: int) -> str | None: return state or None +def process_group_has_live_members(process_group_id: int) -> bool | None: + """Return whether a process group contains a non-zombie member. + + ``False`` means every observed member has exited (and may only be waiting + to be reaped), while ``None`` keeps callers fail-closed when the process + table cannot be inspected reliably. + """ + if type(process_group_id) is not int or process_group_id <= 0: + return None + ps_path = "/bin/ps" + if not os.path.isfile(ps_path) or not os.access(ps_path, os.X_OK): + return None + try: + completed = subprocess.run( + [ps_path, "-axo", "pgid=,stat="], + check=False, + capture_output=True, + text=True, + timeout=2, + ) + except (OSError, subprocess.TimeoutExpired): + return None + if completed.returncode != 0: + return None + parsed_rows = 0 + for line in completed.stdout.splitlines(): + if not line.strip(): + continue + fields = line.split() + if len(fields) < 2: + return None + try: + member_pgid = int(fields[0]) + except ValueError: + return None + parsed_rows += 1 + if member_pgid != process_group_id: + continue + if not fields[1].startswith("Z"): + return True + return False if parsed_rows else None + + def process_identity_is_dead(*, pid: int, started_at: str) -> bool: """ Return true only with positive evidence that the generation ended. diff --git a/experiments/local_agent_dispatch/result_envelope.py b/experiments/local_agent_dispatch/result_envelope.py index 2e5dbf4..26efa55 100644 --- a/experiments/local_agent_dispatch/result_envelope.py +++ b/experiments/local_agent_dispatch/result_envelope.py @@ -3,9 +3,10 @@ from __future__ import annotations from dataclasses import dataclass, field +import math from typing import Any, Mapping, Sequence -from .context_guard import assert_content_allowed +from .context_guard import assert_content_allowed, is_credential_field_name from .errors import DispatchValidationError from .locator_verify import EvidenceItem, verify_evidence @@ -28,6 +29,11 @@ class ResultEnvelope: isolation: str = "not-applicable" def to_mapping(self) -> dict[str, object]: + normalized_usage = _normalize_safe_result_value( + self.usage, "result.usage" + ) + if type(normalized_usage) is not dict: + raise DispatchValidationError("result.usage must be an object") return { "schema_version": self.schema_version, "run_id": self.run_id, @@ -37,7 +43,7 @@ def to_mapping(self) -> dict[str, object]: "evidence": [item.to_mapping() for item in self.evidence], "patch_ref": self.patch_ref, "takeover": self.takeover, - "usage": dict(self.usage), + "usage": normalized_usage, "warnings": list(self.warnings), "backend": self.backend, "error_code": self.error_code, @@ -94,6 +100,11 @@ def build_result( value = item.get(name) if isinstance(value, str): _assert_safe_result_text(value, f"result.evidence[{index}].{name}") + normalized_usage = _normalize_safe_result_value( + usage if usage is not None else {}, "result.usage" + ) + if type(normalized_usage) is not dict: + raise DispatchValidationError("result.usage must be an object") verified = verify_evidence(list(evidence or ()), cwd=cwd) return ResultEnvelope( schema_version=1, @@ -104,7 +115,7 @@ def build_result( evidence=verified, patch_ref=patch_ref, takeover=takeover, - usage=dict(usage or {}), + usage=normalized_usage, warnings=list(warnings or ()), backend=backend, error_code=error_code, @@ -120,6 +131,82 @@ def _assert_safe_result_text(value: str, label: str) -> None: assert_content_allowed(value, label=label) +def _normalize_safe_result_value( + value: object, + label: str, + *, + _seen: set[int] | None = None, + _depth: int = 0, + _credential_parent: bool = False, +) -> object: + """Return one validated, plain-JSON snapshot for result metadata.""" + if _depth > 16: + raise DispatchValidationError(f"{label} exceeds the nesting limit") + if _credential_parent: + exact_empty = ( + value is None + or (type(value) is str and not value) + or (type(value) in {tuple, list, dict} and len(value) == 0) + ) + if exact_empty: + if type(value) is tuple: + return [] + return value + raise DispatchValidationError( + f"secret-like content is not allowed in {label}" + ) + if type(value) is str: + _assert_safe_result_text(value, label) + return value + if value is None or type(value) in {bool, int}: + return value + if type(value) is float: + if not math.isfinite(value): + raise DispatchValidationError(f"{label} must be finite") + return value + seen = _seen if _seen is not None else set() + identity = id(value) + if identity in seen: + raise DispatchValidationError(f"{label} contains a recursive value") + seen.add(identity) + try: + if type(value) is dict: + normalized: dict[str, object] = {} + for key, item in value.items(): + if type(key) is not str: + raise DispatchValidationError( + f"{label} keys must be strings" + ) + _assert_safe_result_text(key, f"{label}.key") + credential_parent = ( + _credential_parent or is_credential_field_name(key) + ) + normalized[key] = _normalize_safe_result_value( + item, + f"{label}.{key}", + _seen=seen, + _depth=_depth + 1, + _credential_parent=credential_parent, + ) + return normalized + if type(value) in {list, tuple}: + normalized_items: list[object] = [] + for index, item in enumerate(value): + normalized_items.append( + _normalize_safe_result_value( + item, + f"{label}[{index}]", + _seen=seen, + _depth=_depth + 1, + _credential_parent=_credential_parent, + ) + ) + return normalized_items + finally: + seen.discard(identity) + raise DispatchValidationError(f"{label} contains a non-JSON value") + + def result_from_mapping(payload: Mapping[str, Any], *, cwd) -> ResultEnvelope: if payload.get("schema_version") != 1: raise DispatchValidationError("result schema_version must be 1") diff --git a/experiments/local_agent_dispatch/run_store.py b/experiments/local_agent_dispatch/run_store.py index 5d9b2dc..eb78965 100644 --- a/experiments/local_agent_dispatch/run_store.py +++ b/experiments/local_agent_dispatch/run_store.py @@ -3,19 +3,30 @@ from __future__ import annotations from dataclasses import dataclass +import errno +import json import math import os from pathlib import Path import secrets import signal +import stat import time from typing import Any, Mapping +from .adapters.registry import ( + execution_profile_sha256, + normalize_execution_profile, +) from .errors import DispatchValidationError from .file_lock import exclusive_file_lock, file_lock_is_held -from .json_store import atomic_write_json, read_json +from .json_store import atomic_write_json from .paths import dispatch_home_path, runs_dir -from .process_identity import process_identity_is_dead, process_started_at +from .process_identity import ( + process_group_has_live_members, + process_identity_is_dead, + process_started_at, +) from .task_contract import TaskContract @@ -26,6 +37,9 @@ {"completed", "failed", "timeout", "cancelled"} ) ASYNC_RESERVATION_GRACE_SECONDS = 10.0 +MAX_CANCEL_REASON_CHARS = 500 +MAX_ORCHESTRATION_ID_CHARS = 256 +MAX_RUN_STATE_BYTES = 2 * 1024 * 1024 _POSIX_PROCESS_GROUPS = ( os.name == "posix" and hasattr(os, "getpgid") @@ -57,6 +71,13 @@ class RunRecord: backend_pgid: int = 0 backend_started_at: str = "" backend_lock_path: str = "" + cancel_requested_at: float = 0.0 + cancel_reason: str = "" + orchestration_id: str = "" + planned_context_sha256: str = "" + planned_base_head: str = "" + planned_execution_profile_sha256: str = "" + planned_execution_profile: dict[str, str] | None = None def to_mapping(self) -> dict[str, object]: return { @@ -82,6 +103,17 @@ def to_mapping(self) -> dict[str, object]: "backend_pgid": self.backend_pgid, "backend_started_at": self.backend_started_at, "backend_lock_path": self.backend_lock_path, + "cancel_requested_at": self.cancel_requested_at, + "cancel_reason": self.cancel_reason, + "orchestration_id": self.orchestration_id, + "planned_context_sha256": self.planned_context_sha256, + "planned_base_head": self.planned_base_head, + "planned_execution_profile_sha256": ( + self.planned_execution_profile_sha256 + ), + "planned_execution_profile": dict( + self.planned_execution_profile or {} + ), } @classmethod @@ -146,6 +178,105 @@ def from_mapping(cls, payload: Mapping[str, Any]) -> RunRecord: raise DispatchValidationError( "run backend must lead its dedicated process group" ) + cancel_requested_at = payload.get("cancel_requested_at", 0.0) + if ( + isinstance(cancel_requested_at, bool) + or not isinstance(cancel_requested_at, (int, float)) + or not math.isfinite(float(cancel_requested_at)) + or cancel_requested_at < 0 + ): + raise DispatchValidationError( + "run.cancel_requested_at must be a finite non-negative number" + ) + cancel_reason = payload.get("cancel_reason", "") + if type(cancel_reason) is not str: + raise DispatchValidationError("run.cancel_reason must be a string") + if len(cancel_reason) > MAX_CANCEL_REASON_CHARS: + raise DispatchValidationError( + "run.cancel_reason exceeds the character limit" + ) + if not cancel_requested_at and cancel_reason: + raise DispatchValidationError( + "run.cancel_reason requires cancel_requested_at" + ) + orchestration_id = payload.get("orchestration_id", "") + if type(orchestration_id) is not str: + raise DispatchValidationError("run.orchestration_id must be a string") + if len(orchestration_id) > MAX_ORCHESTRATION_ID_CHARS: + raise DispatchValidationError( + "run.orchestration_id exceeds the character limit" + ) + planned_context_sha256 = payload.get("planned_context_sha256", "") + if type(planned_context_sha256) is not str or ( + planned_context_sha256 + and ( + len(planned_context_sha256) != 64 + or any( + char not in "0123456789abcdef" + for char in planned_context_sha256 + ) + ) + ): + raise DispatchValidationError( + "run.planned_context_sha256 must be empty or a lowercase SHA-256 digest" + ) + planned_base_head = payload.get("planned_base_head", "") + if type(planned_base_head) is not str or ( + planned_base_head + and ( + len(planned_base_head) not in {40, 64} + or any(char not in "0123456789abcdef" for char in planned_base_head) + ) + ): + raise DispatchValidationError( + "run.planned_base_head must be empty or a lowercase Git object ID" + ) + if planned_base_head and ( + not planned_context_sha256 or contract.get("mode") != "edit" + ): + raise DispatchValidationError( + "run.planned_base_head requires edit mode and a planned context digest" + ) + planned_execution_profile_sha256 = payload.get( + "planned_execution_profile_sha256", "" + ) + if type(planned_execution_profile_sha256) is not str or ( + planned_execution_profile_sha256 + and ( + len(planned_execution_profile_sha256) != 64 + or any( + char not in "0123456789abcdef" + for char in planned_execution_profile_sha256 + ) + ) + ): + raise DispatchValidationError( + "run.planned_execution_profile_sha256 must be empty or a " + "lowercase SHA-256 digest" + ) + raw_execution_profile = payload.get("planned_execution_profile", {}) + if not isinstance(raw_execution_profile, Mapping): + raise DispatchValidationError( + "run.planned_execution_profile must be an object" + ) + planned_execution_profile = ( + normalize_execution_profile( + raw_execution_profile, + backend=str(payload.get("backend") or ""), + ) + if raw_execution_profile + else {} + ) + if bool(planned_execution_profile) != bool( + planned_execution_profile_sha256 + ) or ( + planned_execution_profile + and execution_profile_sha256(planned_execution_profile) + != planned_execution_profile_sha256 + ): + raise DispatchValidationError( + "run planned execution profile digest does not match its profile" + ) return cls( run_id=str(payload["run_id"]), status=status, @@ -168,6 +299,13 @@ def from_mapping(cls, payload: Mapping[str, Any]) -> RunRecord: backend_pgid=backend_pgid, backend_started_at=backend_started_at, backend_lock_path=backend_lock_path, + cancel_requested_at=float(cancel_requested_at), + cancel_reason=cancel_reason, + orchestration_id=orchestration_id, + planned_context_sha256=planned_context_sha256, + planned_base_head=planned_base_head, + planned_execution_profile_sha256=planned_execution_profile_sha256, + planned_execution_profile=planned_execution_profile, ) @@ -197,6 +335,10 @@ def create( backend: str, panel_id: str = "", thread_id: str = "", + planned_context_sha256: str = "", + planned_base_head: str = "", + planned_execution_profile_sha256: str = "", + planned_execution_profile: Mapping[str, str] | None = None, ) -> RunRecord: now = time.time() run_id = f"run-{secrets.token_hex(8)}" @@ -210,10 +352,176 @@ def create( updated_at=now, panel_id=panel_id, thread_id=thread_id or run_id, + planned_context_sha256=planned_context_sha256, + planned_base_head=planned_base_head, + planned_execution_profile_sha256=( + planned_execution_profile_sha256 + ), + planned_execution_profile=dict(planned_execution_profile or {}), ) self.save(record) return record + def ensure_created( + self, + *, + run_id: str, + contract: TaskContract, + project_root: Path, + backend: str, + orchestration_id: str, + thread_id: str, + panel_id: str = "", + planned_context_sha256: str = "", + planned_base_head: str = "", + planned_execution_profile_sha256: str = "", + planned_execution_profile: Mapping[str, str] | None = None, + ) -> RunRecord: + """Create one deterministic run, or verify an identical prior create.""" + if type(orchestration_id) is not str or not orchestration_id: + raise DispatchValidationError("orchestration_id must not be empty") + if len(orchestration_id) > MAX_ORCHESTRATION_ID_CHARS: + raise DispatchValidationError( + "orchestration_id exceeds the character limit" + ) + if ( + type(planned_context_sha256) is not str + or ( + planned_context_sha256 + and ( + len(planned_context_sha256) != 64 + or any( + char not in "0123456789abcdef" + for char in planned_context_sha256 + ) + ) + ) + ): + raise DispatchValidationError( + "planned_context_sha256 must be empty or a lowercase SHA-256 digest" + ) + if ( + type(planned_base_head) is not str + or ( + planned_base_head + and ( + len(planned_base_head) not in {40, 64} + or any( + char not in "0123456789abcdef" + for char in planned_base_head + ) + ) + ) + ): + raise DispatchValidationError( + "planned_base_head must be empty or a lowercase Git object ID" + ) + if planned_base_head and ( + not planned_context_sha256 or contract.mode != "edit" + ): + raise DispatchValidationError( + "planned_base_head requires edit mode and a planned context digest" + ) + if ( + type(planned_execution_profile_sha256) is not str + or ( + planned_execution_profile_sha256 + and ( + len(planned_execution_profile_sha256) != 64 + or any( + char not in "0123456789abcdef" + for char in planned_execution_profile_sha256 + ) + ) + ) + ): + raise DispatchValidationError( + "planned_execution_profile_sha256 must be empty or a " + "lowercase SHA-256 digest" + ) + normalized_execution_profile = ( + normalize_execution_profile( + planned_execution_profile, + backend=backend, + ) + if planned_execution_profile + else {} + ) + if bool(normalized_execution_profile) != bool( + planned_execution_profile_sha256 + ) or ( + normalized_execution_profile + and execution_profile_sha256(normalized_execution_profile) + != planned_execution_profile_sha256 + ): + raise DispatchValidationError( + "planned execution profile digest does not match its profile" + ) + expected_contract = contract.to_mapping() + expected_project_root = str(Path(project_root).resolve()) + expected = { + "contract": expected_contract, + "project_root": expected_project_root, + "backend": backend, + "orchestration_id": orchestration_id, + "thread_id": thread_id, + "panel_id": panel_id, + "planned_context_sha256": planned_context_sha256, + "planned_base_head": planned_base_head, + "planned_execution_profile_sha256": ( + planned_execution_profile_sha256 + ), + "planned_execution_profile": normalized_execution_profile, + } + path = self._path(run_id) + with exclusive_file_lock(self._lock_path(run_id)): + if path.exists() or path.is_symlink(): + record = self.load(run_id) + actual = { + "contract": record.contract, + "project_root": record.project_root, + "backend": record.backend, + "orchestration_id": record.orchestration_id, + "thread_id": record.thread_id, + "panel_id": record.panel_id, + "planned_context_sha256": record.planned_context_sha256, + "planned_base_head": record.planned_base_head, + "planned_execution_profile_sha256": ( + record.planned_execution_profile_sha256 + ), + "planned_execution_profile": dict( + record.planned_execution_profile or {} + ), + } + if actual != expected: + raise DispatchValidationError( + "existing run conflicts with deterministic create: " + f"{run_id}" + ) + return record + + now = time.time() + record = RunRecord( + run_id=run_id, + status="accepted", + contract=expected_contract, + project_root=expected_project_root, + backend=backend, + created_at=now, + updated_at=now, + panel_id=panel_id, + thread_id=thread_id, + orchestration_id=orchestration_id, + planned_context_sha256=planned_context_sha256, + planned_base_head=planned_base_head, + planned_execution_profile_sha256=( + planned_execution_profile_sha256 + ), + planned_execution_profile=normalized_execution_profile, + ) + self._save_unlocked(record) + return record + def save(self, record: RunRecord) -> None: with exclusive_file_lock(self._lock_path(record.run_id)): self._save_unlocked(record) @@ -222,27 +530,87 @@ def _save_unlocked(self, record: RunRecord) -> None: if record.status not in RUN_STATUSES: raise DispatchValidationError(f"invalid run status: {record.status}") record.updated_at = time.time() - atomic_write_json(self._path(record.run_id), record.to_mapping()) + payload = record.to_mapping() + RunRecord.from_mapping(payload) + encoded = ( + json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False) + + "\n" + ).encode("utf-8") + if len(encoded) > MAX_RUN_STATE_BYTES: + raise DispatchValidationError( + f"run state exceeds {MAX_RUN_STATE_BYTES} bytes" + ) + atomic_write_json(self._path(record.run_id), payload) + + def _read_payload(self, path: Path) -> dict[str, Any]: + flags = ( + os.O_RDONLY + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NONBLOCK", 0) + ) + flags |= getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(path, flags) + except FileNotFoundError as exc: + raise DispatchValidationError( + f"run not found: {path.stem}" + ) from exc + except OSError as exc: + if exc.errno in {errno.ELOOP, errno.EMLINK} or path.is_symlink(): + raise DispatchValidationError( + f"run state is a symbolic link: {path.stem}" + ) from exc + raise DispatchValidationError( + f"run state cannot be opened safely: {path.stem}" + ) from exc + try: + opened = os.fstat(descriptor) + if not stat.S_ISREG(opened.st_mode): + raise DispatchValidationError( + f"run state is not a regular file: {path.stem}" + ) + if opened.st_size > MAX_RUN_STATE_BYTES: + raise DispatchValidationError( + f"run state exceeds {MAX_RUN_STATE_BYTES} bytes" + ) + linked = os.stat(path, follow_symlinks=False) + if stat.S_ISLNK(linked.st_mode) or not os.path.samestat(opened, linked): + raise DispatchValidationError( + f"run state path changed while opening: {path.stem}" + ) + with os.fdopen(descriptor, "rb", closefd=False) as handle: + raw = handle.read(MAX_RUN_STATE_BYTES + 1) + if len(raw) > MAX_RUN_STATE_BYTES: + raise DispatchValidationError( + f"run state exceeds {MAX_RUN_STATE_BYTES} bytes" + ) + except FileNotFoundError as exc: + raise DispatchValidationError( + f"run state path changed while opening: {path.stem}" + ) from exc + finally: + os.close(descriptor) + try: + payload = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise DispatchValidationError( + f"run state is corrupt: {path.stem}" + ) from exc + if not isinstance(payload, dict): + raise DispatchValidationError( + f"run state must be an object: {path.stem}" + ) + return payload def load(self, run_id: str) -> RunRecord: path = self._path(run_id) - if path.is_symlink(): - raise DispatchValidationError(f"run state is a symbolic link: {run_id}") - payload = read_json(path) - if payload is None: - raise DispatchValidationError(f"run not found: {run_id}") - return RunRecord.from_mapping(payload) + return RunRecord.from_mapping(self._read_payload(path)) def list_runs(self) -> list[RunRecord]: items: list[RunRecord] = [] for path in sorted(self.root.glob("run-*.json")): - if path.is_symlink(): - continue - payload = read_json(path) - if payload is None: - continue try: - items.append(RunRecord.from_mapping(payload)) + items.append(self.load(path.stem)) except DispatchValidationError: continue return items @@ -283,6 +651,10 @@ def update_status( "run terminal transition rejected: backend cleanup " "is not proven" ) + if record.cancel_requested_at and status != "cancelled": + raise DispatchValidationError( + "run terminal transition rejected: cancellation requested" + ) record.status = status if error: record.error = error @@ -296,6 +668,54 @@ def update_status( self._save_unlocked(record) return record + def request_cancel( + self, + run_id: str, + *, + reason: str = "cancel requested", + ) -> RunRecord: + """Persist an idempotent cooperative cancellation request.""" + if type(reason) is not str: + raise DispatchValidationError("cancel reason must be a string") + if len(reason) > MAX_CANCEL_REASON_CHARS: + raise DispatchValidationError( + "cancel reason exceeds the character limit" + ) + with exclusive_file_lock(self._lock_path(run_id)): + record = self.load(run_id) + if record.status in TERMINAL_RUN_STATUSES: + return record + if record.cancel_requested_at: + return record + requested_at = time.time() + if not math.isfinite(requested_at) or requested_at <= 0: + raise DispatchValidationError( + "cancel request time must be finite and positive" + ) + record.cancel_requested_at = requested_at + record.cancel_reason = reason + if record.status == "accepted": + record.status = "cancelled" + record.revision += 1 + self._save_unlocked(record) + return record + + def cancel_requested( + self, + run_id: str, + *, + worker_token: str, + ) -> bool: + """Return cancellation only to the exact active worker generation.""" + if not worker_token: + raise DispatchValidationError("worker token must not be empty") + record = self.load(run_id) + return bool( + record.status == "running" + and record.worker_token == worker_token + and record.cancel_requested_at + ) + def reserve_async_worker( self, run_id: str, @@ -433,20 +853,6 @@ def bind_backend_process( self._save_unlocked(record) return record - @staticmethod - def _process_group_exists(process_group_id: int) -> bool: - if not _POSIX_PROCESS_GROUPS: - raise DispatchValidationError( - "backend cleanup requires POSIX process groups" - ) - try: - os.killpg(process_group_id, 0) - except ProcessLookupError: - return False - except (PermissionError, OSError): - return True - return True - def _backend_cleanup_proven(self, record: RunRecord) -> bool: if record.backend_pid <= 0: return True @@ -466,8 +872,10 @@ def _backend_cleanup_proven(self, record: RunRecord) -> bool: ): return False lock_held = file_lock_is_held(expected_lock) - group_exists = self._process_group_exists(record.backend_pgid) - if lock_held is False and not group_exists: + group_has_live_members = process_group_has_live_members( + record.backend_pgid + ) + if lock_held is False and group_has_live_members is False: return True if lock_held is not True: return False @@ -488,31 +896,43 @@ def _backend_cleanup_proven(self, record: RunRecord) -> bool: try: os.killpg(record.backend_pgid, signal.SIGTERM) except ProcessLookupError: - pass + term_delivered = False except OSError: return False + else: + term_delivered = True deadline = time.monotonic() + 0.5 while time.monotonic() < deadline: - if file_lock_is_held(expected_lock) is False: - break + lock_held = file_lock_is_held(expected_lock) + group_has_live_members = process_group_has_live_members( + record.backend_pgid + ) + if lock_held is False and group_has_live_members is False: + return True + if lock_held is None or group_has_live_members is None: + return False time.sleep(0.02) - try: - os.killpg(record.backend_pgid, signal.SIGKILL) - except ProcessLookupError: - pass - except OSError: - return False + # The trusted wrapper keeps the lifetime lock held for longer than the + # TERM grace window. Only escalate while that generation anchor is + # still present; after release, a bare PGID is never safe to signal. + if term_delivered and file_lock_is_held(expected_lock) is True: + try: + os.killpg(record.backend_pgid, signal.SIGKILL) + except ProcessLookupError: + pass + except OSError: + return False deadline = time.monotonic() + 1.0 while time.monotonic() < deadline: if ( file_lock_is_held(expected_lock) is False - and not self._process_group_exists(record.backend_pgid) + and process_group_has_live_members(record.backend_pgid) is False ): return True time.sleep(0.02) return ( file_lock_is_held(expected_lock) is False - and not self._process_group_exists(record.backend_pgid) + and process_group_has_live_members(record.backend_pgid) is False ) def cleanup_backend_if_owned( @@ -625,8 +1045,11 @@ def fail_if_accepted(self, run_id: str, *, error: str) -> RunRecord: record = self.load(run_id) if record.status != "accepted" or record.worker_token: return record - record.status = "failed" - record.error = error + if record.cancel_requested_at: + record.status = "cancelled" + else: + record.status = "failed" + record.error = error record.revision += 1 self._save_unlocked(record) return record @@ -647,8 +1070,11 @@ def fail_if_running( or record.backend_pid ): return record - record.status = "failed" - record.error = error + if record.cancel_requested_at: + record.status = "cancelled" + else: + record.status = "failed" + record.error = error record.revision += 1 self._save_unlocked(record) return record @@ -669,8 +1095,11 @@ def fail_if_active_worker( or (record.status == "running" and record.backend_pid) ): return record - record.status = "failed" - record.error = error + if record.cancel_requested_at: + record.status = "cancelled" + else: + record.status = "failed" + record.error = error record.revision += 1 self._save_unlocked(record) return record diff --git a/experiments/local_agent_dispatch/skill_render.py b/experiments/local_agent_dispatch/skill_render.py index 3ee2eff..e78d355 100644 --- a/experiments/local_agent_dispatch/skill_render.py +++ b/experiments/local_agent_dispatch/skill_render.py @@ -64,8 +64,9 @@ def render_skill_markdown( *, home: Path | None = None, routes: Sequence[Mapping[str, str]] | None = None, + passive: bool = False, ) -> str: - backends = probe_backends() + backends = probe_backends(passive=passive) providers = [b for b in backends if b.get("execution_kind") == "provider"] available = [ b @@ -80,7 +81,7 @@ def render_skill_markdown( lines = [ "---", - "name: dyro-local-agent-dispatch", + "name: dyro-dispatch", "description: >-", " Dispatch read-only or isolated-edit tasks to local agent CLIs via", " `python -m experiments.local_agent_dispatch`. Only backends listed", @@ -97,7 +98,10 @@ def render_skill_markdown( ] if available: for row in available: - lines.append(f"- `{row['id']}` (command: `{row['command']}`)") + mode_note = "; read-only only" if row["id"] == "cursor-agent" else "" + lines.append( + f"- `{row['id']}` (command: `{row['command']}`{mode_note})" + ) else: lines.append("- none ready; configure and authenticate an integrated Provider") @@ -107,6 +111,11 @@ def render_skill_markdown( lines.append("") for row in unavailable: state = "not found" if not row["available"] else "not authenticated" + if row.get("authentication_probe") == "not_run" and row["available"]: + state = "authentication not probed" + reason = str(row.get("reason") or "").strip() + if reason: + state = f"{state}; {reason}" lines.append(f"- `{row['id']}` (`{row['command']}`: {state})") if discovery_only: diff --git a/experiments/local_agent_dispatch/supervisor.py b/experiments/local_agent_dispatch/supervisor.py index e1579c0..4a7e7a4 100644 --- a/experiments/local_agent_dispatch/supervisor.py +++ b/experiments/local_agent_dispatch/supervisor.py @@ -8,6 +8,7 @@ from pathlib import Path import secrets import signal +import shutil import subprocess import sys import threading @@ -15,14 +16,18 @@ from typing import Callable, Mapping from .adapters.registry import ( + adapter_execution_profile, + adapter_execution_profile_sha256, adapter_is_authenticated, + execution_profile_sha256, get_adapter, list_real_provider_ids, ) from .context_guard import materialize_strict_shadow, safe_error_text from .edit_workspace import EditWorkspace +from .edit_workspace import review_edit_snapshot from .errors import DispatchValidationError -from .fileset import collect_guarded_context +from .fileset import collect_guarded_context, guarded_context_sha256 from .lease import LeaseHeartbeat, SlotManager from .paths import dispatch_home, runs_dir, shadow_dir from .process_identity import current_identity @@ -51,20 +56,6 @@ "NO_PROXY", } ) -_WORKER_BACKEND_ENV = { - "codex": frozenset({"CODEX_HOME"}), - "claude": frozenset( - { - "ANTHROPIC_API_KEY", - "ANTHROPIC_AUTH_TOKEN", - "CLAUDE_CODE_OAUTH_TOKEN", - "CLAUDE_CONFIG_DIR", - "XDG_CONFIG_HOME", - } - ), -} - - def _require_posix_supervision() -> None: if os.name != "posix": raise DispatchValidationError( @@ -81,6 +72,10 @@ class _WorkerOutcome: shadow_path: str = "" +class _CooperativeCancellation(Exception): + """Internal control flow after the exact worker observes cancellation.""" + + def _validate_timeout(timeout_seconds: float) -> None: if ( isinstance(timeout_seconds, bool) @@ -91,26 +86,150 @@ def _validate_timeout(timeout_seconds: float) -> None: raise DispatchValidationError("timeout_seconds must be finite and positive") -def _worker_environment(*, backend: str, home: Path) -> dict[str, str]: - allowed = _WORKER_COMMON_ENV | _WORKER_BACKEND_ENV.get( - backend, - frozenset(), - ) - environment = { - name: value - for name, value in os.environ.items() - if name in allowed and value - } +def _worker_environment( + *, + backend: str, + home: Path, + run_id: str, + expected_execution_profile_sha256: str, + expected_execution_profile: Mapping[str, str], +) -> dict[str, str]: + if backend in list_real_provider_ids(): + adapter = get_adapter(backend) + current_profile = adapter_execution_profile(adapter) + if ( + not expected_execution_profile_sha256 + or current_profile != dict(expected_execution_profile) + or execution_profile_sha256(current_profile) + != expected_execution_profile_sha256 + ): + raise DispatchValidationError( + "backend execution profile changed before async worker start" + ) + configure_profile = getattr(adapter, "configure_execution_profile", None) + if callable(configure_profile): + configure_profile(expected_execution_profile) + build_environment = getattr(adapter, "worker_environment", None) + if not callable(build_environment): + raise DispatchValidationError( + f"backend cannot build a safe async worker environment: {backend}" + ) + isolated_home = _async_worker_profile_home( + home=home, + run_id=run_id, + backend=backend, + ) + try: + environment = build_environment(isolated_home=isolated_home) + except Exception: + if isolated_home is not None: + _cleanup_async_worker_profile_home(isolated_home, home=home) + raise + if not isinstance(environment, dict) or any( + type(name) is not str or type(value) is not str + for name, value in environment.items() + ): + if isolated_home is not None: + _cleanup_async_worker_profile_home(isolated_home, home=home) + raise DispatchValidationError( + f"backend returned an invalid async worker environment: {backend}" + ) + environment["DYRO_DISPATCH_PROFILE_BACKEND"] = backend + environment["DYRO_DISPATCH_PROFILE_PROVIDER"] = ( + expected_execution_profile["provider"] + ) + environment["DYRO_DISPATCH_PROFILE_MODEL"] = ( + expected_execution_profile["model"] + ) + else: + if backend == "echo" and ( + not expected_execution_profile_sha256 + or adapter_execution_profile_sha256(get_adapter(backend)) + != expected_execution_profile_sha256 + ): + raise DispatchValidationError( + "backend execution profile changed before async worker start" + ) + environment = { + name: value + for name, value in os.environ.items() + if name in _WORKER_COMMON_ENV and value + } environment["DYRO_LOCAL_AGENT_DISPATCH_HOME"] = str(home) return environment +def _async_worker_profile_home( + *, home: Path, run_id: str, backend: str +) -> Path | None: + if backend not in list_real_provider_ids(): + return None + if not run_id.startswith("run-") or "/" in run_id or ".." in run_id: + raise DispatchValidationError("invalid run_id for async worker profile") + return runs_dir(home) / f".{run_id}.{backend}.home" + + +def _cleanup_async_worker_profile_home(path: Path, *, home: Path) -> None: + parent = runs_dir(home).resolve(strict=True) + if not path.exists() and not path.is_symlink(): + return + if path.is_symlink() or not path.is_dir(): + raise DispatchValidationError("async worker profile home is unsafe") + if path.resolve(strict=True).parent != parent: + raise DispatchValidationError("async worker profile home escapes runs directory") + shutil.rmtree(path) + + +def _validate_orchestration_binding( + record: RunRecord, + *, + home: Path | None, +) -> None: + """Fail closed if mutable run state diverges from its signed plan manifest.""" + if not record.orchestration_id: + return + from .orchestration_store import OrchestrationStore + + manifest = OrchestrationStore(home, create=False).load( + record.orchestration_id + ) + for member, planned in zip( + manifest.members, + manifest.plan.members, + strict=True, + ): + if member.run_id != record.run_id: + continue + expected_base_head = planned.base_head or "" + if ( + record.project_root != str(manifest.plan.project_root) + or record.backend != member.backend + or record.thread_id != member.role_id + or record.contract != planned.normalized_contract + or record.planned_context_sha256 != planned.context_sha256 + or record.planned_base_head != expected_base_head + or record.planned_execution_profile_sha256 + != execution_profile_sha256(planned.execution_profile) + or dict(record.planned_execution_profile or {}) + != dict(planned.execution_profile) + ): + raise DispatchValidationError( + "batch run state does not match its orchestration plan" + ) + return + raise DispatchValidationError( + "batch run is not a member of its orchestration plan" + ) + + def _reap_async_worker( process: subprocess.Popen[bytes], *, store: RunStore, run_id: str, worker_token: str, + worker_profile_home: Path | None = None, + home: Path | None = None, ) -> None: try: return_code = process.wait() @@ -137,8 +256,12 @@ def _reap_async_worker( ), ) finally: - with _ASYNC_WORKERS_LOCK: - _ASYNC_WORKERS.discard(process) + try: + if worker_profile_home is not None and home is not None: + _cleanup_async_worker_profile_home(worker_profile_home, home=home) + finally: + with _ASYNC_WORKERS_LOCK: + _ASYNC_WORKERS.discard(process) def _start_async_reaper( @@ -147,6 +270,8 @@ def _start_async_reaper( store: RunStore, run_id: str, worker_token: str, + worker_profile_home: Path | None = None, + home: Path | None = None, ) -> None: """Start the waiter only after the startup handshake is resolved.""" threading.Thread( @@ -156,6 +281,8 @@ def _start_async_reaper( "store": store, "run_id": run_id, "worker_token": worker_token, + "worker_profile_home": worker_profile_home, + "home": home, }, daemon=True, ).start() @@ -218,6 +345,31 @@ def _failure_outcome( ) +def _cancelled_outcome( + *, + record: RunRecord, + cwd: Path | None = None, + shadow_path: str = "", + duration_ms: int = 0, +) -> _WorkerOutcome: + return _WorkerOutcome( + status="cancelled", + error="cancelled", + shadow_path=shadow_path, + result=build_result( + run_id=record.run_id, + status="cancelled", + summary="", + cwd=cwd or Path(record.project_root), + evidence=[], + backend=record.backend, + error_code="cancelled", + warnings=["run cancellation was requested"], + usage={"duration_ms": duration_ms}, + ).to_mapping(), + ) + + def refuse_production_actions(flags: Mapping[str, object] | None) -> None: if not flags: return @@ -327,7 +479,7 @@ def accept( ) contract = parse_task_contract(payload) # Fail-closed: expand + guard before accepting. - collect_guarded_context(contract.files, project_root) + context = collect_guarded_context(contract.files, project_root) adapter = _resolve_execution_backend(contract, home=self.home) backend = adapter.id if contract.strict and not getattr(adapter, "strict_isolation", False): @@ -347,11 +499,23 @@ def accept( "real provider access requires allow_unconfined_provider=true; " "files are projected for read-only work but this is not OS-level isolation" ) + planned_base_head = ( + review_edit_snapshot(project_root, tuple(context)) + if contract.mode == "edit" + else "" + ) + execution_profile = adapter_execution_profile(adapter) return self.store.create( contract=contract, project_root=project_root, backend=backend, panel_id=panel_id, + planned_context_sha256=guarded_context_sha256(context), + planned_base_head=planned_base_head, + planned_execution_profile_sha256=execution_profile_sha256( + execution_profile + ), + planned_execution_profile=execution_profile, ) def execute( @@ -464,7 +628,16 @@ def abort_on_lease_failure(_failure: Exception) -> None: ) from operation_error raise backend_cleanup_error - if operation_error is not None: + cancellation_requested = self.store.cancel_requested( + run_id, + worker_token=worker_token, + ) + if cancellation_requested: + outcome = _cancelled_outcome(record=record) + # Backend termination may surface as an adapter or parser error. + # Once cleanup is proven, the persisted cancellation is the cause. + operation_error = None + elif operation_error is not None: error = "worker failed after claim: " + safe_error_text(operation_error) warning = error if cleanup_errors: @@ -477,8 +650,12 @@ def abort_on_lease_failure(_failure: Exception) -> None: error=error, warning=warning, ) - elif cleanup_errors: - if outcome is not None and outcome.status in {"failed", "timeout"}: + if cleanup_errors: + if outcome is not None and outcome.status in { + "failed", + "timeout", + "cancelled", + }: preserved_outcome_error = True result = dict(outcome.result) warnings = list(result.get("warnings") or []) @@ -515,17 +692,35 @@ def abort_on_lease_failure(_failure: Exception) -> None: expected_worker_token=worker_token, ) except Exception as state_exc: - if operation_error is not None: + if self.store.cancel_requested( + run_id, + worker_token=worker_token, + ): + # Close the check/update race: update_status rejects any + # non-cancelled terminal transition once the request wins its + # run lock, then this exact worker retries as cancelled. + outcome = _cancelled_outcome(record=record) + final_record = self.store.update_status( + run_id, + outcome.status, + result=outcome.result, + error=outcome.error, + shadow_path=outcome.shadow_path, + expected_worker_token=worker_token, + ) + operation_error = None + elif operation_error is not None: raise DispatchValidationError( "worker failed and running state could not be " f"terminalized: {state_exc}" ) from operation_error - if cleanup_errors: + elif cleanup_errors: raise DispatchValidationError( "worker lifecycle cleanup failed and run state could " f"not be terminalized: {state_exc}" ) from cleanup_errors[0] - raise + else: + raise if operation_error is not None: raise operation_error @@ -545,6 +740,7 @@ def _fail_async_supervision( run_id: str, worker_token: str, error: Exception, + worker_profile_home: Path | None = None, ) -> RunRecord: """Fail a spawn only after its worker and backend are proven stopped.""" cleanup_errors: list[Exception] = [] @@ -570,6 +766,14 @@ def _fail_async_supervision( ) except Exception as cleanup_exc: # noqa: BLE001 cleanup_errors.append(cleanup_exc) + if worker_profile_home is not None: + try: + _cleanup_async_worker_profile_home( + worker_profile_home, + home=dispatch_home(self.home), + ) + except Exception as cleanup_exc: # noqa: BLE001 + cleanup_errors.append(cleanup_exc) if cleanup_errors: detail = "; ".join(str(item) for item in cleanup_errors) raise DispatchValidationError( @@ -596,12 +800,47 @@ def spawn_worker( ) home = dispatch_home(self.home) log_path = runs_dir(home) / f"{run_id}.worker.log" - environment = _worker_environment(backend=record.backend, home=home) - worker_token = secrets.token_hex(16) - record = self.store.reserve_async_worker( - run_id, - worker_token=worker_token, + worker_profile_home = _async_worker_profile_home( + home=home, + run_id=run_id, + backend=record.backend, ) + worker_token = secrets.token_hex(16) + try: + record = self.store.reserve_async_worker( + run_id, + worker_token=worker_token, + ) + except Exception: + raise + try: + environment = _worker_environment( + backend=record.backend, + home=home, + run_id=run_id, + expected_execution_profile_sha256=( + record.planned_execution_profile_sha256 + ), + expected_execution_profile=dict( + record.planned_execution_profile or {} + ), + ) + except Exception as exc: # noqa: BLE001 - exact reservation owns cleanup + if worker_profile_home is not None: + try: + _cleanup_async_worker_profile_home( + worker_profile_home, + home=home, + ) + except Exception: + pass + return self._fail_async_supervision( + process=None, + run_id=run_id, + worker_token=worker_token, + error=exc, + worker_profile_home=worker_profile_home, + ) package_root = Path(__file__).resolve().parents[2] bootstrap = ( "import runpy,sys;" @@ -657,6 +896,7 @@ def spawn_worker( run_id=run_id, worker_token=worker_token, error=exc, + worker_profile_home=worker_profile_home, ) assert process is not None startup_deadline = time.monotonic() + ASYNC_STARTUP_TIMEOUT_SECONDS @@ -669,6 +909,8 @@ def spawn_worker( store=self.store, run_id=run_id, worker_token=worker_token, + worker_profile_home=worker_profile_home, + home=home, ) except Exception as exc: # noqa: BLE001 return self._fail_async_supervision( @@ -676,6 +918,7 @@ def spawn_worker( run_id=run_id, worker_token=worker_token, error=exc, + worker_profile_home=worker_profile_home, ) return current if process.poll() is not None: @@ -684,6 +927,8 @@ def spawn_worker( store=self.store, run_id=run_id, worker_token=worker_token, + worker_profile_home=worker_profile_home, + home=home, ) return self.store.load(run_id) time.sleep(0.02) @@ -699,6 +944,8 @@ def spawn_worker( store=self.store, run_id=run_id, worker_token=worker_token, + worker_profile_home=worker_profile_home, + home=home, ) except Exception as exc: # noqa: BLE001 return self._fail_async_supervision( @@ -706,11 +953,14 @@ def spawn_worker( run_id=run_id, worker_token=worker_token, error=exc, + worker_profile_home=worker_profile_home, ) return current _terminate_async_worker(process) with _ASYNC_WORKERS_LOCK: _ASYNC_WORKERS.discard(process) + if worker_profile_home is not None: + _cleanup_async_worker_profile_home(worker_profile_home, home=home) return current def _run_worker( @@ -722,18 +972,63 @@ def _run_worker( lease_check: Callable[[], None], ) -> _WorkerOutcome: record = self.store.load(run_id) + _validate_orchestration_binding(record, home=self.home) contract = parse_task_contract(record.contract) project_root = Path(record.project_root) started = time.time() cleanup_warning = "" + shadow_path = "" + + def cancellation_requested() -> bool: + return self.store.cancel_requested( + run_id, + worker_token=worker_token, + ) + + def check_cancellation() -> None: + if cancellation_requested(): + raise _CooperativeCancellation try: lease_check() + check_cancellation() + adapter = get_adapter( + record.backend, + require_strict=contract.strict, + ) context = collect_guarded_context(contract.files, project_root) + if not record.planned_context_sha256: + raise DispatchValidationError( + "run is missing its reviewed context snapshot" + ) + if not record.planned_execution_profile_sha256: + raise DispatchValidationError( + "run is missing its reviewed backend execution profile" + ) + current_execution_profile = adapter_execution_profile(adapter) + if current_execution_profile != dict( + record.planned_execution_profile or {} + ) or execution_profile_sha256( + current_execution_profile + ) != record.planned_execution_profile_sha256: + raise DispatchValidationError( + "backend execution profile changed after run acceptance" + ) + configure_profile = getattr( + adapter, + "configure_execution_profile", + None, + ) + if callable(configure_profile): + configure_profile(record.planned_execution_profile or {}) + if guarded_context_sha256(context) != record.planned_context_sha256: + raise DispatchValidationError( + "guarded context changed after the reviewed batch plan" + ) lease_check() + check_cancellation() work_cwd = project_root - shadow_path = "" edit_workspace: EditWorkspace | None = None use_context_projection = ( contract.strict @@ -755,20 +1050,36 @@ def _run_worker( shadow_path = str(shadow_root) if contract.mode == "edit": + if not record.planned_base_head: + raise DispatchValidationError( + "edit run is missing its reviewed Git HEAD" + ) edit_workspace = EditWorkspace.create( project_root=project_root, home=self.home, run_id=run_id, + base_head=record.planned_base_head, ) work_cwd = edit_workspace.worktree_root - lease_check() + try: + lease_check() + check_cancellation() + except BaseException: + try: + edit_workspace.cleanup() + except Exception as exc: # noqa: BLE001 - preserve cause + cleanup_warning = ( + "edit workspace cleanup could not be completed: " + + safe_error_text(exc) + ) + raise + else: + lease_check() + check_cancellation() envelope_mapping: dict[str, object] try: - adapter = get_adapter( - record.backend, - require_strict=contract.strict, - ) + check_cancellation() if contract.strict and not getattr( adapter, "strict_isolation", @@ -789,6 +1100,7 @@ def bind_backend_process( started_at: str, ) -> None: lease_check() + check_cancellation() self.store.bind_backend_process( run_id, worker_token=worker_token, @@ -797,6 +1109,7 @@ def bind_backend_process( backend_started_at=started_at, ) lease_check() + check_cancellation() configure_tracking( observer=bind_backend_process, @@ -805,7 +1118,17 @@ def bind_backend_process( / f"{run_id}.backend.lifetime" ), ) + configure_cancellation = getattr( + adapter, + "configure_cancellation", + None, + ) + if callable(configure_cancellation): + configure_cancellation( + cancel_check=cancellation_requested, + ) lease_check() + check_cancellation() adapter_result = adapter.run( contract=contract, cwd=work_cwd, @@ -813,6 +1136,7 @@ def bind_backend_process( timeout_seconds=timeout_seconds, ) lease_check() + check_cancellation() patch_ref = ( edit_workspace.seal_patch() if edit_workspace is not None @@ -884,6 +1208,8 @@ def bind_backend_process( envelope_mapping["warnings"] = warnings if adapter_result.status == "timeout": status = "timeout" + elif adapter_result.status == "cancelled": + status = "cancelled" elif adapter_result.status == "ok": status = "completed" else: @@ -895,6 +1221,25 @@ def bind_backend_process( error=adapter_result.error_code, shadow_path=shadow_path, ) + except _CooperativeCancellation: + outcome = _cancelled_outcome( + record=record, + cwd=project_root, + shadow_path=shadow_path, + duration_ms=int((time.time() - started) * 1000), + ) + if cleanup_warning: + result = dict(outcome.result) + warnings = list(result.get("warnings") or []) + warnings.append(cleanup_warning) + result["warnings"] = warnings + outcome = _WorkerOutcome( + status=outcome.status, + result=result, + error=outcome.error, + shadow_path=outcome.shadow_path, + ) + return outcome except Exception as exc: # noqa: BLE001 - persist failure onto run safe_error = safe_error_text(exc) warnings = [safe_error] @@ -919,6 +1264,15 @@ def result(self, run_id: str) -> RunRecord: self.store.reconcile_orphaned_workers(run_ids={run_id}) return self.store.load(run_id) + def cancel( + self, + run_id: str, + *, + reason: str = "cancel requested", + ) -> RunRecord: + """Request cooperative cancellation without signalling an external PID.""" + return self.store.request_cancel(run_id, reason=reason) + def wait( self, run_ids: list[str], diff --git a/pyproject.toml b/pyproject.toml index 57cc117..487378c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "dyro" -version = "0.6.7" +version = "0.6.8" description = "DyroEngineeringFlow: local-first automation and delivery control for multi-repository teams" readme = "README.md" requires-python = ">=3.11" @@ -62,6 +62,8 @@ packages = [ "dyro.integrations" = [ "assets/dyro-control-plane/SKILL.md", "assets/dyro-control-plane/agents/openai.yaml", + "assets/dyro-dispatch/SKILL.md", + "assets/dyro-dispatch/agents/openai.yaml", ] [tool.unittest] diff --git a/src/dyro/cli.py b/src/dyro/cli.py index 4c3c670..990cb27 100644 --- a/src/dyro/cli.py +++ b/src/dyro/cli.py @@ -119,6 +119,7 @@ set_default_workspace, ) from .integrations import ( + INTEGRATION_CHOICES, IntegrationState, install_integration, integration_status, @@ -251,6 +252,12 @@ """ +_MANAGED_SKILL_BUNDLE: tuple[tuple[str, str], ...] = ( + ("skill", "控制面"), + ("dispatch", "Dispatch"), +) + + def _config(args: argparse.Namespace) -> Config: root_arg = getattr(args, "root", None) workspace_arg = getattr(args, "workspace_alias", None) @@ -791,46 +798,76 @@ def _setup_default_workspace( def _setup_skill_preference() -> bool: - """Ask whether to install/sync the control-plane Skill during setup.""" - status = integration_status("skill") - if status.state is IntegrationState.CURRENT: - print(muted("控制面 Skill 已是当前版本;无需在 setup 中重复安装。")) + """Ask whether to install/sync the first-party Skill bundle during setup.""" + statuses = { + integration: integration_status(integration) + for integration, _label in _MANAGED_SKILL_BUNDLE + } + if all( + status.state is IntegrationState.CURRENT for status in statuses.values() + ): + print(muted("Dyro Skills 已是当前版本;无需在 setup 中重复安装。")) return False - if status.state in { - IntegrationState.DRIFTED, - IntegrationState.UNOWNED_CONFLICT, - IntegrationState.STALE_MANIFEST, - IntegrationState.RECOVERY_REQUIRED, - }: + blocked = { + integration: status + for integration, status in statuses.items() + if status.state + in { + IntegrationState.DRIFTED, + IntegrationState.UNOWNED_CONFLICT, + IntegrationState.STALE_MANIFEST, + IntegrationState.RECOVERY_REQUIRED, + } + } + if blocked: + detail = ";".join( + f"{integration}={status.state.value}({status.detail})" + for integration, status in blocked.items() + ) print( warning( - f"控制面 Skill 状态为 {status.state.value}({status.detail});" - "setup 不会自动改写,请先手动处理后再运行 " - "dyro integration install skill。" + f"Dyro Skills 状态需要人工处理:{detail};" + "setup 不会自动改写,请先运行对应的 " + "dyro integration status 。" ) ) return False - hosts = [row.host for row in status.avatars] + hosts = sorted( + { + row.host + for status in statuses.values() + for row in status.avatars + } + ) if hosts: host_text = "、".join(hosts) - prompt = f"控制面 Skill(将挂接到已检测宿主:{host_text})" + prompt = f"Dyro Skills(控制面 + Dispatch;挂接到:{host_text})" option_one = "安装 / 同步到已检测宿主(推荐)" default = "1" else: - prompt = "控制面 Skill(当前未检测到 Agent 宿主目录)" + prompt = "Dyro Skills(当前未检测到 Agent 宿主目录)" option_one = "仍要尝试安装(当前无宿主,预期失败)" default = "2" selected = _ask_setup_choice( prompt, ( ("1", option_one), - ("2", "稍后手动安装(dyro integration install skill)"), + ("2", "稍后手动安装(dyro integration install skill / dispatch)"), ), default=default, ) return selected == "1" +def _skill_status_blocks_automatic_change(status: IntegrationState) -> bool: + return status in { + IntegrationState.DRIFTED, + IntegrationState.UNOWNED_CONFLICT, + IntegrationState.STALE_MANIFEST, + IntegrationState.RECOVERY_REQUIRED, + } + + def _setup_personal_preferences( *, root: Path, @@ -871,27 +908,44 @@ def _render_setup_personal_preferences( else: print(" - 编码工具:" + muted("不设置个人默认")) if preferences.install_skill: - status = integration_status("skill") - if status.state is IntegrationState.ABSENT and not status.avatars: - print(" - Skill:无法安装:未检测到 Agent 宿主目录") + statuses = { + integration: integration_status(integration) + for integration, _label in _MANAGED_SKILL_BUNDLE + } + if all( + status.state is IntegrationState.ABSENT and not status.avatars + for status in statuses.values() + ): + print(" - Skills:无法安装:未检测到 Agent 宿主目录") print( " · 确认后会 soft-fail;请安装宿主后运行 " - "dyro integration install skill" + "dyro integration install skill / dispatch" ) return - plan = sync_managed_skill(yes=False, dry_run=True, allow_first_install=True) - if plan is None: - print(" - Skill:" + muted("已是当前版本")) + plans = [] + for integration, label in _MANAGED_SKILL_BUNDLE: + plan = sync_managed_skill( + integration, + yes=False, + dry_run=True, + allow_first_install=True, + ) + if plan is not None: + plans.append((label, plan)) + if not plans: + print(" - Skills:" + muted("已是当前版本")) else: - print(" - Skill:安装 / 同步控制面 Skill(镜像 + 宿主分身)") - for change in plan.changes: - print(" · " + change) + print(" - Skills:安装 / 同步控制面与 Dispatch(镜像 + 宿主分身)") + for label, plan in plans: + print(f" · {label}") + for change in plan.changes: + print(" · " + change) else: - print(" - Skill:" + muted("稍后手动安装")) + print(" - Skills:" + muted("稍后手动安装")) def _apply_setup_personal_preferences(preferences: SetupPersonalPreferences) -> str: - """Apply personal preferences; return Skill outcome for setup completion.""" + """Apply personal preferences; return Skill-bundle setup outcome.""" if preferences.check_enabled: set_update_enabled(True) set_auto_patch(preferences.auto_patch) @@ -901,22 +955,37 @@ def _apply_setup_personal_preferences(preferences: SetupPersonalPreferences) -> set_default_tool(preferences.default_tool) if not preferences.install_skill: return "skipped" - try: - plan = sync_managed_skill(yes=True, allow_first_install=True) - except DyroError as exc: + plans = [] + failures = [] + for integration, label in _MANAGED_SKILL_BUNDLE: + try: + plan = sync_managed_skill( + integration, + yes=True, + allow_first_install=True, + ) + except DyroError as exc: + failures.append((integration, label, exc)) + continue + if plan is not None: + plans.append((label, plan)) + for integration, label, exc in failures: print( warning( - f"控制面 Skill 未安装成功:{exc};" - "可稍后运行 dyro integration install skill --dry-run 排查。" + f"{label} Skill 未安装成功:{exc};可稍后运行 " + f"dyro integration install {integration} --dry-run 排查。" ) ) + if failures: return "failed" - if plan is None: - print(muted("控制面 Skill 已是当前版本。")) + if not plans: + print(muted("Dyro Skills 已是当前版本。")) return "current" - print(success("已安装 / 同步控制面 Skill。")) - for change in plan.changes: - print(" - " + change) + print(success("已安装 / 同步 Dyro Skills。")) + for label, plan in plans: + print(f" - {label}") + for change in plan.changes: + print(" - " + change) return "success" @@ -1023,18 +1092,18 @@ def _print_setup_completion( tool = preferences.default_tool or "未设置" print(f" - 编码工具:个人默认 {terminal_value(tool)}") if skill_outcome == "success": - print(" - Skill:已安装 / 同步") + print(" - Skills:已安装 / 同步") elif skill_outcome == "current": - print(" - Skill:" + muted("已是当前版本")) + print(" - Skills:" + muted("已是当前版本")) elif skill_outcome == "failed": print( - " - Skill:" + " - Skills:" + warning( - "安装未成功;可稍后运行 dyro integration install skill --dry-run" + "安装未成功;可运行 dyro integration status skill / dispatch 排查" ) ) else: - print(" - Skill:" + muted("未在 setup 中安装")) + print(" - Skills:" + muted("未在 setup 中安装")) print("下一步:" + terminal_value("dyro start")) @@ -1910,6 +1979,7 @@ def cmd_integration_sync(args: argparse.Namespace) -> None: """Upgrade a managed Skill install; never performs a first-time install.""" preview = args.dry_run or not args.yes plan = sync_managed_skill( + args.id, yes=args.yes, dry_run=preview, allow_first_install=False, @@ -3729,7 +3799,7 @@ def build_parser() -> argparse.ArgumentParser: integration_status_parser = integration_sub.add_parser( "status", help="只读检查集成状态" ) - integration_status_parser.add_argument("id", choices=("skill", "codex")) + integration_status_parser.add_argument("id", choices=INTEGRATION_CHOICES) integration_status_parser.add_argument( "--format", choices=("text", "json"), default="text" ) @@ -3744,8 +3814,8 @@ def build_parser() -> argparse.ArgumentParser: ) integration_install_parser.add_argument( "id", - choices=("skill", "codex"), - help="skill 为 canonical id;codex 为兼容别名", + choices=INTEGRATION_CHOICES, + help="skill 为控制面 Skill;dispatch 为多 Harness 派发 Skill;codex 为兼容别名", ) integration_install_parser.add_argument( "--yes", action="store_true", help="确认执行已预览的安装或升级" @@ -3763,8 +3833,8 @@ def build_parser() -> argparse.ArgumentParser: ) integration_sync_parser.add_argument( "id", - choices=("skill", "codex"), - help="skill 为 canonical id;codex 为兼容别名", + choices=INTEGRATION_CHOICES, + help="skill 为控制面 Skill;dispatch 为多 Harness 派发 Skill;codex 为兼容别名", ) integration_sync_parser.add_argument( "--yes", action="store_true", help="确认执行已预览的同步或升级" @@ -3779,7 +3849,7 @@ def build_parser() -> argparse.ArgumentParser: integration_uninstall_parser = integration_sub.add_parser( "uninstall", help="仅卸载仍匹配 ownership manifest 的资产" ) - integration_uninstall_parser.add_argument("id", choices=("skill", "codex")) + integration_uninstall_parser.add_argument("id", choices=INTEGRATION_CHOICES) integration_uninstall_parser.add_argument( "--yes", action="store_true", help="确认卸载仍完整的自有资产" ) @@ -4402,7 +4472,7 @@ def _route_experiment_surface(raw: list[str]) -> tuple[str, list[str]] | None: selected_root = str(get_workspace(global_args.workspace_alias).root) if ( selected_root - and dispatch_command in {"run", "panel"} + and dispatch_command in {"run", "panel", "batch-plan", "batch-start"} and not any( token == "--project" or token.startswith("--project=") for token in forwarded @@ -4481,56 +4551,117 @@ def _fresh_dyro_argv(*cli_args: str) -> list[str]: def _refresh_skill_via_new_cli() -> None: - """Best-effort Skill sync using the freshly installed ``dyro`` entry point.""" - argv = _fresh_dyro_argv("integration", "sync", "skill", "--yes") - print("正在同步已托管的控制面 Skill……") + """Best-effort Skill-bundle sync through the freshly installed CLI.""" + requests = [("sync", "skill")] try: - completed = subprocess.run( - argv, - capture_output=True, - text=True, - timeout=120, - check=False, + control_status = integration_status("skill") + dispatch_status = integration_status("dispatch") + except (DyroError, OSError, ValidationError): + control_status = None + dispatch_status = None + if control_status is not None and control_status.state in { + IntegrationState.CURRENT, + IntegrationState.OUTDATED, + }: + # Existing control-plane ownership is the user's one-time opt-in to the + # first-party Skill bundle. Install a newly shipped companion safely. + requests.append(("install", "dispatch")) + elif dispatch_status is not None and dispatch_status.state in { + IntegrationState.CURRENT, + IntegrationState.OUTDATED, + }: + requests.append(("sync", "dispatch")) + + print("正在同步已托管的 Dyro Skills……") + for action, integration in requests: + argv = _fresh_dyro_argv( + "integration", + action, + integration, + "--yes", ) - except (OSError, subprocess.TimeoutExpired) as exc: - print(warning(f"Skill 同步未完成:{exc};下次启动将重试。")) - return - if completed.returncode != 0: - detail = (completed.stderr or completed.stdout or "").strip() - message = "Skill 同步未完成" - if detail: - message += f":{detail}" - print(warning(message + ";下次启动将重试。")) - return - output = (completed.stdout or "").strip() - if output: - print(output) + try: + completed = subprocess.run( + argv, + capture_output=True, + text=True, + timeout=120, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + print( + warning( + f"{integration} Skill 同步未完成:{exc};下次启动将重试。" + ) + ) + continue + if completed.returncode != 0: + detail = (completed.stderr or completed.stdout or "").strip() + message = f"{integration} Skill 同步未完成" + if detail: + message += f":{detail}" + print(warning(message + ";下次启动将重试。")) + continue + output = (completed.stdout or "").strip() + if output: + print(output) def _maybe_sync_managed_skill() -> None: - """Auto-repair OUTDATED managed Skill installs on interactive launch.""" - try: - status = integration_status("skill") - except (DyroError, OSError, ValidationError): - return - if status.state is not IntegrationState.OUTDATED: - return - print("\n检测到控制面 Skill 可升级,正在自动同步……") - try: - plan = sync_managed_skill(yes=True, allow_first_install=False) - except DyroError as exc: - print( - warning( - f"Skill 自动同步未完成:{exc};" - "可运行 dyro integration sync skill --dry-run 查看详情。" - ) + """Auto-repair the opted-in Skill bundle on interactive launch.""" + statuses = {} + for integration, _label in _MANAGED_SKILL_BUNDLE: + try: + statuses[integration] = integration_status(integration) + except (DyroError, OSError, ValidationError): + continue + control_status = statuses.get("skill") + control_opted_in = ( + control_status is not None + and control_status.state + in {IntegrationState.CURRENT, IntegrationState.OUTDATED} + ) + candidates = [] + for integration, label in _MANAGED_SKILL_BUNDLE: + status = statuses.get(integration) + if status is None or _skill_status_blocks_automatic_change(status.state): + continue + allow_first_install = ( + integration == "dispatch" + and status.state is IntegrationState.ABSENT + and control_opted_in ) + if status.state is IntegrationState.OUTDATED or allow_first_install: + candidates.append((integration, label, allow_first_install)) + if not candidates: return - if plan is None: + + print("\n检测到 Dyro Skills 可自动安装 / 同步,正在处理……") + changed = [] + for integration, label, allow_first_install in candidates: + try: + plan = sync_managed_skill( + integration, + yes=True, + allow_first_install=allow_first_install, + ) + except DyroError as exc: + print( + warning( + f"{label} Skill 自动同步未完成:{exc};可运行 " + f"dyro integration sync {integration} --dry-run 查看详情。" + ) + ) + continue + if plan is not None: + changed.append((label, plan)) + if not changed: return - print(success("控制面 Skill 已同步到当前 Dyro 包。")) - for change in plan.changes: - print(" - " + change) + print(success("Dyro Skills 已同步到当前 Dyro 包。")) + for label, plan in changed: + print(f" - {label}") + for change in plan.changes: + print(" - " + change) def main(argv: list[str] | None = None) -> None: diff --git a/src/dyro/console/_inspect_worker.py b/src/dyro/console/_inspect_worker.py index 20ecdeb..7dfd415 100644 --- a/src/dyro/console/_inspect_worker.py +++ b/src/dyro/console/_inspect_worker.py @@ -25,8 +25,8 @@ _CURSOR_SECRET_ENV = "DYRO_CONSOLE_CURSOR_SECRET" _MAX_WORKERS = 4 -_WORKSPACE_TIMEOUT_SECONDS = 0.75 -_OVERVIEW_TIMEOUT_SECONDS = 5.0 +_WORKSPACE_TIMEOUT_SECONDS = 3.0 +_OVERVIEW_TIMEOUT_SECONDS = 6.0 _WORKER_RESPONSE_LIMIT = 2 * 1024 * 1024 @@ -222,7 +222,7 @@ def _isolated_workspace( ) summaries, warnings = _isolated_summaries( isolated_registry, - total_timeout=_WORKSPACE_TIMEOUT_SECONDS, + total_timeout=_OVERVIEW_TIMEOUT_SECONDS, ) if not summaries: raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") diff --git a/src/dyro/console/assets.py b/src/dyro/console/assets.py index 47bb010..1f62944 100644 --- a/src/dyro/console/assets.py +++ b/src/dyro/console/assets.py @@ -27,8 +27,8 @@ class ConsoleAsset: ), "app.js": ( "text/javascript; charset=utf-8", - "2ece45334c7e51ece5bb13122c7f09575c7664c4b3bfa43451fb01b7cbed3805", - 14011, + "3ce02999da246edc48caceca5a23bc258478e53c333c4bfb151fb06a6bcba7d2", + 15196, ), "styles.css": ( "text/css; charset=utf-8", diff --git a/src/dyro/console/assets/app.js b/src/dyro/console/assets/app.js index e0018d8..c3562a4 100644 --- a/src/dyro/console/assets/app.js +++ b/src/dyro/console/assets/app.js @@ -1,6 +1,6 @@ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/; const TOKEN_KEY = "dyro.console.bearer"; -const state = { bearer: "", etags: new Map(), timer: null, focus: "" }; +const state = { bearer: "", etags: new Map(), timer: null, focus: "", partial: false }; const HEALTH_LABELS = { healthy: "健康", degraded: "需关注", unavailable: "不可用" }; const FRESHNESS_LABELS = { fresh: "新鲜", partial: "部分可用", stale: "待更新" }; const AVAILABILITY_LABELS = { available: "可用", unavailable: "不可用" }; @@ -27,6 +27,17 @@ function count(value) { return Number.isInteger(value) && value >= 0 ? value : 0; } +function workspaceCount(summary, key) { + if (!summary || text(summary.availability) !== "available") return "—"; + const value = summary[key]; + return Number.isInteger(value) && value >= 0 ? String(value) : "—"; +} + +function unavailableWorkspaceCount(workspaces) { + if (!Array.isArray(workspaces)) return 0; + return workspaces.filter((summary) => text(summary.availability) !== "available").length; +} + function displayLabel(value, labels) { const raw = text(value); return labels[raw] || raw || "未提供"; @@ -121,9 +132,10 @@ function priorityWorkspace(workspaces) { .find((summary) => text(summary.recommendation && summary.recommendation.command)); } -function overviewState(attention) { +function overviewState(attention, workspaces) { if (count(attention && attention.repair_required)) return "需要修复"; if (count(attention && attention.needs_user)) return "等待你的处理"; + if (unavailableWorkspaceCount(workspaces)) return "状态不完整"; if (count(attention && attention.ready)) return "有工作可推进"; if (count(attention && attention.waiting)) return "等待外部条件"; if (count(attention && attention.paused)) return "存在已暂停工作"; @@ -206,7 +218,7 @@ function renderWorkspaceCard(summary) { const identity = element("div"); identity.className = "workspace-identity"; const title = element("h3", text(summary.display_name) || text(summary.alias) || "未命名工作区"); - const meta = element("p", `别名:${text(summary.alias)} · 任务 ${count(summary.task_count)} · 活跃目标 ${count(summary.active_objective_count)}`); + const meta = element("p", `别名:${text(summary.alias)} · 仓库 ${workspaceCount(summary, "repository_count")} · 开发线 ${workspaceCount(summary, "line_count")} · 任务 ${workspaceCount(summary, "task_count")} · 活跃目标 ${workspaceCount(summary, "active_objective_count")}`); meta.className = "workspace-meta"; identity.append(title, meta); card.append(identity); @@ -225,11 +237,11 @@ function renderWorkspaceCard(summary) { addBadge(freshness, displayLabel(summary.freshness, FRESHNESS_LABELS)); card.append(freshness); - const tasks = element("div", String(count(summary.task_count))); + const tasks = element("div", workspaceCount(summary, "task_count")); tasks.className = "workspace-count"; tasks.dataset.label = "任务数"; card.append(tasks); - const objectives = element("div", String(count(summary.objective_count))); + const objectives = element("div", workspaceCount(summary, "objective_count")); objectives.className = "workspace-count"; objectives.dataset.label = "目标数"; card.append(objectives); @@ -250,8 +262,13 @@ function renderOverview(payload) { if (!data || !Array.isArray(data.workspaces)) throw new Error("OVERVIEW_UNAVAILABLE"); const total = count(data.total_workspaces); const attention = data.attention_counts || {}; - $("overview-heading").textContent = total ? overviewState(attention) : "尚未登记工作区"; - $("overview-summary").textContent = total ? `已加载 ${total} 个本地工作区;优先使用下一步命令继续已识别的工程工作。` : "尚未登记工作区。可运行 dyro setup、dyro join 或 dyro workspace add。"; + const unavailable = unavailableWorkspaceCount(data.workspaces); + $("overview-heading").textContent = total ? overviewState(attention, data.workspaces) : "尚未登记工作区"; + $("overview-summary").textContent = total + ? unavailable + ? `已登记 ${total} 个本地工作区,其中 ${unavailable} 个暂时不可读取;未知数据不会按 0 展示。` + : `已加载 ${total} 个本地工作区;优先使用下一步命令继续已识别的工程工作。` + : "尚未登记工作区。可运行 dyro setup、dyro join 或 dyro workspace add。"; $("captured-at").textContent = text(payload.captured_at) ? `采样于 ${new Date(text(payload.captured_at)).toLocaleString("zh-CN")}` : ""; renderCounts(data.attention_counts || {}); renderPrimaryAction(data.workspaces); @@ -287,9 +304,10 @@ async function loadWorkspace(alias, silent = false) { definition("别名", text(summary.alias)), definition("健康", displayLabel(summary.health, HEALTH_LABELS)), definition("可用性", displayLabel(summary.availability, AVAILABILITY_LABELS)), - definition("任务总数", String(count(summary.task_count))), - definition("开发线", String(count(summary.line_count))), - definition("目标", String(count(summary.objective_count))), + definition("仓库", workspaceCount(summary, "repository_count")), + definition("任务总数", workspaceCount(summary, "task_count")), + definition("开发线", workspaceCount(summary, "line_count")), + definition("目标", workspaceCount(summary, "objective_count")), ); content.replaceChildren(grid); const command = text(summary.recommendation && summary.recommendation.command); @@ -319,8 +337,14 @@ function showError(error) { async function refresh() { try { const payload = await request("/api/v1/overview?limit=100", "overview"); - if (payload) renderOverview(payload); - setStatus("本地会话已就绪;页面只读。", false); + if (payload) { + renderOverview(payload); + state.partial = Boolean(payload.freshness && payload.freshness.partial); + } + setStatus( + state.partial ? "本地会话已就绪;部分工作区状态未能读取,页面只读。" : "本地会话已就绪;页面只读。", + false, + ); } catch (error) { if (error && error.message === "SESSION_EXPIRED") { expireSession(); diff --git a/src/dyro/console/inspection.py b/src/dyro/console/inspection.py index faa74a4..5ed8a3b 100644 --- a/src/dyro/console/inspection.py +++ b/src/dyro/console/inspection.py @@ -64,7 +64,7 @@ def __init__( self, *, registry_state_home: Path | None = None, - timeout_seconds: float = 5.0, + timeout_seconds: float = 8.0, cursor_secret: bytes | None = None, python_executable: str | None = None, target_root: Path | None = None, diff --git a/src/dyro/home.py b/src/dyro/home.py index 8acaba7..1267529 100644 --- a/src/dyro/home.py +++ b/src/dyro/home.py @@ -34,6 +34,7 @@ load_tool_preferences, tool_definition, tool_definition_for_command, + tool_runtime_issue, ) from .process import git from .terminal import danger, muted, success, title, value, warning @@ -51,7 +52,7 @@ DISCOVERABLE_AGENTS = tuple( - (definition.command, bool(definition.launch)) + (definition.command, definition.profile_preset) for definition in TOOL_DEFINITIONS if definition.interface != "desktop" ) @@ -331,7 +332,10 @@ def _launcher_tool( ) for key, value in definition.environment ) - if installed: + runtime_issue = tool_runtime_issue(definition) if installed else "" + if runtime_issue: + state = ToolState.UNAVAILABLE + elif installed: needs_setup = definition.id == "openclaw" and _openclaw_needs_setup() state = ToolState.NEEDS_SETUP if needs_setup else ToolState.READY if needs_setup: @@ -377,6 +381,9 @@ def home_tools(config: Config, *, workspace: Path) -> list[HomeTool]: configured_commands.add(command) definition = tool_definition_for_command(command) installed = executable_available(executable, cwd=workspace) + runtime_issue = ( + tool_runtime_issue(definition) if definition and installed else "" + ) tools.append( HomeTool( adapter_id, @@ -386,9 +393,9 @@ def home_tools(config: Config, *, workspace: Path) -> list[HomeTool]: (), ( ToolState.READY - if installed + if installed and not runtime_issue else ToolState.INSTALLABLE - if definition and definition.install + if definition and definition.install and not runtime_issue else ToolState.UNAVAILABLE ), ) @@ -482,9 +489,19 @@ def state_cell(state: str, *, installed: bool, configured: bool) -> str: known_commands = {command for command, _ in DISCOVERABLE_AGENTS} for command, integrated in DISCOVERABLE_AGENTS: definition = tool_definition_for_command(command) - installed = shutil.which(command) is not None + detected = shutil.which(command) is not None + runtime_issue = ( + tool_runtime_issue(definition) if definition is not None and detected else "" + ) + installed = detected and not runtime_issue configured_as = ",".join(configured_commands.get(command, ())) - if configured_as and installed: + if runtime_issue and configured_as: + state = f"已配置但不兼容:{configured_as}" + note = runtime_issue + elif runtime_issue: + state = "运行环境不兼容" + note = runtime_issue + elif configured_as and installed: state = f"已配置:{configured_as}" note = "可由当前 Profile 启动" elif configured_as: @@ -504,10 +521,15 @@ def state_cell(state: str, *, installed: bool, configured: bool) -> str: if definition and definition.install else "未安装;暂无内置安装方案" ) - installed_cell = success(f"{'已检测':10}") if installed else muted(f"{'-':10}") + if runtime_issue: + installed_cell = warning(f"{'不兼容':10}") + elif installed: + installed_cell = success(f"{'已检测':10}") + else: + installed_cell = muted(f"{'-':10}") print( f"{value(f'{command:16}')}{installed_cell}" - f"{state_cell(state, installed=installed, configured=bool(configured_as))} " + f"{state_cell(state, installed=detected, configured=bool(configured_as))} " f"{muted(note)}" ) for definition in TOOL_DEFINITIONS: diff --git a/src/dyro/integrations/__init__.py b/src/dyro/integrations/__init__.py index b834db0..f0bf12e 100644 --- a/src/dyro/integrations/__init__.py +++ b/src/dyro/integrations/__init__.py @@ -2,6 +2,7 @@ from .manager import ( AvatarStatus, + INTEGRATION_CHOICES, IntegrationPlan, IntegrationState, IntegrationStatus, @@ -14,6 +15,7 @@ __all__ = [ "AvatarStatus", + "INTEGRATION_CHOICES", "IntegrationPlan", "IntegrationState", "IntegrationStatus", diff --git a/src/dyro/integrations/assets/dyro-control-plane/SKILL.md b/src/dyro/integrations/assets/dyro-control-plane/SKILL.md index 34f1690..feea4e6 100644 --- a/src/dyro/integrations/assets/dyro-control-plane/SKILL.md +++ b/src/dyro/integrations/assets/dyro-control-plane/SKILL.md @@ -16,7 +16,8 @@ When the request already supplies a workspace alias, skip global discovery and u - One safe next step: `dyro --workspace next --format json` - Lines or hotfixes: `dyro --workspace line list [--kind line|hotfix] --format json` - Change Sets: `dyro --workspace changeset list --format json` or `dyro --workspace changeset verify --format json` -- Installed Skill health: `dyro integration status skill --format json` +- Installed control-plane Skill health: `dyro integration status skill --format json` +- Installed dispatch Skill health: `dyro integration status dispatch --format json` - Objective inventory or facts: `dyro --workspace objective list --format json` or `dyro --workspace objective status --format json` - Objective explanation: `dyro --workspace objective explain --format json` - Objective blockers or human attention: `dyro --workspace objective attention --format json` diff --git a/src/dyro/integrations/assets/dyro-dispatch/SKILL.md b/src/dyro/integrations/assets/dyro-dispatch/SKILL.md new file mode 100644 index 0000000..147198d --- /dev/null +++ b/src/dyro/integrations/assets/dyro-dispatch/SKILL.md @@ -0,0 +1,146 @@ +--- +name: dyro-dispatch +description: Plan and dispatch bounded work to multiple local coding-agent harnesses through Dyro, then collect and reconcile their advisory results. Use when the user explicitly asks to parallelize, delegate, obtain independent reviews, compare multiple harnesses, or split a task across agents; use isolated edit mode only when the user also authorizes project changes. +--- + +# Dyro Dispatch + +Treat `dyro dispatch` as an outbound harness, separate from the read-only +`dyro-control-plane` Skill and from Dyro delivery gates. + +## Authorization boundary + +- Start Providers only after the user explicitly requests parallel, delegated, + multi-harness, or independent-agent work. A Skill trigger alone is not consent. +- Treat Provider execution as a local-state, process, and potentially network or + usage-billed effect even when the delegated task is read-only. +- Use `mode=edit` only when the user also authorizes code changes. Edit runs must + remain in detached worktrees and may return only a patch reference. +- Never merge, push, commit, signoff, release, publish, import production + evidence, or represent a dispatch result as a Dyro gate. +- Never enable `echo` as a fallback. It is an explicit offline simulation, not a + Provider conclusion. + +## Workflow + +1. Inspect capability without creating dispatch state: + + ```bash + dyro dispatch --dry-run doctor + dyro dispatch --dry-run backends + ``` + + Dry-run reports passive installation capability with + `authentication_probe=not_run`; it never starts a Provider authentication + CLI and must not be treated as proof of login. Use rows with + `supported=true`, `available=true`, and `execution_kind=provider` only as + planning candidates. Supported Provider IDs are `codex`, `claude`, + `cursor-agent`, `opencode`, `grok`, `hermes`, `kimi`, `dsh`, and `pi`; a + locally installed command may still be not ready. Cursor additionally requires + `CURSOR_API_KEY` so dispatch can isolate its home from user MCP processes. If + no Provider qualifies, stop and report the missing capability. Cursor is + read-only here; do not select it as an edit writer. Kimi binds one selected + Provider/model route; file-backed OAuth copies only that selected token into + a per-run home, while keyring-backed OAuth fails closed. DSH uses the reviewed + `deepseek-official/deepseek-v4-flash` headless route. + +2. Choose the smallest useful strategy: + + - Independent opinions on one question: use `panel`. + - An explicitly requested full-harness comparison: use `panel --members all`; + it selects every ready Provider and executes at most four concurrently. + - Different roles or repository slices: use a persistent Batch V1 request. + It supports two to four independent members and at most one edit writer. + - One delegated task: use one asynchronous `run`. + - Review: prefer a finder plus an adversarial verifier. + - Edit: use exactly one writer for an overlapping repository/fileset, followed + by an independent verifier. + - Keep the default fan-out at two or three runs and never exceed the dispatch + global limit. + +3. Build one self-contained TaskContract per role. Include `schema_version=1`, + an automatically selected backend candidate, `mode`, `strict`, the applicable + acknowledgement flags, a minimal `files` list, and all five task fields: + `briefing`, `locations`, `objective`, `constraints`, and `output_contract`. + Never use `**/*`, inject conversation history, or include credentials and + local secret files. + + A single delegated role keeps the existing asynchronous run lifecycle: + + ```bash + dyro dispatch run --project --stdin + dyro dispatch result + ``` + +4. For multiple different roles, place two to four member contracts in one + Batch request with a unique `request_id`, `strategy=independent`, unique + `role_id` values, and finite `timeout_seconds`. Plan it before creating state: + + ```bash + dyro dispatch batch-plan --project --stdin + ``` + + Review `effects`, resolved Providers, their non-secret execution profiles, + context digests, and any edit `base_head`. Planning creates no dispatch state + and starts no Provider or authentication CLI; it can therefore select an + installed-but-logged-out candidate. Fail closed on an invalid contract, zero + matched files, unavailable/capability-incompatible Provider, missing isolation + acknowledgement, or secret guard finding. Do not weaken the contract to force + acceptance. + +5. Only after that review, start the exact plan by binding its digest: + + ```bash + dyro dispatch batch-start --project --stdin \ + --expect-plan-sha256 + ``` + + At this already-authorized execution boundary, run `dyro dispatch backends` + and require `authenticated=true` for every selected Provider. Start is + idempotent for the same request ID and plan. It also performs active + authentication preflight before creating state. Context, Provider execution + profile, or edit HEAD drift changes the digest and must be replanned. All + members are preflighted and persisted before any Provider starts. + + For a same-task comparison, use `dyro dispatch panel` only after recognizing + that current panels duplicate one contract across Providers and wait for the + whole panel. Do not use `--members all` unless the user explicitly asks for a + full-harness run and accepts its Provider usage/cost. Batch V1 is not a DAG or + an asynchronous `all` queue; do not claim dependencies, automatic retries, or + automatic judging. + +6. Retain the returned `orchestration_id`; use the persistent lifecycle instead + of manually tracking member run IDs: + + ```bash + dyro dispatch batch-status + dyro dispatch batch-result [--wait --timeout 300] + dyro dispatch batch-cancel + ``` + + Do not ingest raw event logs. Preserve `summary`, `confidence`, verified + evidence, warnings, and `patch_ref`. Cancellation is cooperative: a running + member becomes cancelled only after its exact worker generation observes the + request and backend cleanup is proven. An unprovable cleanup remains + `running`/`attention_required`; never report a false cancellation. There is no + retry or resume command in Batch V1. + + GC retains a small request tombstone. Reusing a garbage-collected + `request_id` is rejected so a retry cannot silently start a second billed + batch; use a new request ID only for an intentionally new execution. + + Cancellation and timeout supervise Dyro's dedicated POSIX process group, not + an OS container. A deliberately daemonized child that escapes that group is + outside this guarantee; all shipped real Providers therefore remain marked + unconfined and require explicit acknowledgement. + +7. Verify claims against real artifacts. A verifier must use a different method + from the finder where possible. Preserve disagreements and unknowns; never use + majority vote as approval. + +## Handoff + +Report the selected roles and Providers, run IDs and terminal states, verified +evidence, disagreements, unknowns, and any patch reference. Clearly state that +results are advisory and that normal Dyro review, gates, signoff, merge, and push +boundaries still apply. diff --git a/src/dyro/integrations/assets/dyro-dispatch/agents/openai.yaml b/src/dyro/integrations/assets/dyro-dispatch/agents/openai.yaml new file mode 100644 index 0000000..30f4820 --- /dev/null +++ b/src/dyro/integrations/assets/dyro-dispatch/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Dyro Dispatch" + short_description: "Parallelize work through local agent harnesses" + default_prompt: "Use $dyro-dispatch to plan and dispatch this task across multiple local harnesses." diff --git a/src/dyro/integrations/manager.py b/src/dyro/integrations/manager.py index 9d4bee1..5493168 100644 --- a/src/dyro/integrations/manager.py +++ b/src/dyro/integrations/manager.py @@ -1,4 +1,4 @@ -"""Safe mirror + avatar installation of the Dyro control-plane Skill.""" +"""Safe mirror + avatar installation of Dyro-owned Skills.""" from __future__ import annotations @@ -27,12 +27,47 @@ CANONICAL_INTEGRATION_ID = "skill" LEGACY_INTEGRATION_ID = "codex" SKILL_NAME = "dyro-control-plane" -ASSET_VERSION = 2 +ASSET_VERSION = 3 +DISPATCH_INTEGRATION_ID = "dispatch" +DISPATCH_SKILL_NAME = "dyro-dispatch" +DISPATCH_ASSET_VERSION = 1 MANIFEST_SCHEMA_VERSION = 2 LEGACY_MANIFEST_SCHEMA_VERSION = 1 _SHA256_PREFIX = "sha256:" +@dataclass(frozen=True) +class SkillIntegrationSpec: + integration_id: str + skill_name: str + asset_version: int + aliases: tuple[str, ...] = () + legacy_manifest_id: str | None = None + + +CONTROL_PLANE_SPEC = SkillIntegrationSpec( + integration_id=CANONICAL_INTEGRATION_ID, + skill_name=SKILL_NAME, + asset_version=ASSET_VERSION, + aliases=(LEGACY_INTEGRATION_ID,), + legacy_manifest_id=LEGACY_INTEGRATION_ID, +) +DISPATCH_SPEC = SkillIntegrationSpec( + integration_id=DISPATCH_INTEGRATION_ID, + skill_name=DISPATCH_SKILL_NAME, + asset_version=DISPATCH_ASSET_VERSION, +) +SKILL_INTEGRATIONS: tuple[SkillIntegrationSpec, ...] = ( + CONTROL_PLANE_SPEC, + DISPATCH_SPEC, +) +INTEGRATION_CHOICES: tuple[str, ...] = tuple( + choice + for spec in SKILL_INTEGRATIONS + for choice in (spec.integration_id, *spec.aliases) +) + + class IntegrationState(str, Enum): ABSENT = "absent" CURRENT = "current" @@ -56,6 +91,8 @@ class HostSpec: HostSpec("agents", "AGENTS_HOME", ".agents"), HostSpec("cursor", "CURSOR_HOME", ".cursor"), HostSpec("grok", "GROK_HOME", ".grok"), + HostSpec("pi", "PI_CODING_AGENT_DIR", ".pi/agent"), + HostSpec("dsh", "DSH_HOME", ".dsh"), ) @@ -84,8 +121,16 @@ class IntegrationPlan: changes: tuple[str, ...] -def _asset_root() -> Path: - return Path(__file__).parent / "assets" / SKILL_NAME +def _integration_spec(integration: str) -> SkillIntegrationSpec: + for spec in SKILL_INTEGRATIONS: + if integration == spec.integration_id or integration in spec.aliases: + return spec + raise ValidationError(f"未知 Integration:{integration}") + + +def _asset_root(integration: str = CANONICAL_INTEGRATION_ID) -> Path: + skill_spec = _integration_spec(integration) + return Path(__file__).parent / "assets" / skill_spec.skill_name def _absolute_path(value: Path, label: str) -> Path: @@ -108,32 +153,31 @@ def _user_home() -> Path: return Path.home() -def _normalize_integration(integration: str) -> str: - if integration in {CANONICAL_INTEGRATION_ID, LEGACY_INTEGRATION_ID}: - return CANONICAL_INTEGRATION_ID - raise ValidationError(f"未知 Integration:{integration}") - - def _avatar_kind() -> str: return "junction" if os.name == "nt" else "symlink" -def _mirror_path(dyro_home: Path | None) -> Path: +def _mirror_path(dyro_home: Path | None, spec: SkillIntegrationSpec) -> Path: home = _dyro_home(dyro_home) - mirror = home / "skills" / SKILL_NAME - if mirror.parent.parent != home or mirror.name != SKILL_NAME: + mirror = home / "skills" / spec.skill_name + if mirror.parent.parent != home or mirror.name != spec.skill_name: raise ValidationError("Skill 镜像路径越界") return mirror -def _state_paths(dyro_home: Path | None) -> tuple[Path, Path, Path, Path]: +def _state_paths( + dyro_home: Path | None, + spec: SkillIntegrationSpec, +) -> tuple[Path, Path, Path, Path | None]: home = _dyro_home(dyro_home) state_dir = home / "integrations" return ( - state_dir / f"{CANONICAL_INTEGRATION_ID}.json", - state_dir / f"{CANONICAL_INTEGRATION_ID}.transaction.json", - state_dir / f"{CANONICAL_INTEGRATION_ID}.lock", - state_dir / f"{LEGACY_INTEGRATION_ID}.json", + state_dir / f"{spec.integration_id}.json", + state_dir / f"{spec.integration_id}.transaction.json", + state_dir / f"{spec.integration_id}.lock", + state_dir / f"{spec.legacy_manifest_id}.json" + if spec.legacy_manifest_id is not None + else None, ) @@ -154,9 +198,9 @@ def _host_home( return None -def _avatar_path(host_home: Path) -> Path: - target = host_home / "skills" / SKILL_NAME - if target.parent.parent != host_home or target.name != SKILL_NAME: +def _avatar_path(host_home: Path, spec: SkillIntegrationSpec) -> Path: + target = host_home / "skills" / spec.skill_name + if target.parent.parent != host_home or target.name != spec.skill_name: raise ValidationError("Skill 分身路径越界") return target @@ -256,8 +300,10 @@ def walk(directory_fd: int, relative_parent: Path) -> None: return files -def _asset_inventory() -> dict[str, str]: - return _inventory(_asset_root()) +def _asset_inventory( + integration: str = CANONICAL_INTEGRATION_ID, +) -> dict[str, str]: + return _inventory(_asset_root(integration)) def _asset_digest(files: Mapping[str, str]) -> str: @@ -377,14 +423,15 @@ def _remove_avatar_link(avatar: Path) -> None: def _manifest_payload( + spec: SkillIntegrationSpec, mirror: Path, files: Mapping[str, str], avatars: Mapping[str, Mapping[str, str]], ) -> dict[str, object]: return { "schema_version": MANIFEST_SCHEMA_VERSION, - "integration": CANONICAL_INTEGRATION_ID, - "asset_version": ASSET_VERSION, + "integration": spec.integration_id, + "asset_version": spec.asset_version, "asset_digest": _asset_digest(files), "mirror": str(mirror), "files": dict(sorted(files.items())), @@ -416,7 +463,10 @@ def _validate_file_map(files: object) -> dict[str, str]: def _parse_manifest( - path: Path, *, read_budget: ReadBudget | None = None + path: Path, + spec: SkillIntegrationSpec = CONTROL_PLANE_SPEC, + *, + read_budget: ReadBudget | None = None, ) -> dict[str, object]: try: content = ( @@ -448,7 +498,7 @@ def _parse_manifest( } if set(raw) != expected: raise ValidationError("Integration ownership manifest 字段不匹配") - if raw["integration"] != CANONICAL_INTEGRATION_ID: + if raw["integration"] != spec.integration_id: raise ValidationError("Integration ownership manifest 主体不匹配") if not isinstance(raw["asset_version"], int) or raw["asset_version"] < 1: raise ValidationError("Integration ownership manifest asset version 无效") @@ -476,7 +526,10 @@ def _parse_manifest( def _parse_legacy_manifest( - path: Path, *, read_budget: ReadBudget | None = None + path: Path, + spec: SkillIntegrationSpec = CONTROL_PLANE_SPEC, + *, + read_budget: ReadBudget | None = None, ) -> dict[str, object]: try: content = ( @@ -507,7 +560,10 @@ def _parse_legacy_manifest( raise ValidationError("Legacy Integration ownership manifest 字段不匹配") if raw["schema_version"] != LEGACY_MANIFEST_SCHEMA_VERSION: raise ValidationError("Legacy Integration ownership manifest schema 不受支持") - if raw["integration"] != LEGACY_INTEGRATION_ID: + if ( + spec.legacy_manifest_id is None + or raw["integration"] != spec.legacy_manifest_id + ): raise ValidationError("Legacy Integration ownership manifest 主体不匹配") if not isinstance(raw["asset_version"], int) or raw["asset_version"] < 1: raise ValidationError("Legacy Integration ownership manifest asset version 无效") @@ -552,24 +608,30 @@ def _inspect_avatar( def _allowed_legacy_targets( detected: list[tuple[HostSpec, Path]], + spec: SkillIntegrationSpec = CONTROL_PLANE_SPEC, ) -> set[Path]: """Legacy whole-directory installs may only live on detected host avatars.""" - return {_avatar_path(home) for _spec, home in detected} + return {_avatar_path(home, spec) for _spec, home in detected} def _legacy_owned_copy( - legacy_manifest_path: Path, + legacy_manifest_path: Path | None, *, + spec: SkillIntegrationSpec = CONTROL_PLANE_SPEC, expected_target: Path | None = None, allowed_targets: set[Path] | None = None, require_current_assets: bool = True, read_budget: ReadBudget | None = None, ) -> tuple[dict[str, object], Path] | None: + if legacy_manifest_path is None: + return None if not legacy_manifest_path.is_file() or legacy_manifest_path.is_symlink(): return None try: manifest = _parse_legacy_manifest( - legacy_manifest_path, read_budget=read_budget + legacy_manifest_path, + spec, + read_budget=read_budget, ) except ReadLimitError: raise @@ -586,7 +648,9 @@ def _legacy_owned_copy( inventory = _inventory(target, read_budget=read_budget) if inventory != manifest["files"]: return None - if require_current_assets and inventory != _asset_inventory(): + if require_current_assets and inventory != _asset_inventory( + spec.integration_id + ): return None except ValidationError: return None @@ -604,13 +668,16 @@ def integration_status( ) -> IntegrationStatus: """Inspect Skill mirror/avatar ownership without creating files.""" requested = integration - _normalize_integration(integration) + skill_spec = _integration_spec(integration) overrides: dict[str, Path] = dict(host_homes or {}) if codex_home is not None: overrides["codex"] = codex_home - mirror = _mirror_path(dyro_home) - manifest_path, transaction_path, _, legacy_manifest_path = _state_paths(dyro_home) + mirror = _mirror_path(dyro_home, skill_spec) + manifest_path, transaction_path, _, legacy_manifest_path = _state_paths( + dyro_home, + skill_spec, + ) state_root = manifest_path.parent unsafe_state = _symlink_component(state_root, boundary=_dyro_home(dyro_home)) if unsafe_state is not None: @@ -631,17 +698,17 @@ def integration_status( ) detected: list[tuple[HostSpec, Path]] = [] - for spec in HOSTS: - home = _host_home(spec, overrides) + for host_spec in HOSTS: + home = _host_home(host_spec, overrides) if home is not None: - detected.append((spec, home)) + detected.append((host_spec, home)) avatar_rows: list[AvatarStatus] = [] - host_by_id = {spec.host_id: spec for spec, _home in detected} - for spec, home in detected: - avatar = _avatar_path(home) + host_by_id = {host_spec.host_id: host_spec for host_spec, _home in detected} + for host_spec, home in detected: + avatar = _avatar_path(home, skill_spec) row = _inspect_avatar( - host=spec.host_id, + host=host_spec.host_id, avatar=avatar, mirror=mirror, host_home=home, @@ -653,7 +720,8 @@ def integration_status( mirror_exists = mirror.exists() or mirror.is_symlink() legacy = _legacy_owned_copy( legacy_manifest_path, - allowed_targets=_allowed_legacy_targets(detected), + spec=skill_spec, + allowed_targets=_allowed_legacy_targets(detected, skill_spec), require_current_assets=True, read_budget=read_budget, ) @@ -662,8 +730,8 @@ def integration_status( if row.state in {"missing", "current"}: continue if row.state == "unowned": - spec = host_by_id.get(row.host) - if spec is None or not _host_is_explicit(spec, overrides): + host_spec = host_by_id.get(row.host) + if host_spec is None or not _host_is_explicit(host_spec, overrides): continue blocking_avatars.append(row) @@ -715,7 +783,11 @@ def integration_status( ) try: - manifest = _parse_manifest(manifest_path, read_budget=read_budget) + manifest = _parse_manifest( + manifest_path, + skill_spec, + read_budget=read_budget, + ) except ReadLimitError: raise except ValidationError as exc: @@ -785,11 +857,11 @@ def integration_status( recorded_avatars = manifest["avatars"] assert isinstance(recorded_avatars, dict) refreshed: list[AvatarStatus] = [] - for spec, home in detected: - avatar = _avatar_path(home) - recorded = recorded_avatars.get(spec.host_id) + for host_spec, home in detected: + avatar = _avatar_path(home, skill_spec) + recorded = recorded_avatars.get(host_spec.host_id) row = _inspect_avatar( - host=spec.host_id, + host=host_spec.host_id, avatar=avatar, mirror=mirror, host_home=home, @@ -818,9 +890,9 @@ def integration_status( tuple(refreshed), ) - desired = _asset_inventory() + desired = _asset_inventory(skill_spec.integration_id) if ( - manifest["asset_version"] == ASSET_VERSION + manifest["asset_version"] == skill_spec.asset_version and manifest["asset_digest"] == _asset_digest(desired) and installed == desired and managed_or_new @@ -874,9 +946,11 @@ def plan_integration( changes = ("无需写入;Integration 已是当前版本",) elif status.state is IntegrationState.ABSENT: if not status.avatars: + host_envs = " / ".join( + spec.env_var for spec in HOSTS if spec.env_var + ) changes = ( - "无法安装:未检测到宿主目录;需先创建或设置 " - "CODEX_HOME / CLAUDE_HOME / AGENTS_HOME / CURSOR_HOME", + "无法安装:未检测到宿主目录;需先创建或设置 " + host_envs, "不会创建孤立镜像或分身", ) else: @@ -919,9 +993,9 @@ def plan_integration( return IntegrationPlan(action, status, changes) -def _write_stage(stage: Path) -> None: - source = _asset_root() - for relative in _asset_inventory(): +def _write_stage(stage: Path, spec: SkillIntegrationSpec) -> None: + source = _asset_root(spec.integration_id) + for relative in _asset_inventory(spec.integration_id): source_file = source / relative destination = stage / relative destination.parent.mkdir(mode=0o700, parents=True, exist_ok=True) @@ -931,7 +1005,7 @@ def _write_stage(stage: Path) -> None: os.fsync(handle.fileno()) destination.chmod(0o644) fsync_directory(stage) - if _inventory(stage) != _asset_inventory(): + if _inventory(stage) != _asset_inventory(spec.integration_id): raise DyroError("Integration staging 校验失败") @@ -940,7 +1014,12 @@ def _remove_tree(path: Path) -> None: def _transaction_payload( - action: str, mirror: Path, backup: Path | None, *, phase: str + action: str, + mirror: Path, + backup: Path | None, + spec: SkillIntegrationSpec, + *, + phase: str, ) -> str: if phase not in {"prepared", "committed"}: raise ValidationError(f"未知 Integration transaction phase:{phase}") @@ -948,7 +1027,7 @@ def _transaction_payload( json.dumps( { "schema_version": 1, - "integration": CANONICAL_INTEGRATION_ID, + "integration": spec.integration_id, "action": action, "phase": phase, "mirror": str(mirror), @@ -991,7 +1070,10 @@ def _execution_result( def _restored_owned_installation( - mirror: Path, manifest_path: Path, original_manifest_text: str + mirror: Path, + manifest_path: Path, + original_manifest_text: str, + spec: SkillIntegrationSpec = CONTROL_PLANE_SPEC, ) -> bool: if ( mirror.is_symlink() @@ -1003,7 +1085,7 @@ def _restored_owned_installation( try: if manifest_path.read_text(encoding="utf-8") != original_manifest_text: return False - manifest = _parse_manifest(manifest_path) + manifest = _parse_manifest(manifest_path, spec) if manifest["mirror"] != str(mirror): return False if _inventory(mirror) != manifest["files"]: @@ -1058,6 +1140,7 @@ def _host_is_explicit( def _install_avatars( *, + skill_spec: SkillIntegrationSpec, mirror: Path, detected: list[tuple[HostSpec, Path]], legacy_target: Path | None, @@ -1072,17 +1155,19 @@ def _install_avatars( avatars: dict[str, dict[str, str]] = {} created: list[Path] = [] legacy_backups: list[tuple[Path, Path]] = [] - allowed = _allowed_legacy_targets(detected) + allowed = _allowed_legacy_targets(detected, skill_spec) try: - for spec, home in detected: - avatar = _avatar_path(home) + for host_spec, home in detected: + avatar = _avatar_path(home, skill_spec) unsafe = _symlink_component(avatar.parent, boundary=home) if unsafe is not None: - if _host_is_explicit(spec, overrides): - raise DyroError(f"{spec.host_id} skills 目录不安全:{unsafe}") + if _host_is_explicit(host_spec, overrides): + raise DyroError( + f"{host_spec.host_id} skills 目录不安全:{unsafe}" + ) continue if _is_link(avatar) and _resolves_to(avatar, mirror): - avatars[spec.host_id] = { + avatars[host_spec.host_id] = { "path": str(avatar), "kind": _avatar_kind(), } @@ -1095,27 +1180,32 @@ def _install_avatars( and avatar.is_dir() and not avatar.is_symlink() ): - if _inventory(avatar) != _asset_inventory(): + if _inventory(avatar) != _asset_inventory( + skill_spec.integration_id + ): raise DyroError(f"拒绝删除非 Dyro 资产目录:{avatar}") backup = Path( tempfile.mkdtemp( - prefix=f".{SKILL_NAME}.legacy-", dir=avatar.parent + prefix=f".{skill_spec.skill_name}.legacy-", + dir=avatar.parent, ) ) backup.rmdir() os.replace(avatar, backup) legacy_backups.append((avatar, backup)) - elif _host_is_explicit(spec, overrides): + elif _host_is_explicit(host_spec, overrides): raise DyroError(f"拒绝覆盖非 Dyro 分身路径:{avatar}") else: # Auto-detected host with a foreign skill: leave it alone. continue _ensure_safe_directory( - avatar.parent, f"{spec.host_id} skills 目录", boundary=home + avatar.parent, + f"{host_spec.host_id} skills 目录", + boundary=home, ) kind = _create_avatar_link(avatar, mirror) created.append(avatar) - avatars[spec.host_id] = {"path": str(avatar), "kind": kind} + avatars[host_spec.host_id] = {"path": str(avatar), "kind": kind} if not avatars: raise DyroError("没有可挂接的宿主分身;拒绝只安装孤立镜像") return avatars, legacy_backups @@ -1130,6 +1220,7 @@ def _install_avatars( def sync_managed_skill( + integration: str = CANONICAL_INTEGRATION_ID, *, yes: bool, dry_run: bool = False, @@ -1146,7 +1237,7 @@ def sync_managed_skill( - conflict / recovery states: raise ``DyroError`` (callers may soft-fail) """ status = integration_status( - CANONICAL_INTEGRATION_ID, + integration, dyro_home=dyro_home, host_homes=host_homes, codex_home=codex_home, @@ -1156,7 +1247,7 @@ def sync_managed_skill( if status.state is IntegrationState.ABSENT and not allow_first_install: return None return install_integration( - CANONICAL_INTEGRATION_ID, + integration, yes=yes, dry_run=dry_run, dyro_home=dyro_home, @@ -1174,6 +1265,7 @@ def install_integration( host_homes: Mapping[str, Path] | None = None, codex_home: Path | None = None, ) -> IntegrationPlan: + spec = _integration_spec(integration) overrides: dict[str, Path] = dict(host_homes or {}) if codex_home is not None: overrides["codex"] = codex_home @@ -1192,7 +1284,8 @@ def install_integration( return plan manifest_path, transaction_path, lock_path, legacy_manifest_path = _state_paths( - dyro_home + dyro_home, + spec, ) mirror = plan.status.target detected = _detected_hosts(overrides) @@ -1219,13 +1312,17 @@ def install_integration( legacy = _legacy_owned_copy( legacy_manifest_path, - allowed_targets=_allowed_legacy_targets(detected), + spec=spec, + allowed_targets=_allowed_legacy_targets(detected, spec), require_current_assets=True, ) legacy_target = legacy[1] if legacy is not None else None stage = Path( - tempfile.mkdtemp(prefix=f".{SKILL_NAME}.stage-", dir=mirror.parent) + tempfile.mkdtemp( + prefix=f".{spec.skill_name}.stage-", + dir=mirror.parent, + ) ) backup: Path | None = None activated = False @@ -1241,16 +1338,20 @@ def install_integration( manifest_replaced = False transaction_payload = "" try: - _write_stage(stage) + _write_stage(stage, spec) if mirror.exists(): backup = Path( tempfile.mkdtemp( - prefix=f".{SKILL_NAME}.backup-", dir=mirror.parent + prefix=f".{spec.skill_name}.backup-", dir=mirror.parent ) ) backup.rmdir() transaction_payload = _transaction_payload( - "install", mirror, backup, phase="prepared" + "install", + mirror, + backup, + spec, + phase="prepared", ) atomic_write_text(transaction_path, transaction_payload) if backup is not None: @@ -1259,8 +1360,9 @@ def install_integration( raise DyroError("Skill 镜像在事务期间被其他进程创建;已中止") os.replace(stage, mirror) activated = True - desired = _asset_inventory() + desired = _asset_inventory(spec.integration_id) avatars, legacy_backups = _install_avatars( + skill_spec=spec, mirror=mirror, detected=detected, legacy_target=legacy_target, @@ -1279,7 +1381,7 @@ def install_integration( atomic_write_text( manifest_path, json.dumps( - _manifest_payload(mirror, desired, avatars), + _manifest_payload(spec, mirror, desired, avatars), ensure_ascii=True, indent=2, sort_keys=True, @@ -1287,10 +1389,16 @@ def install_integration( + "\n", ) manifest_replaced = True - if legacy_manifest_path.exists() or legacy_manifest_path.is_symlink(): + if legacy_manifest_path is not None and ( + legacy_manifest_path.exists() or legacy_manifest_path.is_symlink() + ): legacy_manifest_path.unlink() transaction_payload = _transaction_payload( - "install", mirror, backup, phase="committed" + "install", + mirror, + backup, + spec, + phase="committed", ) atomic_write_text(transaction_path, transaction_payload) committed = True @@ -1335,7 +1443,10 @@ def install_integration( ) elif mirror.exists() and manifest_path.exists(): restored = _restored_owned_installation( - mirror, manifest_path, old_manifest_text + mirror, + manifest_path, + old_manifest_text, + spec, ) finally: if stage.exists(): @@ -1358,6 +1469,7 @@ def uninstall_integration( host_homes: Mapping[str, Path] | None = None, codex_home: Path | None = None, ) -> IntegrationPlan: + spec = _integration_spec(integration) overrides: dict[str, Path] = dict(host_homes or {}) if codex_home is not None: overrides["codex"] = codex_home @@ -1376,7 +1488,8 @@ def uninstall_integration( return plan manifest_path, transaction_path, lock_path, legacy_manifest_path = _state_paths( - dyro_home + dyro_home, + spec, ) mirror = plan.status.target _safe_existing_directory( @@ -1400,7 +1513,8 @@ def uninstall_integration( detected = _detected_hosts(overrides) legacy = _legacy_owned_copy( legacy_manifest_path, - allowed_targets=_allowed_legacy_targets(detected), + spec=spec, + allowed_targets=_allowed_legacy_targets(detected, spec), require_current_assets=True, ) manifest_text = ( @@ -1419,7 +1533,10 @@ def uninstall_integration( boundary = _dyro_home(dyro_home) _ensure_safe_directory(backup_dir, "Skill 卸载备份目录", boundary=boundary) backup = Path( - tempfile.mkdtemp(prefix=f".{SKILL_NAME}.backup-", dir=backup_dir) + tempfile.mkdtemp( + prefix=f".{spec.skill_name}.backup-", + dir=backup_dir, + ) ) backup.rmdir() removed_manifest = False @@ -1427,7 +1544,11 @@ def uninstall_integration( committed = False restored = False transaction_payload = _transaction_payload( - "uninstall", mirror, backup, phase="prepared" + "uninstall", + mirror, + backup, + spec, + phase="prepared", ) atomic_write_text(transaction_path, transaction_payload) try: @@ -1448,10 +1569,16 @@ def uninstall_integration( if manifest_path.exists(): manifest_path.unlink() removed_manifest = True - if legacy_manifest_path.exists() or legacy_manifest_path.is_symlink(): + if legacy_manifest_path is not None and ( + legacy_manifest_path.exists() or legacy_manifest_path.is_symlink() + ): legacy_manifest_path.unlink() transaction_payload = _transaction_payload( - "uninstall", mirror, backup, phase="committed" + "uninstall", + mirror, + backup, + spec, + phase="committed", ) atomic_write_text(transaction_path, transaction_payload) committed = True @@ -1480,7 +1607,10 @@ def uninstall_integration( and manifest_path.exists() ): restored = _restored_owned_installation( - mirror, manifest_path, manifest_text + mirror, + manifest_path, + manifest_text, + spec, ) finally: if restored and transaction_path.exists(): diff --git a/src/dyro/profile.py b/src/dyro/profile.py index 47fb6be..c6d8a24 100644 --- a/src/dyro/profile.py +++ b/src/dyro/profile.py @@ -9,7 +9,7 @@ from .config import Adapter, CONFIG_NAME, Config, expand_argv, load, validate_id from .errors import DyroError, ValidationError from .state import atomic_write_text, exclusive_lock -from .tooling import TOOL_DEFINITIONS, tool_definition +from .tooling import TOOL_DEFINITIONS, tool_definition, tool_runtime_issue _BARE_TOML_KEY = re.compile(r"^[A-Za-z0-9_-]+$") @@ -45,7 +45,9 @@ def launchable_preset_ids() -> tuple[str, ...]: """Presets that can be written into a Profile and used by `dyro start`.""" tool_ids = tuple( - definition.id for definition in TOOL_DEFINITIONS if definition.launch + definition.id + for definition in TOOL_DEFINITIONS + if definition.launch and definition.profile_preset ) return ("noop", *tool_ids) @@ -56,13 +58,27 @@ def installed_launchable_presets( lookup = which or shutil.which found: list[str] = [] for definition in TOOL_DEFINITIONS: - if definition.launch and lookup(definition.command): - found.append(definition.id) + if not definition.launch or not definition.profile_preset: + continue + if not lookup(definition.command): + continue + if tool_runtime_issue(definition, which=lookup): + continue + found.append(definition.id) return tuple(found) def preset_adapter(adapter_id: str, preset: str) -> Adapter: validate_id(adapter_id, "adapter id") + if preset == "noop": + return Adapter(adapter_id, ("/usr/bin/true",), ("/usr/bin/true",), ("/usr/bin/true",)) + definition = tool_definition(preset) + if definition is None or not definition.launch: + raise ValidationError(f"未知 Agent preset:{preset}") + if not definition.profile_preset: + raise ValidationError( + f"{definition.label} 仅支持首页启动;尚无经过审计的任务 adapter" + ) if preset == "codex": return Adapter( adapter_id, @@ -70,11 +86,6 @@ def preset_adapter(adapter_id: str, preset: str) -> Adapter: ("codex", "exec", "--skip-git-repo-check", "--sandbox", "workspace-write", "{prompt}"), ("codex", "exec", "--skip-git-repo-check", "--sandbox", "workspace-write", "{prompt}"), ) - if preset == "noop": - return Adapter(adapter_id, ("/usr/bin/true",), ("/usr/bin/true",), ("/usr/bin/true",)) - definition = tool_definition(preset) - if definition is None or not definition.launch: - raise ValidationError(f"未知 Agent preset:{preset}") launch = _validate_argv(definition.launch, "Agent launch") return Adapter(adapter_id, launch, launch, launch) diff --git a/src/dyro/tooling.py b/src/dyro/tooling.py index c61d6d3..b1305f9 100644 --- a/src/dyro/tooling.py +++ b/src/dyro/tooling.py @@ -4,6 +4,7 @@ from enum import Enum import json from pathlib import Path +import re import shlex import shutil import subprocess @@ -39,6 +40,13 @@ class InstallGuide: risk: str = "" +@dataclass(frozen=True) +class RuntimeRequirement: + command: str + label: str + minimum_version: tuple[int, int, int] + + @dataclass(frozen=True) class ToolDefinition: id: str @@ -48,6 +56,8 @@ class ToolDefinition: launch: tuple[str, ...] environment: tuple[tuple[str, str], ...] = () install: InstallGuide | None = None + profile_preset: bool = False + runtime: RuntimeRequirement | None = None @dataclass(frozen=True) @@ -67,6 +77,7 @@ class ToolPreferences: "agy", "terminal", ("agy",), + profile_preset=True, install=InstallGuide( "https://antigravity.google/download", "Antigravity 官方用户级安装目录", @@ -79,6 +90,7 @@ class ToolPreferences: "codex", "terminal", ("codex", "-C", "{workspace}"), + profile_preset=True, install=InstallGuide( "https://developers.openai.com/codex/cli/", _NPM_SCOPE, @@ -93,6 +105,7 @@ class ToolPreferences: "codex-desktop", "desktop", ("codex", "app", "{workspace}"), + profile_preset=True, install=InstallGuide( "https://openai.com/codex/", "当前操作系统的 Codex 桌面应用", @@ -105,6 +118,7 @@ class ToolPreferences: "claude", "terminal", ("claude",), + profile_preset=True, install=InstallGuide( "https://docs.anthropic.com/en/docs/claude-code/getting-started", _NPM_SCOPE, @@ -143,19 +157,28 @@ class ToolPreferences: "cursor-agent", "terminal", ("cursor-agent", "--workspace", "{workspace}"), + profile_preset=True, install=InstallGuide( "https://docs.cursor.com/en/cli/installation", "Cursor 官方用户级安装目录", remote_script_only=True, ), ), - ToolDefinition("grok", "Grok", "grok", "terminal", ("grok", "--cwd", "{workspace}")), + ToolDefinition( + "grok", + "Grok", + "grok", + "terminal", + ("grok", "--cwd", "{workspace}"), + profile_preset=True, + ), ToolDefinition( "opencode", "OpenCode", "opencode", "terminal", ("opencode", "{workspace}"), + profile_preset=True, install=InstallGuide( "https://opencode.ai/docs", _NPM_SCOPE, @@ -171,6 +194,7 @@ class ToolPreferences: "runtime", ("openclaw",), environment=(("OPENCLAW_WORKSPACE_DIR", "{workspace}"),), + profile_preset=True, install=InstallGuide( "https://docs.openclaw.ai/install", _NPM_SCOPE, @@ -186,6 +210,7 @@ class ToolPreferences: "hermes", "terminal", ("hermes",), + profile_preset=True, install=InstallGuide( "https://github.com/NousResearch/hermes-agent/blob/main/website/docs/getting-started/quickstart.md", "Hermes 官方用户级安装目录", @@ -198,6 +223,7 @@ class ToolPreferences: "kimi", "terminal", ("kimi",), + profile_preset=True, install=InstallGuide( "https://www.kimi.com/code/docs/kimi-code-cli/guides/getting-started.html", _NPM_SCOPE, @@ -206,12 +232,48 @@ class ToolPreferences: risk=_NPM_RISK, ), ), + ToolDefinition( + "dsh", + "DeepSeek Harness", + "dsh", + "terminal", + ("dsh", "web"), + install=InstallGuide( + "https://github.com/deepseek-ai/deepseek-harness", + _NPM_SCOPE, + ("npm", "install", "-g", "@deepseek-ai/dsh@latest"), + prerequisite="npm", + risk=_NPM_RISK, + ), + ), + ToolDefinition( + "pi", + "Pi", + "pi", + "terminal", + ("pi",), + install=InstallGuide( + "https://pi.dev/docs/latest", + _NPM_SCOPE, + ( + "npm", + "install", + "-g", + "--ignore-scripts", + "@earendil-works/pi-coding-agent@latest", + ), + prerequisite="npm", + risk=_NPM_RISK, + ), + runtime=RuntimeRequirement("node", "Node.js", (22, 19, 0)), + ), ToolDefinition( "qoder", "Qoder CLI", "qodercli", "terminal", ("qodercli",), + profile_preset=True, install=InstallGuide( "https://docs.qoder.com/en/cli/quick-start", _NPM_SCOPE, @@ -226,6 +288,7 @@ class ToolPreferences: "zcode", "desktop", ("zcode", "{workspace}"), + profile_preset=True, install=InstallGuide( "https://zcode.z.ai/en/docs/install", "当前操作系统的桌面应用", @@ -341,6 +404,53 @@ def _run_install( return subprocess.run(argv, check=check) +_SEMANTIC_VERSION = re.compile(r"(? subprocess.CompletedProcess[str]: + return subprocess.run( + argv, + check=check, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=5, + ) + + +def tool_runtime_issue( + definition: ToolDefinition, + *, + which: Callable[[str], str | None] | None = None, + run: Callable[..., subprocess.CompletedProcess[str]] | None = None, +) -> str: + requirement = definition.runtime + if requirement is None: + return "" + lookup = which or shutil.which + executable = lookup(requirement.command) + minimum = ".".join(str(part) for part in requirement.minimum_version) + expected = f"{requirement.label} >= {minimum}" + if executable is None: + return f"{definition.label} 需要 {expected},但当前 PATH 未检测到" + runner = run or _run_runtime_probe + try: + completed = runner((executable, "--version"), check=False) + except (OSError, subprocess.SubprocessError): + return f"{definition.label} 需要 {expected},但版本检测失败" + output = str(completed.stdout or "").strip() + match = _SEMANTIC_VERSION.search(output) + if completed.returncode != 0 or match is None: + return f"{definition.label} 需要 {expected},但无法识别当前版本" + current = tuple(int(part) for part in match.groups()) + if current < requirement.minimum_version: + current_text = ".".join(str(part) for part in current) + return f"{definition.label} 需要 {expected};当前为 {current_text}" + return "" + + def install_tool( tool_id: str, *, @@ -351,7 +461,7 @@ def install_tool( open_url: Callable[[str], bool] | None = None, ) -> bool: ask = ask or input - run = run or _run_install + command_runner = run or _run_install open_url = open_url or webbrowser.open definition = tool_definition(tool_id) if definition is None or definition.install is None: @@ -381,6 +491,9 @@ def install_tool( f"安装 {definition.label} 需要 {guide.prerequisite};" f"请先准备该工具,或查看 {guide.source_url}" ) + runtime_issue = tool_runtime_issue(definition, run=run) + if runtime_issue: + raise DyroError(f"无法安装 {definition.label}:{runtime_issue}") if not yes: confirmed = ask("是否继续?[y/N]:").strip().lower() if confirmed not in {"y", "yes"}: @@ -397,7 +510,7 @@ def install_tool( if prerequisite_path and guide.argv[0] == guide.prerequisite else guide.argv ) - completed = run(tuple(argv), check=False) + completed = command_runner(tuple(argv), check=False) if completed.returncode != 0: raise DyroError( f"{definition.label} 安装命令失败(exit {completed.returncode});" @@ -410,7 +523,7 @@ def install_tool( f"{definition.command};请重新打开终端后再运行 dyro。" ) return True - verification = run((executable, "--version"), check=False) + verification = command_runner((executable, "--version"), check=False) if verification.returncode != 0: raise DyroError( f"{definition.label} 已安装但版本验证失败" diff --git a/tests/test_adversarial_remediation_dispatch.py b/tests/test_adversarial_remediation_dispatch.py index 5f5ed09..9ba021c 100644 --- a/tests/test_adversarial_remediation_dispatch.py +++ b/tests/test_adversarial_remediation_dispatch.py @@ -13,18 +13,35 @@ import tempfile import threading import time +import tomllib import unittest from unittest.mock import Mock, patch from experiments.local_agent_dispatch.adapters.base import AdapterResult from experiments.local_agent_dispatch.adapters.subprocess_cli import ( + _HERMES_ONESHOT_BOOTSTRAP, _completed_to_result, + _dsh_has_default_credential, _parse_model_json, + _parse_wrapped_model_json, + _provider_credentials, + _temporary_text_file, claude_adapter, codex_adapter, + cursor_adapter, + dsh_adapter, + grok_adapter, + hermes_adapter, + kimi_adapter, + opencode_adapter, + pi_adapter, +) +from experiments.local_agent_dispatch.adapters.registry import ( + adapter_execution_profile, + adapter_is_authenticated, + get_adapter, + probe_backends, ) -from experiments.local_agent_dispatch.adapters.registry import get_adapter -from experiments.local_agent_dispatch.adapters.registry import probe_backends from experiments.local_agent_dispatch.bounded_process import ( BoundedCompletedProcess, _terminate_process_group, @@ -44,6 +61,7 @@ from experiments.local_agent_dispatch.json_store import atomic_write_json, read_json from experiments.local_agent_dispatch.lease import SlotManager from experiments.local_agent_dispatch.process_identity import ( + process_group_has_live_members, process_identity_is_dead, process_is_alive, process_started_at, @@ -345,7 +363,7 @@ def test_edit_patch_preserves_raw_git_diff_bytes(self) -> None: ) with patch( "experiments.local_agent_dispatch.edit_workspace._git", - side_effect=[added, diffed], + side_effect=[added, added, diffed], ): patch_ref = workspace.seal_patch() @@ -386,6 +404,29 @@ def fail_worktree_add(project_root, arguments, **kwargs): ) workspace.cleanup() + def test_edit_patch_rejects_temporary_dispatch_input(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + project = root / "project" + project.mkdir() + _init_git_project(project) + workspace = EditWorkspace.create( + project_root=project, + home=root / "home", + run_id="run-temp-input", + ) + try: + (workspace.worktree_root / ".dyro-dispatch-leftover.md").write_text( + "private task context\n", encoding="utf-8" + ) + with self.assertRaisesRegex( + DispatchValidationError, "temporary dispatch input" + ): + workspace.seal_patch() + self.assertFalse(workspace.patch_path.exists()) + finally: + workspace.cleanup() + def test_strict_mode_rejects_backend_without_physical_isolation(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -408,6 +449,54 @@ def test_strict_mode_rejects_backend_without_physical_isolation(self) -> None: class DispatchOwnershipTests(unittest.TestCase): + def test_process_group_zombies_are_not_treated_as_live_members(self) -> None: + process_table = subprocess.CompletedProcess( + args=["ps"], + returncode=0, + stdout=" 42 Z\n 42 Z+\n 99 S\n", + stderr="", + ) + with patch( + "experiments.local_agent_dispatch.process_identity.subprocess.run", + return_value=process_table, + ) as run: + self.assertFalse(process_group_has_live_members(42)) + self.assertTrue(process_group_has_live_members(99)) + self.assertEqual(run.call_args.args[0][0], "/bin/ps") + + def test_process_group_probe_failures_are_unverifiable(self) -> None: + cases = ( + OSError("ps unavailable"), + subprocess.TimeoutExpired(cmd="/bin/ps", timeout=2), + subprocess.CompletedProcess( + args=["/bin/ps"], returncode=1, stdout="", stderr="failed" + ), + subprocess.CompletedProcess( + args=["/bin/ps"], returncode=0, stdout="", stderr="" + ), + subprocess.CompletedProcess( + args=["/bin/ps"], + returncode=0, + stdout="not-a-process-table\n", + stderr="", + ), + ) + for outcome in cases: + with ( + self.subTest(outcome=outcome), + patch( + "experiments.local_agent_dispatch.process_identity." + "subprocess.run", + side_effect=( + outcome if isinstance(outcome, BaseException) else None + ), + return_value=( + None if isinstance(outcome, BaseException) else outcome + ), + ), + ): + self.assertIsNone(process_group_has_live_members(42)) + def test_equal_coarse_start_token_does_not_prove_process_dead( self, ) -> None: @@ -940,6 +1029,25 @@ def test_non_json_backend_output_is_rejected(self) -> None: with self.assertRaisesRegex(DispatchValidationError, "JSON"): _parse_model_json("plain text is not the result contract") + def test_pretty_printed_grok_envelope_is_decoded(self) -> None: + result = _parse_wrapped_model_json( + json.dumps( + { + "text": json.dumps( + { + "summary": "bounded", + "confidence": "high", + "evidence": [], + } + ), + "stopReason": "end_turn", + }, + indent=2, + ), + backend="grok", + ) + self.assertEqual(result["summary"], "bounded") + def test_backend_result_rejects_secret_like_model_echo_without_leaking_it(self) -> None: token = "github_pat_" + ("a" * 82) with self.assertRaisesRegex(DispatchValidationError, "secret-like") as raised: @@ -966,6 +1074,94 @@ def test_result_envelope_rejects_secret_like_warning_without_leaking_it(self) -> ) self.assertNotIn(token, str(raised.exception)) + def test_result_envelope_recursively_rejects_named_credentials_in_usage(self) -> None: + credential = "ordinary-provider-credential" + with self.assertRaisesRegex(DispatchValidationError, "secret-like") as raised: + build_result( + run_id="run-1", + status="error", + summary="", + cwd=Path.cwd(), + usage={"nested": [{"KIMI_API_KEY": credential}]}, + ) + self.assertNotIn(credential, str(raised.exception)) + + def test_result_envelope_rejects_credentials_wrapped_under_sensitive_key(self) -> None: + credential = "ordinary-provider-credential" + for wrapped in ([credential], {"value": credential}): + with ( + self.subTest(value_type=type(wrapped).__name__), + self.assertRaisesRegex(DispatchValidationError, "secret-like"), + ): + build_result( + run_id="run-1", + status="error", + summary="", + cwd=Path.cwd(), + usage={"KIMI_API_KEY": wrapped}, + ) + + def test_result_envelope_rejects_deceptive_sensitive_container_subclasses(self) -> None: + class DeceptiveList(list): + def __eq__(self, _other) -> bool: + return True + + class DeceptiveDict(dict): + def __eq__(self, _other) -> bool: + return True + + credential = "ordinary-provider-credential" + for wrapped in ( + DeceptiveList([credential]), + DeceptiveDict({"value": credential}), + ): + with ( + self.subTest(value_type=type(wrapped).__name__), + self.assertRaisesRegex(DispatchValidationError, "secret-like"), + ): + build_result( + run_id="run-1", + status="error", + summary="", + cwd=Path.cwd(), + usage={"KIMI_API_KEY": wrapped}, + ) + + def test_result_envelope_rejects_stateful_container_subclasses(self) -> None: + class StatefulDict(dict): + def __init__(self) -> None: + super().__init__({"safe": 1}) + self.calls = 0 + + def items(self): + self.calls += 1 + if self.calls < 3: + return {"safe": 1}.items() + return {"KIMI_API_KEY": "ordinary-provider-credential"}.items() + + with self.assertRaisesRegex(DispatchValidationError, "non-JSON"): + build_result( + run_id="run-1", + status="error", + summary="", + cwd=Path.cwd(), + usage=StatefulDict(), + ) + + def test_result_envelope_rejects_non_finite_usage_numbers(self) -> None: + for value in (float("nan"), float("inf"), float("-inf")): + with ( + self.subTest(value=repr(value)), + self.assertRaisesRegex(DispatchValidationError, "finite"), + ): + build_result( + run_id="run-1", + status="error", + summary="", + cwd=Path.cwd(), + usage={"nested": [value]}, + ) + def test_empty_evidence_is_not_fully_verified(self) -> None: result = build_result( run_id="run-1", @@ -1052,7 +1248,16 @@ def test_codex_adapter_pins_sandbox_and_strips_unrelated_secrets(self) -> None: return_value=completed, ) as bounded, ): - result = codex_adapter().run( + adapter = codex_adapter() + adapter.configure_execution_profile( + { + "backend": "codex", + "command_path": "/usr/local/bin/codex", + "provider": "openai", + "model": "reviewed-codex-model", + } + ) + result = adapter.run( contract=contract, cwd=Path.cwd(), context_files={"app.py": "safe = True\n"}, @@ -1109,7 +1314,16 @@ def test_claude_adapter_does_not_receive_codex_credentials(self) -> None: return_value=completed, ) as bounded, ): - result = claude_adapter().run( + adapter = claude_adapter() + adapter.configure_execution_profile( + { + "backend": "claude", + "command_path": "/usr/local/bin/claude", + "provider": "anthropic", + "model": "reviewed-model", + } + ) + result = adapter.run( contract=contract, cwd=Path.cwd(), context_files={"app.py": "safe = True\n"}, @@ -1120,103 +1334,1231 @@ def test_claude_adapter_does_not_receive_codex_credentials(self) -> None: self.assertNotIn("CODEX_HOME", environment) self.assertEqual(environment.get("ANTHROPIC_API_KEY"), "claude-only") - def test_backend_failure_does_not_persist_raw_stderr(self) -> None: - result = _completed_to_result( - BoundedCompletedProcess( - args=("codex",), - returncode=7, - stdout="untrusted output", - stderr="secret-token-value", - ), - backend="codex", + def test_all_discovered_harnesses_have_bounded_provider_adapters(self) -> None: + contract = parse_task_contract( + { + **_payload(), + "backend": "fake", + } ) - self.assertEqual(result.summary, "") - self.assertEqual(result.raw_preview, "") - self.assertNotIn("secret-token-value", " ".join(result.warnings)) + provider_payload = json.dumps( + { + "summary": "bounded", + "confidence": "high", + "evidence": [], + } + ) + cases = { + "cursor-agent": ( + cursor_adapter, + json.dumps({"type": "result", "result": provider_payload}), + ("--mode", "ask", "--sandbox", "disabled"), + ), + "opencode": ( + opencode_adapter, + json.dumps( + { + "type": "text", + "part": {"type": "text", "text": provider_payload}, + } + ), + ("run", "--format", "json", "--pure"), + ), + "grok": ( + grok_adapter, + json.dumps({"text": provider_payload}), + ("--permission-mode", "plan", "--sandbox", "read-only"), + ), + "hermes": ( + hermes_adapter, + provider_payload, + ("-I", "clarify"), + ), + "kimi": ( + kimi_adapter, + json.dumps({"role": "assistant", "content": provider_payload}), + ("--output-format", "stream-json", "--agent-file"), + ), + "dsh": ( + dsh_adapter, + provider_payload, + ("--profile", "headless"), + ), + "pi": ( + pi_adapter, + json.dumps( + { + "type": "message_end", + "message": { + "role": "assistant", + "content": [{"type": "text", "text": provider_payload}], + }, + } + ), + ("--mode", "json", "--tools", "read,grep,find,ls"), + ), + } - def test_setup_failure_terminates_spawned_process_group(self) -> None: + for backend, (factory, stdout, required) in cases.items(): + with self.subTest(backend=backend): + completed = BoundedCompletedProcess( + args=(backend,), + returncode=0, + stdout=stdout, + stderr="", + ) + captured_prompt_files: list[str] = [] + + def bounded(argv, **kwargs): + candidates = [Path(str(raw)) for raw in argv] + candidates.extend( + Path(kwargs["cwd"]).glob(".dyro-dispatch-*") + ) + for candidate in candidates: + if candidate.name.startswith( + ".dyro-dispatch-" + ) and candidate.is_file(): + captured_prompt_files.append( + candidate.read_text(encoding="utf-8") + ) + return completed + + with ( + patch( + "experiments.local_agent_dispatch.adapters.subprocess_cli.shutil.which", + return_value=f"/usr/local/bin/{backend}", + ), + patch( + "experiments.local_agent_dispatch.adapters.subprocess_cli.run_bounded", + side_effect=bounded, + ) as run, + patch( + "experiments.local_agent_dispatch.adapters.subprocess_cli._hermes_python_runtime", + return_value=Path(sys.executable), + ), + patch( + "experiments.local_agent_dispatch.adapters.subprocess_cli." + "_materialize_kimi_worker_home", + ), + ): + adapter = factory() + profile = { + "backend": backend, + "command_path": f"/usr/local/bin/{backend}", + "provider": ( + "deepseek-official" + if backend == "dsh" + else "test-provider" + ), + "model": ( + "deepseek-v4-flash" + if backend == "dsh" + else "reviewed-model" + ), + } + if backend == "kimi": + profile["route_sha256"] = "a" * 64 + adapter.configure_execution_profile(profile) + result = adapter.run( + contract=contract, + cwd=Path.cwd(), + context_files={"app.py": "safe = True\n"}, + timeout_seconds=10.0, + ) + + self.assertEqual(result.status, "ok") + argv = [str(item) for item in run.call_args.args[0]] + for value in required: + self.assertIn(value, argv) + self.assertNotIn("safe = True", " ".join(argv)) + if backend == "opencode": + self.assertLess( + argv.index("Follow the attached Dyro dispatch task exactly."), + argv.index("--file"), + ) + environment = run.call_args.kwargs["env"] + self.assertIn("XDG_CONFIG_HOME", environment) + self.assertIn("XDG_DATA_HOME", environment) + self.assertNotIn( + "OPENCODE_DISABLE_DEFAULT_PLUGINS", environment + ) + if backend == "hermes": + self.assertIn("clarify", argv) + environment = run.call_args.kwargs["env"] + self.assertEqual(environment.get("HERMES_SAFE_MODE"), "1") + if backend == "dsh": + self.assertTrue( + any( + "agent-default-model" in text + and "deepseek-official" in text + and "deepseek-v4-flash" in text + for text in captured_prompt_files + ) + ) + supplied = run.call_args.kwargs.get("input_text") or "" + self.assertTrue( + "safe = True" in supplied + or any("safe = True" in text for text in captured_prompt_files), + f"{backend} must receive the guarded prompt out of argv", + ) + + def test_dsh_sync_run_uses_isolated_home_without_user_settings(self) -> None: + contract = parse_task_contract({**_payload(), "backend": "dsh"}) + payload = json.dumps( + {"summary": "reviewed", "confidence": "medium", "evidence": []} + ) + completed = BoundedCompletedProcess( + args=("dsh",), returncode=0, stdout=payload, stderr="" + ) with tempfile.TemporaryDirectory() as tmp: - marker = Path(tmp) / "setup-failure-survivor" - child = ( - "import pathlib,time;" - "time.sleep(0.5);" - f"pathlib.Path({str(marker)!r}).write_text('bad')" + source_home = Path(tmp) / "source" + source_home.mkdir() + credentials = source_home / ".credentials.yaml" + credentials.write_text( + "DEEPSEEK_API_KEY: test-selected-dsh-credential\n", + encoding="utf-8", + ) + credentials.chmod(0o600) + (source_home / "settings.yaml").write_text( + "agent-default-model:\n model: unreviewed-model\n", + encoding="utf-8", ) + observed: dict[str, object] = {} + + def bounded(_argv, **kwargs): + isolated_home = Path(kwargs["env"]["DSH_HOME"]) + observed.update( + home=isolated_home, + private=( + (isolated_home / ".credentials.yaml").stat().st_mode + & 0o077 + ) + == 0, + settings=(isolated_home / "settings.yaml").exists(), + ) + return completed + with ( + patch.dict( + os.environ, + {"DSH_HOME": str(source_home)}, + clear=True, + ), patch( - "experiments.local_agent_dispatch.bounded_process.os.set_blocking", - side_effect=OSError("simulated setup failure"), + "experiments.local_agent_dispatch.adapters." + "subprocess_cli.shutil.which", + return_value="/usr/local/bin/dsh", + ), + patch( + "experiments.local_agent_dispatch.adapters." + "subprocess_cli.run_bounded", + side_effect=bounded, ), - self.assertRaisesRegex(OSError, "setup failure"), ): - run_bounded( - [sys.executable, "-c", child], - cwd=Path(tmp), - timeout_seconds=2.0, + adapter = dsh_adapter() + profile = adapter_execution_profile(adapter) + adapter.configure_execution_profile(profile) + result = adapter.run( + contract=contract, + cwd=Path.cwd(), + context_files={"app.py": "safe = True\n"}, + timeout_seconds=10.0, ) - time.sleep(0.8) - self.assertFalse(marker.exists()) - def test_non_finite_timeout_is_rejected_before_spawn(self) -> None: - for timeout in (float("nan"), float("inf"), float("-inf")): + self.assertEqual(result.status, "ok") + self.assertTrue(observed["private"]) + self.assertFalse(observed["settings"]) + self.assertFalse(Path(observed["home"]).exists()) + + def test_pi_sync_run_uses_isolated_home_without_lateral_auth(self) -> None: + contract = parse_task_contract({**_payload(), "backend": "pi"}) + payload = json.dumps( + { + "type": "message_end", + "message": { + "role": "assistant", + "content": [ + { + "type": "text", + "text": json.dumps( + { + "summary": "reviewed", + "confidence": "medium", + "evidence": [], + } + ), + } + ], + }, + } + ) + completed = BoundedCompletedProcess( + args=("pi",), returncode=0, stdout=payload, stderr="" + ) + with tempfile.TemporaryDirectory() as tmp: + source_home = Path(tmp) / "pi" + source_home.mkdir() + (source_home / "settings.json").write_text( + json.dumps( + {"defaultProvider": "openai", "defaultModel": "gpt-test"} + ), + encoding="utf-8", + ) + (source_home / "auth.json").write_text( + json.dumps( + { + "openai": {"token": "selected-oauth"}, + "anthropic": {"token": "lateral-oauth"}, + } + ), + encoding="utf-8", + ) + observed: dict[str, object] = {} + + def bounded(_argv, **kwargs): + isolated_home = Path(kwargs["env"]["PI_CODING_AGENT_DIR"]) + observed.update( + home=isolated_home, + auth=json.loads( + (isolated_home / "auth.json").read_text(encoding="utf-8") + ), + environment=dict(kwargs["env"]), + ) + return completed + with ( - self.subTest(timeout=timeout), + patch.dict( + os.environ, + { + "PI_CODING_AGENT_DIR": str(source_home), + "OPENAI_API_KEY": "selected-key", + "ANTHROPIC_API_KEY": "lateral-key", + }, + clear=True, + ), patch( - "experiments.local_agent_dispatch.bounded_process.subprocess.Popen" - ) as popen, - self.assertRaisesRegex(ValueError, "timeout_seconds"), + "experiments.local_agent_dispatch.adapters." + "subprocess_cli.shutil.which", + return_value="/usr/local/bin/pi", + ), + patch( + "experiments.local_agent_dispatch.adapters." + "subprocess_cli.run_bounded", + side_effect=bounded, + ), ): - run_bounded( - [sys.executable, "-c", "pass"], + adapter = pi_adapter() + profile = adapter_execution_profile(adapter) + adapter.configure_execution_profile(profile) + result = adapter.run( + contract=contract, cwd=Path.cwd(), - timeout_seconds=timeout, + context_files={"app.py": "safe = True\n"}, + timeout_seconds=10.0, ) - popen.assert_not_called() - - def test_group_leader_is_not_reaped_before_kill_escalation(self) -> None: - process = Mock() - process.pid = 12345 - process.returncode = None - events: list[str] = [] - process.wait.side_effect = lambda **_kwargs: events.append("wait") or 0 - - def signal_group(_pid: int, sig: signal.Signals) -> None: - events.append("term" if sig == signal.SIGTERM else "kill") - - with ( - patch( - "experiments.local_agent_dispatch.bounded_process.os.killpg", - side_effect=signal_group, - ), - patch( - "experiments.local_agent_dispatch.bounded_process.time.sleep", - side_effect=lambda _seconds: events.append("grace"), - ), - ): - _terminate_process_group(process) - - self.assertEqual(events, ["term", "grace", "kill", "wait"]) - def test_timeout_terminates_descendant_process_group(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - marker = Path(tmp) / "descendant-survived" - child = ( - "import subprocess,sys,time;" - f"subprocess.Popen([sys.executable,'-c'," - f"\"import time,pathlib;time.sleep(1);" - f"pathlib.Path({str(marker)!r}).write_text('bad')\"]);" - "time.sleep(10)" - ) - completed = run_bounded( - [sys.executable, "-c", child], - cwd=Path(tmp), - timeout_seconds=0.2, + self.assertEqual(result.status, "ok") + environment = observed["environment"] + self.assertEqual(environment.get("OPENAI_API_KEY"), "selected-key") + self.assertNotIn("ANTHROPIC_API_KEY", environment) + self.assertEqual( + observed["auth"], + {"openai": {"token": "selected-oauth"}}, ) - self.assertTrue(completed.timed_out) - time.sleep(1.2) - self.assertFalse(marker.exists()) + self.assertNotEqual(Path(observed["home"]), source_home) + self.assertFalse(Path(observed["home"]).exists()) - def test_timeout_terminates_descendant_after_group_leader_exits(self) -> None: + def test_opencode_sync_run_uses_isolated_home_without_lateral_auth(self) -> None: + contract = parse_task_contract({**_payload(), "backend": "opencode"}) + payload = json.dumps( + { + "type": "text", + "part": { + "type": "text", + "text": json.dumps( + { + "summary": "reviewed", + "confidence": "medium", + "evidence": [], + } + ), + }, + } + ) + completed = BoundedCompletedProcess( + args=("opencode",), returncode=0, stdout=payload, stderr="" + ) + with tempfile.TemporaryDirectory() as tmp: + config_root = Path(tmp) / "config" + data_root = Path(tmp) / "data" + (config_root / "opencode").mkdir(parents=True) + (data_root / "opencode").mkdir(parents=True) + (config_root / "opencode" / "opencode.json").write_text( + json.dumps( + { + "model": "openai/reviewed-model", + "provider": { + "openai": {"options": {"apiKey": "selected"}}, + "anthropic": {"options": {"apiKey": "lateral"}}, + }, + } + ), + encoding="utf-8", + ) + (data_root / "opencode" / "auth.json").write_text( + json.dumps( + { + "openai": {"token": "selected-oauth"}, + "anthropic": {"token": "lateral-oauth"}, + } + ), + encoding="utf-8", + ) + observed: dict[str, object] = {} + + def bounded(_argv, **kwargs): + isolated_config = Path(kwargs["env"]["XDG_CONFIG_HOME"]) + isolated_data = Path(kwargs["env"]["XDG_DATA_HOME"]) + observed.update( + home=isolated_config.parent, + config=json.loads( + ( + isolated_config / "opencode" / "opencode.json" + ).read_text(encoding="utf-8") + ), + auth=json.loads( + (isolated_data / "opencode" / "auth.json").read_text( + encoding="utf-8" + ) + ), + environment=dict(kwargs["env"]), + ) + return completed + + with ( + patch.dict( + os.environ, + { + "XDG_CONFIG_HOME": str(config_root), + "XDG_DATA_HOME": str(data_root), + "OPENAI_API_KEY": "selected-key", + "ANTHROPIC_API_KEY": "lateral-key", + }, + clear=True, + ), + patch( + "experiments.local_agent_dispatch.adapters." + "subprocess_cli.shutil.which", + return_value="/usr/local/bin/opencode", + ), + patch( + "experiments.local_agent_dispatch.adapters." + "subprocess_cli.run_bounded", + side_effect=bounded, + ), + ): + adapter = opencode_adapter() + profile = adapter_execution_profile(adapter) + adapter.configure_execution_profile(profile) + result = adapter.run( + contract=contract, + cwd=Path.cwd(), + context_files={"app.py": "safe = True\n"}, + timeout_seconds=10.0, + ) + + self.assertEqual(result.status, "ok") + environment = observed["environment"] + self.assertEqual(environment.get("OPENAI_API_KEY"), "selected-key") + self.assertNotIn("ANTHROPIC_API_KEY", environment) + self.assertEqual(observed["config"]["model"], "openai/reviewed-model") + self.assertEqual( + set(observed["config"]["provider"]), + {"openai"}, + ) + self.assertEqual( + observed["auth"], + {"openai": {"token": "selected-oauth"}}, + ) + self.assertNotEqual(Path(observed["home"]), config_root) + self.assertFalse(Path(observed["home"]).exists()) + + def test_pi_authenticated_returns_false_when_profile_is_unselectable(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + source_home = Path(tmp) / "pi" + source_home.mkdir() + with ( + patch.dict( + os.environ, + {"PI_CODING_AGENT_DIR": str(source_home)}, + clear=True, + ), + patch( + "experiments.local_agent_dispatch.adapters." + "subprocess_cli.shutil.which", + return_value="/usr/local/bin/pi", + ), + ): + adapter = pi_adapter() + self.assertFalse(adapter_is_authenticated(adapter)) + self.assertFalse(adapter.authenticated()) + + def test_dsh_credential_probe_rejects_fifo_without_blocking(self) -> None: + if os.name != "posix" or not hasattr(os, "mkfifo"): + self.skipTest("FIFO semantics require POSIX") + with tempfile.TemporaryDirectory() as tmp: + home = Path(tmp) + fifo = home / ".credentials.yaml" + os.mkfifo(fifo, mode=0o600) + fifo.chmod(0o600) + with patch.dict( + os.environ, + {"DSH_HOME": str(home)}, + clear=True, + ): + started = time.monotonic() + self.assertFalse(_dsh_has_default_credential()) + self.assertLess(time.monotonic() - started, 0.5) + + def test_kimi_worker_home_pins_env_route_without_ambient_model_variables(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source_home = root / "source" + source_home.mkdir() + isolated_home = root / "isolated" + ambient = { + "KIMI_CODE_HOME": str(source_home), + "KIMI_MODEL_NAME": "reviewed-model", + "KIMI_MODEL_API_KEY": "test-selected-kimi-credential", + "KIMI_MODEL_PROVIDER_TYPE": "openai", + "KIMI_MODEL_BASE_URL": "https://provider.invalid/v1", + "KIMI_MODEL_MAX_CONTEXT_SIZE": "65536", + } + with ( + patch.dict(os.environ, ambient, clear=True), + patch( + "experiments.local_agent_dispatch.adapters." + "subprocess_cli.shutil.which", + return_value="/usr/local/bin/kimi", + ), + ): + adapter = kimi_adapter() + profile = adapter_execution_profile(adapter) + adapter.configure_execution_profile(profile) + worker_environment = adapter.worker_environment( + isolated_home=isolated_home + ) + + self.assertEqual(profile["provider"], "__kimi_env__") + self.assertEqual(profile["model"], "__kimi_env_model__") + self.assertRegex(profile["route_sha256"], r"^[0-9a-f]{64}$") + self.assertFalse( + any(name.startswith("KIMI_MODEL_") for name in worker_environment) + ) + self.assertEqual( + worker_environment["KIMI_CODE_HOME"], + str(isolated_home), + ) + isolated = tomllib.loads( + (isolated_home / "config.toml").read_text(encoding="utf-8") + ) + self.assertEqual(isolated["default_model"], "__kimi_env_model__") + self.assertEqual( + isolated["providers"]["__kimi_env__"]["type"], + "openai", + ) + self.assertEqual( + isolated["models"]["__kimi_env_model__"]["model"], + "reviewed-model", + ) + with ( + patch.dict(os.environ, worker_environment, clear=True), + patch( + "experiments.local_agent_dispatch.adapters." + "subprocess_cli.shutil.which", + return_value="/usr/local/bin/kimi", + ), + ): + self.assertEqual( + adapter_execution_profile(kimi_adapter()), + profile, + ) + + def test_kimi_worker_home_copies_only_selected_config_provider(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source_home = root / "source" + source_home.mkdir() + (source_home / "config.toml").write_text( + 'default_provider = "selected"\n' + 'default_model = "selected/model-a"\n\n' + '[providers.selected]\n' + 'type = "openai"\n' + 'api_key = "selected-test-credential"\n' + 'base_url = "https://selected.invalid/v1"\n\n' + '[providers.lateral]\n' + 'type = "anthropic"\n' + 'api_key = "lateral-test-credential"\n\n' + '[models."selected/model-a"]\n' + 'provider = "selected"\n' + 'model = "model-a"\n' + 'max_context_size = 32768\n\n' + '[models."lateral/model-b"]\n' + 'provider = "lateral"\n' + 'model = "model-b"\n' + 'max_context_size = 32768\n', + encoding="utf-8", + ) + isolated_home = root / "isolated" + with ( + patch.dict( + os.environ, + {"KIMI_CODE_HOME": str(source_home)}, + clear=True, + ), + patch( + "experiments.local_agent_dispatch.adapters." + "subprocess_cli.shutil.which", + return_value="/usr/local/bin/kimi", + ), + ): + adapter = kimi_adapter() + profile = adapter_execution_profile(adapter) + adapter.configure_execution_profile(profile) + adapter.worker_environment(isolated_home=isolated_home) + + isolated = tomllib.loads( + (isolated_home / "config.toml").read_text(encoding="utf-8") + ) + self.assertEqual(set(isolated["providers"]), {"selected"}) + self.assertEqual(set(isolated["models"]), {"selected/model-a"}) + self.assertNotIn("lateral", json.dumps(isolated)) + + def test_kimi_worker_home_scopes_file_oauth_and_binds_token_digest(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source_home = root / "source" + credentials = source_home / "credentials" + credentials.mkdir(parents=True, mode=0o700) + token = credentials / "kimi-code.json" + token.write_text( + json.dumps( + { + "access_token": "test-access", + "refresh_token": "test-refresh", + "expires_at": 4102444800, + } + ), + encoding="utf-8", + ) + token.chmod(0o600) + (source_home / "config.toml").write_text( + 'default_provider = "managed:kimi-code"\n' + 'default_model = "kimi/model-a"\n\n' + '[providers."managed:kimi-code"]\n' + 'type = "kimi"\n' + 'base_url = "https://api.kimi.com/coding/v1"\n\n' + '[providers."managed:kimi-code".oauth]\n' + 'storage = "file"\n' + 'key = "oauth/kimi-code"\n\n' + '[models."kimi/model-a"]\n' + 'provider = "managed:kimi-code"\n' + 'model = "model-a"\n' + 'max_context_size = 32768\n', + encoding="utf-8", + ) + isolated_home = root / "isolated" + with ( + patch.dict( + os.environ, + {"KIMI_CODE_HOME": str(source_home)}, + clear=True, + ), + patch( + "experiments.local_agent_dispatch.adapters." + "subprocess_cli.shutil.which", + return_value="/usr/local/bin/kimi", + ), + ): + adapter = kimi_adapter() + before = adapter_execution_profile(adapter) + adapter.configure_execution_profile(before) + adapter.worker_environment(isolated_home=isolated_home) + token.write_text( + json.dumps( + { + "access_token": "test-access-refreshed", + "refresh_token": "test-refresh", + "expires_at": 4102444800, + } + ), + encoding="utf-8", + ) + token.chmod(0o600) + after = adapter_execution_profile(kimi_adapter()) + + self.assertNotEqual(before["route_sha256"], after["route_sha256"]) + copied = isolated_home / "credentials" / "kimi-code.json" + self.assertTrue(copied.is_file()) + self.assertEqual(copied.stat().st_mode & 0o077, 0) + self.assertEqual( + {path.name for path in (isolated_home / "credentials").iterdir()}, + {"kimi-code.json"}, + ) + + def test_all_discovered_harnesses_pin_edit_capabilities(self) -> None: + contract = parse_task_contract( + { + **_payload(mode="edit"), + "backend": "fake", + } + ) + payload = json.dumps( + {"summary": "edited", "confidence": "medium", "evidence": []} + ) + expectations = { + "opencode": (opencode_adapter, ("run", "--format", "json", "--pure")), + "grok": (grok_adapter, ("--permission-mode", "acceptEdits")), + "hermes": (hermes_adapter, ("file",)), + "kimi": (kimi_adapter, ("--agent-file",)), + "dsh": (dsh_adapter, ("--profile", "headless")), + "pi": (pi_adapter, ("--tools", "read,grep,find,ls,edit,write")), + } + for backend, (factory, required) in expectations.items(): + with self.subTest(backend=backend): + completed = BoundedCompletedProcess( + args=(backend,), returncode=0, stdout=payload, stderr="" + ) + with ( + patch( + "experiments.local_agent_dispatch.adapters.subprocess_cli.shutil.which", + return_value=f"/usr/local/bin/{backend}", + ), + patch( + "experiments.local_agent_dispatch.adapters.subprocess_cli.run_bounded", + return_value=completed, + ) as run, + patch( + "experiments.local_agent_dispatch.adapters.subprocess_cli._hermes_python_runtime", + return_value=Path(sys.executable), + ), + patch( + "experiments.local_agent_dispatch.adapters.subprocess_cli." + "_materialize_kimi_worker_home", + ), + ): + adapter = factory() + profile = { + "backend": backend, + "command_path": f"/usr/local/bin/{backend}", + "provider": ( + "deepseek-official" + if backend == "dsh" + else "test-provider" + ), + "model": ( + "deepseek-v4-flash" + if backend == "dsh" + else "reviewed-model" + ), + } + if backend == "kimi": + profile["route_sha256"] = "a" * 64 + adapter.configure_execution_profile(profile) + adapter.run( + contract=contract, + cwd=Path.cwd(), + context_files={"app.py": "safe = True\n"}, + timeout_seconds=10.0, + ) + argv = [str(item) for item in run.call_args.args[0]] + for value in required: + self.assertIn(value, argv) + + def test_cursor_edit_fails_closed_before_spawn(self) -> None: + contract = parse_task_contract( + { + **_payload(mode="edit"), + "backend": "cursor-agent", + } + ) + with ( + patch( + "experiments.local_agent_dispatch.adapters.subprocess_cli.shutil.which", + return_value="/usr/local/bin/cursor-agent", + ), + patch( + "experiments.local_agent_dispatch.adapters.subprocess_cli.run_bounded" + ) as bounded, + ): + result = cursor_adapter().run( + contract=contract, + cwd=Path.cwd(), + context_files={"app.py": "safe = True\n"}, + timeout_seconds=10.0, + ) + self.assertEqual(result.error_code, "backend_mode_unsupported") + bounded.assert_not_called() + + def test_provider_credentials_are_selected_not_shared(self) -> None: + with patch.dict( + "os.environ", + { + "ANTHROPIC_API_KEY": "anthropic-secret", + "OPENAI_API_KEY": "openai-secret", + "XAI_API_KEY": "xai-secret", + }, + clear=True, + ): + self.assertEqual( + _provider_credentials("xai"), {"XAI_API_KEY": "xai-secret"} + ) + self.assertEqual( + _provider_credentials("openai-compatible"), + {"OPENAI_API_KEY": "openai-secret"}, + ) + self.assertEqual(_provider_credentials("openai-codex"), {}) + self.assertEqual(_provider_credentials("xai-oauth"), {}) + self.assertNotIn( + "ANTHROPIC_API_KEY", _provider_credentials("xai") + ) + + def test_provider_credentials_read_only_selected_dotenv_values(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + dotenv = Path(tmp) / ".env" + dotenv.write_text( + "XAI_API_KEY='selected'\n" + "ANTHROPIC_API_KEY=must-not-pass\n", + encoding="utf-8", + ) + with patch.dict("os.environ", {}, clear=True): + credentials = _provider_credentials( + "xai", dotenv_path=dotenv + ) + self.assertEqual(credentials, {"XAI_API_KEY": "selected"}) + + def test_temporary_dispatch_input_cleanup_failure_is_fatal(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + with ( + patch.object(Path, "unlink", side_effect=OSError("blocked")), + self.assertRaisesRegex( + DispatchValidationError, "could not be removed" + ), + ): + with _temporary_text_file( + Path(tmp), suffix=".md", content="private task" + ): + pass + + def test_cursor_dispatch_auth_requires_api_key_and_positive_status(self) -> None: + adapter = cursor_adapter() + with ( + patch.dict("os.environ", {}, clear=True), + patch( + "experiments.local_agent_dispatch.adapters.subprocess_cli.shutil.which", + return_value="/usr/local/bin/cursor-agent", + ), + patch( + "experiments.local_agent_dispatch.adapters.subprocess_cli.run_bounded" + ) as bounded, + ): + self.assertFalse(adapter.authenticated()) + bounded.assert_not_called() + + completed = BoundedCompletedProcess( + args=("cursor-agent",), + returncode=0, + stdout="Not logged in", + stderr="", + ) + with ( + patch.dict("os.environ", {"CURSOR_API_KEY": "test-key"}, clear=True), + patch( + "experiments.local_agent_dispatch.adapters.subprocess_cli.shutil.which", + return_value="/usr/local/bin/cursor-agent", + ), + patch( + "experiments.local_agent_dispatch.adapters.subprocess_cli.run_bounded", + return_value=completed, + ), + ): + self.assertFalse(adapter.authenticated()) + + def test_grok_auth_rejects_unauthenticated_model_listing(self) -> None: + completed = BoundedCompletedProcess( + args=("grok",), + returncode=0, + stdout=( + "You are not authenticated.\n" + "Available models:\n * grok-4.5\n" + ), + stderr="", + ) + with ( + patch( + "experiments.local_agent_dispatch.adapters.subprocess_cli.shutil.which", + return_value="/usr/local/bin/grok", + ), + patch( + "experiments.local_agent_dispatch.adapters.subprocess_cli.run_bounded", + return_value=completed, + ), + ): + self.assertFalse(grok_adapter().authenticated()) + + def test_hermes_auth_is_bound_to_selected_provider(self) -> None: + completed = BoundedCompletedProcess( + args=("hermes",), + returncode=0, + stdout=( + "OpenAI Codex ✓ logged in\n" + "Anthropic ✗ (not set)\n" + ), + stderr="", + ) + with ( + patch( + "experiments.local_agent_dispatch.adapters.subprocess_cli._hermes_python_runtime", + return_value=Path(sys.executable), + ), + patch( + "experiments.local_agent_dispatch.adapters.subprocess_cli._hermes_model_selection", + return_value=("anthropic", "claude-test"), + ), + patch( + "experiments.local_agent_dispatch.adapters.subprocess_cli.shutil.which", + return_value="/usr/local/bin/hermes", + ), + patch( + "experiments.local_agent_dispatch.adapters.subprocess_cli.run_bounded", + return_value=completed, + ), + ): + self.assertFalse(hermes_adapter().authenticated()) + + def test_hermes_bootstrap_disables_ambient_context_and_memory(self) -> None: + captured: dict[str, object] = {} + + class FakeAgent: + def __init__(self, **kwargs) -> None: + captured["kwargs"] = kwargs + captured["agent_home"] = os.environ.get("HERMES_HOME") + captured["agent_home_mode"] = stat.S_IMODE( + Path(str(captured["agent_home"])).stat().st_mode + ) + + def run_conversation(self, prompt): + captured["prompt"] = prompt + return {"final_response": "bounded result"} + + def close(self) -> None: + captured["closed"] = True + + hermes_cli = type(sys)("hermes_cli") + hermes_cli.__path__ = [] + hermes_config = type(sys)("hermes_cli.config") + hermes_config.load_config = lambda: {"ambient": True} + hermes_config.load_config_readonly = lambda: {"ambient": True} + hermes_env_loader = type(sys)("hermes_cli.env_loader") + hermes_env_loader.load_hermes_dotenv = lambda **kwargs: ["ambient"] + runtime_provider = type(sys)("hermes_cli.runtime_provider") + def resolve_runtime_provider(**kwargs): + captured["runtime_home"] = os.environ.get("HERMES_HOME") + return { + "provider": kwargs["requested"], + "requested_provider": kwargs["requested"], + "api_mode": "chat_completions", + "api_key": "test-key", + "base_url": "https://example.invalid", + "credential_pool": {"ambient": "must-not-pass"}, + } + + runtime_provider.resolve_runtime_provider = resolve_runtime_provider + run_agent = type(sys)("run_agent") + run_agent.AIAgent = FakeAgent + hermes_cli.config = hermes_config + hermes_cli.env_loader = hermes_env_loader + modules = { + "hermes_cli": hermes_cli, + "hermes_cli.config": hermes_config, + "hermes_cli.env_loader": hermes_env_loader, + "hermes_cli.runtime_provider": runtime_provider, + "run_agent": run_agent, + } + stdout = io.StringIO() + isolated_home = Path(tempfile.mkdtemp(prefix="dyro-hermes-test-")) + self.addCleanup(shutil.rmtree, isolated_home, True) + isolated_home.chmod(0o700) + with ( + patch.dict(sys.modules, modules), + patch.object( + sys, + "argv", + [ + "bootstrap", + "model", + "provider", + "clarify", + "/source/hermes", + str(isolated_home), + ], + ), + patch.object(sys, "stdin", io.StringIO("guarded prompt")), + patch.dict(os.environ, {"HERMES_HOME": str(isolated_home)}), + redirect_stdout(stdout), + self.assertRaises(SystemExit) as raised, + ): + exec(_HERMES_ONESHOT_BOOTSTRAP, {}) + + self.assertEqual(raised.exception.code, 0) + self.assertEqual(stdout.getvalue().strip(), "bounded result") + self.assertEqual(captured["prompt"], "guarded prompt") + self.assertTrue(captured["closed"]) + self.assertEqual(captured["runtime_home"], "/source/hermes") + self.assertNotEqual(captured["agent_home"], "/source/hermes") + self.assertEqual(captured["agent_home_mode"], 0o700) + self.assertTrue(Path(str(captured["agent_home"])).exists()) + kwargs = captured["kwargs"] + self.assertEqual(kwargs["enabled_toolsets"], ["clarify"]) + self.assertTrue(kwargs["skip_context_files"]) + self.assertFalse(kwargs["load_soul_identity"]) + self.assertTrue(kwargs["skip_memory"]) + self.assertTrue(kwargs["skip_background_review"]) + self.assertIsNone(kwargs["session_db"]) + self.assertIsNone(kwargs["fallback_model"]) + self.assertIsNone(kwargs["credential_pool"]) + self.assertFalse(kwargs["checkpoints_enabled"]) + self.assertEqual(hermes_env_loader.load_hermes_dotenv(), []) + self.assertEqual( + hermes_config.load_config_readonly()["context"]["engine"], + "compressor", + ) + + def test_hermes_bootstrap_rejects_runtime_tool_bypass(self) -> None: + class ForbiddenRuntimeAgent: + def __init__(self, **kwargs) -> None: + raise AssertionError("forbidden runtime must fail before Agent init") + + hermes_cli = type(sys)("hermes_cli") + hermes_cli.__path__ = [] + hermes_config = type(sys)("hermes_cli.config") + hermes_env_loader = type(sys)("hermes_cli.env_loader") + runtime_provider = type(sys)("hermes_cli.runtime_provider") + runtime_provider.resolve_runtime_provider = lambda **kwargs: { + "api_mode": "codex_app_server" + } + run_agent = type(sys)("run_agent") + run_agent.AIAgent = ForbiddenRuntimeAgent + hermes_cli.config = hermes_config + hermes_cli.env_loader = hermes_env_loader + modules = { + "hermes_cli": hermes_cli, + "hermes_cli.config": hermes_config, + "hermes_cli.env_loader": hermes_env_loader, + "hermes_cli.runtime_provider": runtime_provider, + "run_agent": run_agent, + } + with ( + patch.dict(sys.modules, modules), + patch.object( + sys, + "argv", + [ + "bootstrap", + "model", + "openai", + "clarify", + "/source/hermes", + "/isolated/hermes", + ], + ), + patch.object(sys, "stdin", io.StringIO("guarded prompt")), + self.assertRaisesRegex(RuntimeError, "unsupported runtime mode"), + ): + exec(_HERMES_ONESHOT_BOOTSTRAP, {}) + + def test_hermes_parent_cleans_home_after_forced_termination(self) -> None: + contract = parse_task_contract( + {**_payload(), "backend": "hermes"} + ) + cases = ( + BoundedCompletedProcess( + args=("hermes",), + returncode=-15, + stdout="", + stderr="", + timed_out=True, + ), + BoundedCompletedProcess( + args=("hermes",), + returncode=-15, + stdout="", + stderr="", + output_limited=True, + ), + ) + for completed in cases: + with self.subTest(completed=completed): + captured: dict[str, Path] = {} + + def bounded(argv, **kwargs): + home = Path(kwargs["env"]["HERMES_HOME"]) + captured["home"] = home + self.assertTrue(home.is_dir()) + self.assertEqual(stat.S_IMODE(home.stat().st_mode), 0o700) + self.assertEqual(Path(str(argv[-1])), home) + return completed + + with ( + patch( + "experiments.local_agent_dispatch.adapters.subprocess_cli.shutil.which", + return_value="/usr/local/bin/hermes", + ), + patch( + "experiments.local_agent_dispatch.adapters.subprocess_cli._hermes_python_runtime", + return_value=Path(sys.executable), + ), + patch( + "experiments.local_agent_dispatch.adapters.subprocess_cli._hermes_model_selection", + return_value=("xai-oauth", "grok-test"), + ), + patch( + "experiments.local_agent_dispatch.adapters.subprocess_cli.run_bounded", + side_effect=bounded, + ), + ): + hermes_adapter().run( + contract=contract, + cwd=Path.cwd(), + context_files={"app.py": "safe = True\n"}, + timeout_seconds=10.0, + ) + self.assertFalse(captured["home"].exists()) + + def test_hermes_auth_supports_non_legacy_provider_labels(self) -> None: + completed = BoundedCompletedProcess( + args=("hermes",), + returncode=0, + stdout="Z.AI / GLM ✓ logged in\n", + stderr="", + ) + with ( + patch( + "experiments.local_agent_dispatch.adapters.subprocess_cli._hermes_python_runtime", + return_value=Path(sys.executable), + ), + patch( + "experiments.local_agent_dispatch.adapters.subprocess_cli._hermes_model_selection", + return_value=("z-ai", "glm-test"), + ), + patch( + "experiments.local_agent_dispatch.adapters.subprocess_cli.shutil.which", + return_value="/usr/local/bin/hermes", + ), + patch( + "experiments.local_agent_dispatch.adapters.subprocess_cli.run_bounded", + return_value=completed, + ), + ): + self.assertTrue(hermes_adapter().authenticated()) + + def test_backend_failure_does_not_persist_raw_stderr(self) -> None: + result = _completed_to_result( + BoundedCompletedProcess( + args=("codex",), + returncode=7, + stdout="untrusted output", + stderr="secret-token-value", + ), + backend="codex", + ) + self.assertEqual(result.summary, "") + self.assertEqual(result.raw_preview, "") + self.assertNotIn("secret-token-value", " ".join(result.warnings)) + + def test_setup_failure_terminates_spawned_process_group(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + marker = Path(tmp) / "setup-failure-survivor" + child = ( + "import pathlib,time;" + "time.sleep(0.5);" + f"pathlib.Path({str(marker)!r}).write_text('bad')" + ) + with ( + patch( + "experiments.local_agent_dispatch.bounded_process.os.set_blocking", + side_effect=OSError("simulated setup failure"), + ), + self.assertRaisesRegex(OSError, "setup failure"), + ): + run_bounded( + [sys.executable, "-c", child], + cwd=Path(tmp), + timeout_seconds=2.0, + ) + time.sleep(0.8) + self.assertFalse(marker.exists()) + + def test_non_finite_timeout_is_rejected_before_spawn(self) -> None: + for timeout in (float("nan"), float("inf"), float("-inf")): + with ( + self.subTest(timeout=timeout), + patch( + "experiments.local_agent_dispatch.bounded_process.subprocess.Popen" + ) as popen, + self.assertRaisesRegex(ValueError, "timeout_seconds"), + ): + run_bounded( + [sys.executable, "-c", "pass"], + cwd=Path.cwd(), + timeout_seconds=timeout, + ) + popen.assert_not_called() + + def test_group_leader_is_not_reaped_before_kill_escalation(self) -> None: + process = Mock() + process.pid = 12345 + process.returncode = None + events: list[str] = [] + process.wait.side_effect = lambda **_kwargs: events.append("wait") or 0 + + def signal_group(_pid: int, sig: signal.Signals) -> None: + events.append("term" if sig == signal.SIGTERM else "kill") + + with ( + patch( + "experiments.local_agent_dispatch.bounded_process.os.killpg", + side_effect=signal_group, + ), + patch( + "experiments.local_agent_dispatch.bounded_process.time.sleep", + side_effect=lambda _seconds: events.append("grace"), + ), + ): + _terminate_process_group(process) + + self.assertEqual(events, ["term", "grace", "kill", "wait"]) + + def test_timeout_terminates_descendant_process_group(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + marker = Path(tmp) / "descendant-survived" + child = ( + "import subprocess,sys,time;" + f"subprocess.Popen([sys.executable,'-c'," + f"\"import time,pathlib;time.sleep(1);" + f"pathlib.Path({str(marker)!r}).write_text('bad')\"]);" + "time.sleep(10)" + ) + completed = run_bounded( + [sys.executable, "-c", child], + cwd=Path(tmp), + timeout_seconds=0.2, + ) + self.assertTrue(completed.timed_out) + time.sleep(1.2) + self.assertFalse(marker.exists()) + + def test_timeout_terminates_descendant_after_group_leader_exits(self) -> None: with tempfile.TemporaryDirectory() as tmp: marker = Path(tmp) / "orphan-survived" child = ( @@ -1234,6 +2576,30 @@ def test_timeout_terminates_descendant_after_group_leader_exits(self) -> None: time.sleep(1.2) self.assertFalse(marker.exists()) + def test_successful_leader_cannot_leave_closed_stdio_descendant(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + marker = Path(tmp) / "closed-stdio-descendant-survived" + descendant = ( + "import pathlib,time;" + "time.sleep(0.7);" + f"pathlib.Path({str(marker)!r}).write_text('bad')" + ) + leader = ( + "import subprocess,sys;" + "subprocess.Popen(" + f"[sys.executable,'-c',{descendant!r}]," + "stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL," + "stderr=subprocess.DEVNULL)" + ) + completed = run_bounded( + [sys.executable, "-c", leader], + cwd=Path(tmp), + timeout_seconds=2.0, + ) + self.assertEqual(completed.returncode, 0) + time.sleep(0.9) + self.assertFalse(marker.exists()) + def test_output_limit_terminates_backend(self) -> None: completed = run_bounded( [ @@ -1572,6 +2938,61 @@ def test_backend_cleanup_fails_closed_without_posix_process_groups( worker_token="generation", ) + def test_backend_cleanup_never_escalates_after_lifetime_lock_release( + self, + ) -> None: + with tempfile.TemporaryDirectory() as tmp: + store = supervisor_module.RunStore(Path(tmp) / "home") + lifetime_path = store.root / "run-1.backend.lifetime" + record = RunRecord( + run_id="run-1", + status="running", + contract={}, + project_root=str(Path(tmp)), + backend="codex", + created_at=1.0, + updated_at=1.0, + worker_token="generation", + worker_pid=12, + worker_started_at="worker-generation", + backend_pid=42, + backend_pgid=42, + backend_started_at="backend-generation", + backend_lock_path=str(lifetime_path), + ) + + with ( + patch( + "experiments.local_agent_dispatch.run_store." + "file_lock_is_held", + side_effect=[True, False, False], + ), + patch( + "experiments.local_agent_dispatch.run_store." + "process_group_has_live_members", + side_effect=[True, True], + ), + patch( + "experiments.local_agent_dispatch.run_store." + "process_started_at", + return_value="backend-generation", + ), + patch( + "experiments.local_agent_dispatch.run_store.os.getpgid", + return_value=42, + ), + patch( + "experiments.local_agent_dispatch.run_store.os.killpg" + ) as kill_group, + patch( + "experiments.local_agent_dispatch.run_store.time.monotonic", + side_effect=[0.0, 1.0, 2.0, 3.0], + ), + ): + self.assertFalse(store._backend_cleanup_proven(record)) + + kill_group.assert_called_once_with(42, signal.SIGTERM) + def test_reaper_cannot_terminalize_unproven_backend_cleanup(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -1786,6 +3207,47 @@ def launch_process(*_args, **_kwargs): self.assertIn("thread unavailable", failed.error) self.assertNotIn(process, supervisor_module._ASYNC_WORKERS) + def test_duplicate_async_spawn_cannot_remove_reserved_worker_profile_home( + self, + ) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + project = root / "project" + project.mkdir() + (project / "app.py").write_text("safe = True\n", encoding="utf-8") + home = root / "home" + supervisor = DispatchSupervisor(home=home) + contract = parse_task_contract( + { + **_payload(), + "backend": "pi", + "allow_unconfined_provider": True, + } + ) + record = supervisor.store.create( + contract=contract, + project_root=project, + backend="pi", + ) + supervisor.store.reserve_async_worker( + record.run_id, + worker_token="first-generation", + ) + profile_home = ( + home / "runs" / f".{record.run_id}.pi.home" + ) + profile_home.mkdir() + marker = profile_home / "first-worker-auth" + marker.write_text("owned", encoding="utf-8") + + with self.assertRaisesRegex( + DispatchValidationError, + "not available for async spawn", + ): + supervisor.spawn_worker(record.run_id) + + self.assertEqual(marker.read_text(encoding="utf-8"), "owned") + def test_startup_timeout_has_no_concurrent_reaper_waiter(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -1941,10 +3403,17 @@ def test_new_supervisor_reconciles_worker_killed_after_parent_cli_exit( payload["allow_unconfined_provider"] = True task_file = root / "task.json" task_file.write_text(json.dumps(payload), encoding="utf-8") + codex_home = root / "codex-home" + codex_home.mkdir() + (codex_home / "config.toml").write_text( + 'model = "test-model"\n', + encoding="utf-8", + ) environment = dict(os.environ) environment["PATH"] = ( f"{fake_bin}{os.pathsep}{environment.get('PATH', '')}" ) + environment["CODEX_HOME"] = str(codex_home) worker_pid = 0 backend_pid = 0 try: @@ -1981,9 +3450,16 @@ def test_new_supervisor_reconciles_worker_killed_after_parent_cli_exit( worker_pid = running.worker_pid self.assertGreater(worker_pid, 0) backend_deadline = time.time() + 2.0 + observed_backend_pid = 0 while time.time() < backend_deadline: running = store.load(run_id) - if backend_pid_path.is_file() and running.backend_pid > 0: + try: + observed_backend_pid = int( + backend_pid_path.read_text(encoding="utf-8").strip() + ) + except (FileNotFoundError, ValueError): + observed_backend_pid = 0 + if observed_backend_pid > 0 and running.backend_pid > 0: break time.sleep(0.02) worker_log = ( @@ -1993,9 +3469,8 @@ def test_new_supervisor_reconciles_worker_killed_after_parent_cli_exit( backend_pid_path.is_file(), f"record={running.to_mapping()!r} log={worker_log!r}", ) - backend_pid = int( - backend_pid_path.read_text(encoding="utf-8").strip() - ) + backend_pid = observed_backend_pid + self.assertGreater(backend_pid, 0) self.assertNotEqual(running.backend_pid, backend_pid) self.assertEqual( running.backend_pgid, @@ -2026,7 +3501,11 @@ def test_new_supervisor_reconciles_worker_killed_after_parent_cli_exit( DispatchSupervisor(home=home) failed = store.load(run_id) - self.assertEqual(failed.status, "failed") + self.assertEqual( + failed.status, + "failed", + f"record={failed.to_mapping()!r}", + ) self.assertIn("worker process exited", failed.error) self.assertEqual(failed.backend_pid, 0) self.assertEqual(failed.backend_pgid, 0) @@ -2471,7 +3950,7 @@ def test_top_level_root_does_not_override_equals_project_argument(self) -> None: self.assertNotIn("/global-project", forwarded) def test_top_level_root_is_injected_after_dispatch_home_option(self) -> None: - for command in ("run", "panel"): + for command in ("run", "panel", "batch-plan", "batch-start"): with self.subTest(command=command): surface, forwarded = _route_experiment_surface( [ diff --git a/tests/test_batch_contract_store.py b/tests/test_batch_contract_store.py new file mode 100644 index 0000000..2240190 --- /dev/null +++ b/tests/test_batch_contract_store.py @@ -0,0 +1,304 @@ +from __future__ import annotations + +from dataclasses import replace +import json +import math +import os +from pathlib import Path +import tempfile +import unittest + +from experiments.local_agent_dispatch.batch_contract import ( + BatchMemberPlan, + BatchPlan, + batch_plan_sha256, + effects_for_members, + parse_batch_request, +) +from experiments.local_agent_dispatch.errors import DispatchValidationError +from experiments.local_agent_dispatch.orchestration_store import ( + MAX_MANIFEST_BYTES, + OrchestrationStore, + orchestration_id_for, + run_id_for, +) + + +def _contract(*, backend: str = "codex", mode: str = "read-only") -> dict: + return { + "schema_version": 1, + "backend": backend, + "mode": mode, + "strict": False, + "allow_unconfined_provider": True, + "allow_offline_simulation": False, + "files": ["src/*.py"], + "task": { + "briefing": "small project", + "locations": "src/", + "objective": "review the implementation", + "constraints": "do not mutate unrelated files", + "output_contract": "summary and evidence", + }, + } + + +def _request(*, request_id: str = "batch-001") -> dict: + return { + "schema_version": 1, + "request_id": request_id, + "strategy": "independent", + "members": [ + { + "role_id": "reviewer", + "timeout_seconds": 60, + "contract": _contract(), + }, + { + "role_id": "challenger", + "timeout_seconds": 90, + "contract": _contract(backend="claude"), + }, + ], + } + + +def _plan(project_root: Path, *, request_id: str = "batch-001") -> BatchPlan: + request = parse_batch_request(_request(request_id=request_id)) + members = tuple( + BatchMemberPlan( + role_id=member.role_id, + resolved_backend=member.contract.backend, + context_file_count=1, + context_sha256=("a" if index == 0 else "b") * 64, + base_head=None, + execution_profile={ + "backend": member.contract.backend, + "command_path": member.contract.backend, + }, + timeout_seconds=member.timeout_seconds, + normalized_contract=member.contract.to_mapping(), + ) + for index, member in enumerate(request.members) + ) + return BatchPlan( + project_root=project_root, + request_id=request.request_id, + strategy=request.strategy, + effects=effects_for_members(members), + members=members, + ) + + +class BatchContractTests(unittest.TestCase): + def test_strict_schema_unknown_fields_and_complete_contract(self) -> None: + valid = _request() + self.assertEqual(parse_batch_request(valid).to_mapping(), valid) + for mutation in ("schema", "top_unknown", "member_unknown", "task_unknown"): + with self.subTest(mutation=mutation): + payload = _request() + if mutation == "schema": + payload["schema_version"] = 2 + elif mutation == "top_unknown": + payload["surprise"] = True + elif mutation == "member_unknown": + payload["members"][0]["surprise"] = True + else: + payload["members"][0]["contract"]["task"]["surprise"] = True + with self.assertRaises(DispatchValidationError): + parse_batch_request(payload) + + incomplete = _request() + del incomplete["members"][0]["contract"]["strict"] + with self.assertRaisesRegex(DispatchValidationError, "missing required"): + parse_batch_request(incomplete) + + def test_rejects_unsafe_roles_timeouts_multiple_edits_and_simulation(self) -> None: + cases = [] + unsafe = _request() + unsafe["members"][0]["role_id"] = "../reviewer" + cases.append(unsafe) + duplicate = _request() + duplicate["members"][1]["role_id"] = "reviewer" + cases.append(duplicate) + timeout = _request() + timeout["members"][0]["timeout_seconds"] = math.inf + cases.append(timeout) + too_slow = _request() + too_slow["members"][0]["timeout_seconds"] = 3601 + cases.append(too_slow) + edits = _request() + edits["members"][0]["contract"]["mode"] = "edit" + edits["members"][1]["contract"]["mode"] = "edit" + cases.append(edits) + echo = _request() + echo["members"][0]["contract"]["backend"] = "echo" + cases.append(echo) + offline = _request() + offline["members"][0]["contract"]["allow_offline_simulation"] = True + cases.append(offline) + for index, payload in enumerate(cases): + with self.subTest(index=index), self.assertRaises( + DispatchValidationError + ): + parse_batch_request(payload) + + def test_rejects_batch_request_that_cannot_fit_persisted_manifest(self) -> None: + payload = _request() + for member in payload["members"]: + for field in member["contract"]["task"]: + member["contract"]["task"][field] = "x" * 150_000 + with self.assertRaisesRegex(DispatchValidationError, "batch request exceeds"): + parse_batch_request(payload) + + def test_plan_digest_is_canonical_and_excludes_manifest_time(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + first = _plan(root / "child" / "..") + reordered_effects = dict(reversed(list(first.effects.items()))) + second = replace(first, effects=reordered_effects) + self.assertEqual(first.project_root, str(root.resolve())) + self.assertEqual(batch_plan_sha256(first), batch_plan_sha256(second)) + self.assertEqual(first.plan_sha256, batch_plan_sha256(first)) + self.assertEqual( + first.to_mapping()["kind"], "local-agent-dispatch-batch-plan" + ) + self.assertNotIn("plan_sha256", first.to_canonical_mapping()) + + def test_plan_base_head_tracks_edit_effect_only(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + read_only = _plan(Path(tmp)) + with self.assertRaisesRegex(DispatchValidationError, "read-only.*null"): + replace(read_only.members[0], base_head="c" * 40) + + edit_contract = _contract(mode="edit") + with self.assertRaisesRegex(DispatchValidationError, "edit.*Git hash"): + replace( + read_only.members[0], + normalized_contract=edit_contract, + base_head=None, + ) + edited = replace( + read_only.members[0], + normalized_contract=edit_contract, + base_head="c" * 40, + ) + self.assertEqual(edited.base_head, "c" * 40) + + +class OrchestrationStoreTests(unittest.TestCase): + def test_create_load_is_idempotent_and_rejects_request_conflict(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + home = Path(tmp) / "home" + plan = _plan(Path(tmp) / "project") + store = OrchestrationStore(home) + first = store.create_or_load(plan) + second = store.create_or_load(plan) + self.assertEqual(first.to_mapping(), second.to_mapping()) + self.assertEqual( + first.orchestration_id, + orchestration_id_for(plan.request_id, batch_plan_sha256(plan)), + ) + self.assertEqual( + [member.run_id for member in first.members], + [run_id_for(first.orchestration_id, 0), run_id_for(first.orchestration_id, 1)], + ) + + changed_member = replace( + plan.members[0], context_sha256="d" * 64 + ) + changed_members = (changed_member, plan.members[1]) + changed = replace( + plan, + members=changed_members, + effects=effects_for_members(changed_members), + ) + with self.assertRaisesRegex(DispatchValidationError, "different plan"): + store.create_or_load(changed) + + def test_load_rejects_symlink_oversize_and_corrupt_manifest(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + home = Path(tmp) / "home" + store = OrchestrationStore(home) + plan = _plan(Path(tmp) / "project") + manifest = store.create_or_load(plan) + path = store.root / f"{manifest.orchestration_id}.json" + + good = path.read_bytes() + path.unlink() + target = Path(tmp) / "target.json" + target.write_bytes(good) + os.symlink(target, path) + with self.assertRaisesRegex(DispatchValidationError, "symbolic link"): + store.load(manifest.orchestration_id) + + path.unlink() + path.write_bytes(b"{" + (b" " * MAX_MANIFEST_BYTES) + b"}") + with self.assertRaisesRegex(DispatchValidationError, "exceeds"): + store.load(manifest.orchestration_id) + + path.write_text("{not-json", encoding="utf-8") + with self.assertRaisesRegex(DispatchValidationError, "corrupt"): + store.load(manifest.orchestration_id) + + if os.name == "posix" and hasattr(os, "mkfifo"): + path.unlink() + os.mkfifo(path) + errors: list[Exception] = [] + + def load_fifo() -> None: + try: + store.load(manifest.orchestration_id) + except Exception as exc: # noqa: BLE001 - asserted below + errors.append(exc) + + import threading + + reader = threading.Thread(target=load_fifo) + reader.start() + reader.join(timeout=0.5) + was_blocked = reader.is_alive() + if was_blocked: + descriptor = os.open( + path, + os.O_RDWR | getattr(os, "O_NONBLOCK", 0), + ) + os.close(descriptor) + reader.join(timeout=1.0) + self.assertFalse( + was_blocked, + "orchestration manifest reader blocked on FIFO", + ) + self.assertFalse(reader.is_alive()) + self.assertEqual(len(errors), 1) + self.assertIn("regular file", str(errors[0])) + + def test_load_rejects_tampering_and_cancel_is_cas_idempotent(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + home = Path(tmp) / "home" + store = OrchestrationStore(home) + manifest = store.create_or_load(_plan(Path(tmp) / "project")) + with self.assertRaisesRegex(DispatchValidationError, "revision conflict"): + store.request_cancel( + manifest.orchestration_id, expected_revision=manifest.revision + 1 + ) + cancelled = store.request_cancel( + manifest.orchestration_id, expected_revision=manifest.revision + ) + self.assertTrue(cancelled.cancel_requested) + self.assertEqual(cancelled.revision, manifest.revision + 1) + repeated = store.request_cancel( + manifest.orchestration_id, expected_revision=manifest.revision + ) + self.assertEqual(cancelled.to_mapping(), repeated.to_mapping()) + + path = store.root / f"{manifest.orchestration_id}.json" + payload = json.loads(path.read_text(encoding="utf-8")) + payload["members"][0]["run_id"] = "run-0000000000000000" + path.write_text(json.dumps(payload), encoding="utf-8") + with self.assertRaisesRegex(DispatchValidationError, "run_id"): + store.load(manifest.orchestration_id) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli.py b/tests/test_cli.py index ff3e01d..05763e7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -6,7 +6,7 @@ import unittest from contextlib import redirect_stderr, redirect_stdout from io import StringIO -from unittest.mock import Mock, patch +from unittest.mock import Mock, call, patch from dyro.cli import ( _print_doctor_finding, @@ -349,6 +349,8 @@ def test_setup_offers_every_detected_launchable_provider(self) -> None: "opencode", "hermes", "kimi", + "dsh", + "pi", "qodercli", } output = StringIO() @@ -677,9 +679,47 @@ def test_apply_setup_personal_preferences_installs_skill_when_requested( outcome = _apply_setup_personal_preferences(preferences) set_enabled.assert_called_once_with(True) set_auto.assert_called_once_with(False) - sync.assert_called_once_with(yes=True, allow_first_install=True) + self.assertEqual( + sync.call_args_list, + [ + call("skill", yes=True, allow_first_install=True), + call("dispatch", yes=True, allow_first_install=True), + ], + ) self.assertEqual(outcome, "success") + def test_setup_skill_preference_offers_missing_dispatch_companion(self) -> None: + from dyro.cli import _setup_skill_preference + from dyro.integrations import AvatarStatus, IntegrationState, IntegrationStatus + + statuses = { + "skill": IntegrationStatus( + "skill", + IntegrationState.CURRENT, + Path("/tmp/control"), + Path("/tmp/control.json"), + "current", + avatars=( + AvatarStatus("codex", Path("/tmp/codex"), "current", "current"), + ), + ), + "dispatch": IntegrationStatus( + "dispatch", + IntegrationState.ABSENT, + Path("/tmp/dispatch"), + Path("/tmp/dispatch.json"), + "absent", + avatars=( + AvatarStatus("codex", Path("/tmp/codex"), "missing", "missing"), + ), + ), + } + with ( + patch("dyro.cli.integration_status", side_effect=statuses.__getitem__), + patch("builtins.input", return_value=""), + ): + self.assertTrue(_setup_skill_preference()) + def test_print_setup_completion_reflects_skill_failure(self) -> None: from dyro.cli import SetupPersonalPreferences, _print_setup_completion diff --git a/tests/test_console_assets.py b/tests/test_console_assets.py index c9673aa..639b3cc 100644 --- a/tests/test_console_assets.py +++ b/tests/test_console_assets.py @@ -69,6 +69,10 @@ def test_shell_exposes_a_semantic_command_center(self) -> None: script = load_asset("app.js") self.assertIn(b"AVAILABILITY_LABELS", script.body) self.assertIn("任务总数".encode(), script.body) + self.assertIn(b"function workspaceCount", script.body) + self.assertIn("仓库".encode(), script.body) + self.assertIn("状态不完整".encode(), script.body) + self.assertIn("—".encode(), script.body) if __name__ == "__main__": diff --git a/tests/test_console_inspection.py b/tests/test_console_inspection.py index 0d17c53..5c6ca80 100644 --- a/tests/test_console_inspection.py +++ b/tests/test_console_inspection.py @@ -5,14 +5,16 @@ import json import os from pathlib import Path +import queue import subprocess import unittest from unittest.mock import Mock, patch from dyro.canonical import canonical_json_bytes +from dyro.console import _inspect_worker from dyro.console.inspection import IsolatedOverviewService from dyro.console.overview import ConsoleOverviewError -from dyro.hub import add_workspace +from dyro.hub import WorkspaceRecord, WorkspaceRegistry, add_workspace from .support import WorkspaceCase @@ -41,9 +43,71 @@ def test_exec_worker_returns_overview_and_single_workspace_without_root_disclosu self.assertEqual(overview["data"]["workspaces"][0]["alias"], "demo") self.assertEqual(workspace["data"]["workspace"]["alias"], "demo") + self.assertEqual(overview["data"]["workspaces"][0]["availability"], "available") + self.assertEqual(workspace["data"]["workspace"]["availability"], "available") self.assertNotIn(str(self.root), repr(overview)) self.assertNotIn(str(self.root), repr(workspace)) + def test_default_workspace_budget_tolerates_process_startup_overhead(self) -> None: + clock = [0.0] + record = WorkspaceRecord(name="demo", root=self.root) + registry = WorkspaceRegistry(default="demo", workspaces=(record,)) + available = _inspect_worker._unavailable_summary("demo", "IGNORED") + available.update( + { + "availability": "available", + "health": "healthy", + "freshness": "fresh", + "recommendation": None, + } + ) + + class DelayedQueue: + def get_nowait(self) -> object: + if clock[0] < 1.0: + raise queue.Empty + return {"summary": available, "warnings": []} + + def get(self, *, timeout: float) -> object: + del timeout + return self.get_nowait() + + def close(self) -> None: + return None + + class DelayedProcess: + def start(self) -> None: + return None + + def is_alive(self) -> bool: + return clock[0] < 1.0 + + def terminate(self) -> None: + return None + + def join(self, *, timeout: float) -> None: + del timeout + + context = Mock() + context.Queue.return_value = DelayedQueue() + context.Process.return_value = DelayedProcess() + + with ( + patch("dyro.console._inspect_worker.get_context", return_value=context), + patch( + "dyro.console._inspect_worker.time.monotonic", + side_effect=lambda: clock[0], + ), + patch( + "dyro.console._inspect_worker.time.sleep", + side_effect=lambda seconds: clock.__setitem__(0, clock[0] + seconds), + ), + ): + summaries, warnings = _inspect_worker._isolated_summaries(registry) + + self.assertEqual(warnings, set()) + self.assertEqual(summaries[0]["availability"], "available") + def test_temporary_root_is_read_without_registering_it_globally(self) -> None: service = IsolatedOverviewService( registry_state_home=self.root / "unrelated-state", diff --git a/tests/test_dispatch_cancellation.py b/tests/test_dispatch_cancellation.py new file mode 100644 index 0000000..003f853 --- /dev/null +++ b/tests/test_dispatch_cancellation.py @@ -0,0 +1,524 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +import sys +import tempfile +import threading +import time +import unittest +from unittest.mock import patch + +from experiments.local_agent_dispatch.adapters.base import AdapterResult +from experiments.local_agent_dispatch.adapters.registry import ( + adapter_execution_profile_sha256, +) +from experiments.local_agent_dispatch.adapters.subprocess_cli import ( + SubprocessCliAdapter, + claude_adapter, + codex_adapter, + cursor_adapter, + dsh_adapter, + grok_adapter, + hermes_adapter, + kimi_adapter, + opencode_adapter, + pi_adapter, +) +from experiments.local_agent_dispatch.bounded_process import run_bounded +from experiments.local_agent_dispatch.errors import DispatchValidationError +from experiments.local_agent_dispatch.run_store import ( + MAX_CANCEL_REASON_CHARS, + MAX_RUN_STATE_BYTES, + RunRecord, + RunStore, +) +from experiments.local_agent_dispatch.supervisor import ( + DispatchSupervisor, + _worker_environment, +) +from experiments.local_agent_dispatch.task_contract import parse_task_contract + + +def _payload(*, backend: str = "fake") -> dict[str, object]: + return { + "schema_version": 1, + "backend": backend, + "mode": "read-only", + "strict": False, + "files": ["app.py"], + "task": { + "briefing": "Inspect the supplied file.", + "locations": "app.py", + "objective": "Return a bounded result.", + "constraints": "Do not touch production actions.", + "output_contract": "JSON summary and evidence.", + }, + } + + +class _SlowSubprocessAdapter(SubprocessCliAdapter): + def __init__(self) -> None: + super().__init__(backend_id="fake", command=sys.executable) + self.started = threading.Event() + + def run(self, *, contract, cwd, context_files, timeout_seconds): + del contract, context_files + self.started.set() + return self._run( + [sys.executable, "-c", "import time; time.sleep(30)"], + cwd=cwd, + prompt="", + timeout_seconds=timeout_seconds, + ) + + +class _CooperativeAdapter: + id = "fake" + command = "fake" + strict_isolation = False + supported_modes = frozenset({"read-only", "edit"}) + + def __init__(self) -> None: + self.started = threading.Event() + self._cancel_check = lambda: False + + def available(self) -> bool: + return True + + def authenticated(self) -> bool: + return True + + def configure_cancellation(self, *, cancel_check) -> None: + self._cancel_check = cancel_check + + def run(self, *, contract, cwd, context_files, timeout_seconds): + del contract, cwd, context_files, timeout_seconds + self.started.set() + deadline = time.monotonic() + 5.0 + while time.monotonic() < deadline: + if self._cancel_check(): + return AdapterResult( + status="cancelled", + summary="", + error_code="cancelled", + ) + time.sleep(0.01) + raise AssertionError("cancellation was not observed") + + +class RunStoreCancellationTests(unittest.TestCase): + def _store_and_contract(self, root: Path): + project = root / "project" + project.mkdir() + (project / "app.py").write_text("safe = True\n", encoding="utf-8") + return RunStore(root / "home"), parse_task_contract(_payload()), project + + def test_accepted_running_and_terminal_cancellation_are_idempotent(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + store, contract, project = self._store_and_contract(Path(tmp)) + + accepted = store.create( + contract=contract, + project_root=project, + backend="fake", + ) + cancelled = store.request_cancel(accepted.run_id, reason="operator stop") + repeated = store.request_cancel(accepted.run_id, reason="later reason") + self.assertEqual(cancelled.status, "cancelled") + self.assertGreater(cancelled.cancel_requested_at, 0) + self.assertEqual(cancelled.cancel_reason, "operator stop") + self.assertEqual(repeated.revision, cancelled.revision) + self.assertEqual(repeated.cancel_reason, "operator stop") + + running = store.create( + contract=contract, + project_root=project, + backend="fake", + ) + running = store.claim_for_execution( + running.run_id, + worker_token="right-generation", + lease_slots=[], + ) + requested = store.request_cancel(running.run_id, reason="stop running") + self.assertEqual(requested.status, "running") + self.assertFalse( + store.cancel_requested( + running.run_id, + worker_token="wrong-generation", + ) + ) + self.assertTrue( + store.cancel_requested( + running.run_id, + worker_token="right-generation", + ) + ) + + terminal = store.create( + contract=contract, + project_root=project, + backend="fake", + ) + terminal = store.claim_for_execution( + terminal.run_id, + worker_token="terminal-generation", + lease_slots=[], + ) + terminal = store.update_status( + terminal.run_id, + "completed", + result={}, + expected_worker_token="terminal-generation", + ) + unchanged = store.request_cancel(terminal.run_id, reason="too late") + self.assertEqual(unchanged.status, "completed") + self.assertEqual(unchanged.revision, terminal.revision) + self.assertEqual(unchanged.cancel_requested_at, 0) + + def test_cancel_fields_are_backward_compatible_and_bounded(self) -> None: + payload = RunRecord( + run_id="run-old", + status="accepted", + contract={}, + project_root="/tmp/project", + backend="echo", + created_at=1.0, + updated_at=1.0, + ).to_mapping() + payload.pop("cancel_requested_at") + payload.pop("cancel_reason") + payload.pop("orchestration_id") + payload.pop("planned_context_sha256") + payload.pop("planned_base_head") + payload.pop("planned_execution_profile_sha256") + payload.pop("planned_execution_profile") + restored = RunRecord.from_mapping(payload) + self.assertEqual(restored.cancel_requested_at, 0) + self.assertEqual(restored.cancel_reason, "") + self.assertEqual(restored.orchestration_id, "") + + payload["cancel_requested_at"] = 1.0 + payload["cancel_reason"] = "x" * (MAX_CANCEL_REASON_CHARS + 1) + with self.assertRaisesRegex(DispatchValidationError, "character limit"): + RunRecord.from_mapping(payload) + + def test_run_state_reads_are_nofollow_bounded_and_fail_closed(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + store, contract, project = self._store_and_contract(Path(tmp)) + record = store.create( + contract=contract, + project_root=project, + backend="fake", + ) + path = store.root / f"{record.run_id}.json" + good = path.read_bytes() + path.unlink() + target = Path(tmp) / "outside.json" + target.write_bytes(good) + os.symlink(target, path) + with self.assertRaisesRegex(DispatchValidationError, "symbolic link"): + store.load(record.run_id) + + path.unlink() + path.write_bytes(b"{" + (b" " * MAX_RUN_STATE_BYTES) + b"}") + with self.assertRaisesRegex(DispatchValidationError, "exceeds"): + store.load(record.run_id) + + if os.name == "posix" and hasattr(os, "mkfifo"): + path.unlink() + os.mkfifo(path) + errors: list[Exception] = [] + + def load_fifo() -> None: + try: + store.load(record.run_id) + except Exception as exc: # noqa: BLE001 - asserted below + errors.append(exc) + + reader = threading.Thread(target=load_fifo) + reader.start() + reader.join(timeout=0.5) + was_blocked = reader.is_alive() + if was_blocked: + descriptor = os.open( + path, + os.O_RDWR | getattr(os, "O_NONBLOCK", 0), + ) + os.close(descriptor) + reader.join(timeout=1.0) + self.assertFalse(was_blocked, "run state reader blocked on FIFO") + self.assertFalse(reader.is_alive()) + self.assertEqual(len(errors), 1) + self.assertIn("regular file", str(errors[0])) + + def test_deterministic_create_is_idempotent_and_conflicts_fail_closed(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + store, contract, project = self._store_and_contract(Path(tmp)) + first = store.ensure_created( + run_id="run-batch-fixed", + contract=contract, + project_root=project, + backend="fake", + orchestration_id="batch-1", + thread_id="reviewer", + ) + repeated = store.ensure_created( + run_id="run-batch-fixed", + contract=contract, + project_root=project, + backend="fake", + orchestration_id="batch-1", + thread_id="reviewer", + ) + self.assertEqual(repeated.to_mapping(), first.to_mapping()) + + with self.assertRaisesRegex( + DispatchValidationError, + "conflicts with deterministic create", + ): + store.ensure_created( + run_id="run-batch-fixed", + contract=contract, + project_root=project, + backend="echo", + orchestration_id="batch-1", + thread_id="reviewer", + ) + + +class CooperativeProcessCancellationTests(unittest.TestCase): + def test_run_bounded_cancels_a_real_long_running_process(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + cancelled = threading.Event() + timer = threading.Timer(0.15, cancelled.set) + started = time.monotonic() + timer.start() + try: + completed = run_bounded( + [sys.executable, "-c", "import time; time.sleep(30)"], + cwd=Path(tmp), + timeout_seconds=10.0, + cancel_check=cancelled.is_set, + ) + finally: + timer.cancel() + + self.assertTrue(completed.cancelled) + self.assertFalse(completed.timed_out) + self.assertLess(time.monotonic() - started, 3.0) + + def test_supervisor_cancels_real_backend_after_cleanup(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + project = root / "project" + project.mkdir() + (project / "app.py").write_text("safe = True\n", encoding="utf-8") + supervisor = DispatchSupervisor(home=root / "home") + adapter = _SlowSubprocessAdapter() + outcomes: list[RunRecord] = [] + errors: list[BaseException] = [] + + with patch( + "experiments.local_agent_dispatch.supervisor.get_adapter", + return_value=adapter, + ): + record = supervisor.accept(_payload(), project_root=project) + + def execute() -> None: + try: + outcomes.append( + supervisor.execute(record.run_id, timeout_seconds=10.0) + ) + except BaseException as exc: # pragma: no cover - asserted below + errors.append(exc) + + thread = threading.Thread(target=execute) + thread.start() + self.assertTrue(adapter.started.wait(2.0)) + supervisor.cancel(record.run_id, reason="batch stop") + thread.join(timeout=8.0) + + self.assertFalse(thread.is_alive()) + self.assertEqual(errors, []) + self.assertEqual(len(outcomes), 1) + self.assertEqual(outcomes[0].status, "cancelled") + self.assertEqual(outcomes[0].backend_pid, 0) + self.assertEqual((outcomes[0].result or {}).get("status"), "cancelled") + + def test_unproven_backend_cleanup_keeps_cancelled_run_running(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + project = root / "project" + project.mkdir() + (project / "app.py").write_text("safe = True\n", encoding="utf-8") + supervisor = DispatchSupervisor(home=root / "home") + adapter = _CooperativeAdapter() + errors: list[BaseException] = [] + + with patch( + "experiments.local_agent_dispatch.supervisor.get_adapter", + return_value=adapter, + ): + record = supervisor.accept(_payload(), project_root=project) + + def execute() -> None: + try: + supervisor.execute(record.run_id) + except BaseException as exc: # pragma: no cover - asserted below + errors.append(exc) + + with patch.object( + supervisor.store, + "cleanup_backend_if_owned", + return_value=False, + ): + thread = threading.Thread(target=execute) + thread.start() + self.assertTrue(adapter.started.wait(2.0)) + supervisor.cancel(record.run_id) + thread.join(timeout=5.0) + + self.assertFalse(thread.is_alive()) + self.assertEqual(len(errors), 1) + self.assertIn("cleanup could not be proven", str(errors[0])) + self.assertEqual(supervisor.store.load(record.run_id).status, "running") + + +class AsyncWorkerEnvironmentTests(unittest.TestCase): + def test_all_real_adapters_build_backend_scoped_worker_environments(self) -> None: + factories = ( + codex_adapter, + claude_adapter, + cursor_adapter, + opencode_adapter, + grok_adapter, + hermes_adapter, + kimi_adapter, + dsh_adapter, + pi_adapter, + ) + with patch.dict( + os.environ, + {"AWS_SECRET_ACCESS_KEY": "must-not-pass"}, + clear=True, + ): + for factory in factories: + with self.subTest(adapter=factory.__name__): + environment = factory().worker_environment() + self.assertIsInstance(environment, dict) + self.assertNotIn("AWS_SECRET_ACCESS_KEY", environment) + + self.assertEqual(cursor_adapter().supported_modes, frozenset({"read-only"})) + self.assertEqual( + codex_adapter().supported_modes, + frozenset({"read-only", "edit"}), + ) + + def test_pi_worker_only_inherits_current_default_provider_credentials(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + pi_home = Path(tmp) / "pi" + pi_home.mkdir() + (pi_home / "settings.json").write_text( + json.dumps( + {"defaultProvider": "openai", "defaultModel": "gpt-test"} + ), + encoding="utf-8", + ) + (pi_home / "auth.json").write_text( + json.dumps( + { + "openai": {"token": "selected-oauth"}, + "anthropic": {"token": "lateral-oauth"}, + } + ), + encoding="utf-8", + ) + with patch.dict( + os.environ, + { + "PI_CODING_AGENT_DIR": str(pi_home), + "OPENAI_API_KEY": "selected-key", + "ANTHROPIC_API_KEY": "lateral-key", + "XAI_API_KEY": "lateral-xai-key", + "AWS_SECRET_ACCESS_KEY": "unrelated-key", + }, + clear=True, + ): + adapter = pi_adapter() + profile = adapter.execution_profile() + environment = _worker_environment( + backend="pi", + home=Path(tmp) / "dispatch-home", + run_id="run-1111111111111111", + expected_execution_profile_sha256=( + adapter_execution_profile_sha256(adapter) + ), + expected_execution_profile=profile, + ) + + self.assertEqual(environment.get("OPENAI_API_KEY"), "selected-key") + self.assertNotIn("ANTHROPIC_API_KEY", environment) + self.assertNotIn("XAI_API_KEY", environment) + self.assertNotIn("AWS_SECRET_ACCESS_KEY", environment) + isolated = Path(str(environment.get("PI_CODING_AGENT_DIR"))) + self.assertNotEqual(isolated, pi_home) + self.assertEqual(isolated.parent.name, "runs") + self.assertEqual( + json.loads((isolated / "auth.json").read_text()), + {"openai": {"token": "selected-oauth"}}, + ) + self.assertEqual( + environment.get("DYRO_LOCAL_AGENT_DISPATCH_HOME"), + str(Path(tmp) / "dispatch-home"), + ) + + def test_hermes_worker_home_contains_only_selected_provider_auth(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "hermes-source" + source.mkdir() + (source / "config.yaml").write_text( + "model:\n default: grok-test\n provider: xai-oauth\n", + encoding="utf-8", + ) + (source / "auth.json").write_text( + json.dumps( + { + "version": 1, + "active_provider": "other-provider", + "providers": { + "xai-oauth": {"token": "selected"}, + "other-provider": {"token": "lateral"}, + }, + "credential_pool": { + "xai-oauth": [{"token": "selected-pool"}], + "other-provider": [{"token": "lateral-pool"}], + }, + } + ), + encoding="utf-8", + ) + with patch.dict(os.environ, {"HERMES_HOME": str(source)}, clear=True): + adapter = hermes_adapter() + environment = _worker_environment( + backend="hermes", + home=Path(tmp) / "dispatch-home", + run_id="run-2222222222222222", + expected_execution_profile_sha256=( + adapter_execution_profile_sha256(adapter) + ), + expected_execution_profile=adapter.execution_profile(), + ) + + isolated = Path(environment["HERMES_HOME"]) + self.assertNotEqual(isolated, source) + copied = json.loads((isolated / "auth.json").read_text()) + self.assertEqual(set(copied["providers"]), {"xai-oauth"}) + self.assertEqual(set(copied["credential_pool"]), {"xai-oauth"}) + self.assertNotIn("other-provider", json.dumps(copied)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub.py b/tests/test_hub.py index 774a17d..622c4ff 100644 --- a/tests/test_hub.py +++ b/tests/test_hub.py @@ -833,6 +833,29 @@ def test_home_detects_antigravity_qoder_and_zcode_as_launch_only_tools( self.assertEqual(by_id[tool_id].state, ToolState.READY) self.assertNotIn(tool_id, load(self.root).adapters) + def test_home_marks_detected_pi_unavailable_when_node_is_incompatible( + self, + ) -> None: + with ( + patch( + "dyro.home.shutil.which", + side_effect=lambda name: "/fake/pi" if name == "pi" else None, + ), + patch( + "dyro.home.tool_runtime_issue", + side_effect=lambda definition: ( + "Pi 需要 Node.js >= 22.19.0;当前为 22.15.1" + if definition.id == "pi" + else "" + ), + ), + ): + tools = home_tools(load(self.root), workspace=self.root) + + pi = next(tool for tool in tools if tool.id == "pi") + self.assertEqual(pi.state, ToolState.UNAVAILABLE) + self.assertFalse(pi.available) + def test_home_detects_codex_and_claude_desktops_as_launch_only_tools( self, ) -> None: @@ -1346,6 +1369,35 @@ def test_agent_discovery_separates_configured_and_unintegrated_commands( self.assertIn("未安装", rendered) self.assertNotIn("尚无 Core", rendered) + def test_agent_discovery_keeps_dsh_and_pi_out_of_profile_presets(self) -> None: + output = StringIO() + discovered = {"dsh", "pi"} + with ( + patch( + "dyro.home.shutil.which", + side_effect=lambda name: ( + f"/fake/{name}" if name in discovered else None + ), + ), + patch( + "dyro.home.tool_runtime_issue", + side_effect=lambda definition: ( + "Pi 需要 Node.js >= 22.19.0;当前为 22.15.1" + if definition.id == "pi" + else "" + ), + ), + redirect_stdout(output), + ): + main(["--root", str(self.root), "agent", "discover"]) + + rendered = output.getvalue() + self.assertIn("尚未集成", rendered) + self.assertIn("运行环境不兼容", rendered) + self.assertIn("Node.js >= 22.19.0", rendered) + self.assertNotIn("dyro agent add dsh", rendered) + self.assertNotIn("dyro agent add pi", rendered) + def test_agent_discovery_reports_configured_missing_command(self) -> None: with self.root.joinpath("dyro.toml").open("a", encoding="utf-8") as handle: handle.write( diff --git a/tests/test_integrations.py b/tests/test_integrations.py index bf0bbb9..0721e6f 100644 --- a/tests/test_integrations.py +++ b/tests/test_integrations.py @@ -32,9 +32,13 @@ def setUp(self) -> None: self.dyro_home = self.root / "dyro" self.fake_home = self.root / "home" self.fake_home.mkdir() + isolated_hosts = { + spec.env_var: "" for spec in manager.HOSTS if spec.env_var + } self.environment = patch.dict( os.environ, { + **isolated_hosts, "CODEX_HOME": str(self.codex_home), "DYRO_HOME": str(self.dyro_home), "HOME": str(self.fake_home), @@ -68,6 +72,18 @@ def manifest(self) -> Path: def legacy_manifest(self) -> Path: return self.dyro_home / "integrations" / "codex.json" + @property + def dispatch_mirror(self) -> Path: + return self.dyro_home / "skills" / "dyro-dispatch" + + @property + def dispatch_avatar(self) -> Path: + return self.codex_home / "skills" / "dyro-dispatch" + + @property + def dispatch_manifest(self) -> Path: + return self.dyro_home / "integrations" / "dispatch.json" + def _host_homes(self, *, claude: bool = False) -> dict[str, Path]: homes = {"codex": self.codex_home} if claude: @@ -104,6 +120,7 @@ def test_packaged_skill_is_concise_and_has_required_metadata(self) -> None: "workspace list --format json", "status --format json", "doctor --format json", + "integration status dispatch --format json", "objective attention --format json", "objective explain --format json", "objective plan --format json", @@ -130,6 +147,140 @@ def test_packaged_skill_is_concise_and_has_required_metadata(self) -> None: if ": " in line: self.assertTrue(line.split(": ", 1)[1].startswith('"')) + def test_packaged_dispatch_skill_is_concise_and_has_required_metadata( + self, + ) -> None: + root = manager._asset_root("dispatch") + skill = root / "SKILL.md" + metadata = root / "agents" / "openai.yaml" + + content = skill.read_text(encoding="utf-8") + self.assertLessEqual(len(content.encode("utf-8")), 8 * 1024) + self.assertNotIn("TODO", content) + frontmatter = content.split("---", 2)[1] + keys = { + line.split(":", 1)[0] for line in frontmatter.splitlines() if line.strip() + } + self.assertEqual(keys, {"name", "description"}) + self.assertIn("name: dyro-dispatch", frontmatter) + self.assertIn("parallel", frontmatter.lower()) + for command in ( + "dyro dispatch --dry-run doctor", + "dyro dispatch run", + "dyro dispatch result", + ): + self.assertIn(command, content) + for boundary in ("merge", "push", "signoff", "Dyro gate"): + self.assertIn(boundary, content) + self.assertIn("$dyro-dispatch", metadata.read_text(encoding="utf-8")) + for line in metadata.read_text(encoding="utf-8").splitlines(): + if ": " in line: + self.assertTrue(line.split(": ", 1)[1].startswith('"')) + + def test_dispatch_skill_installs_independently_from_control_plane(self) -> None: + self.assertEqual( + integration_status("dispatch").state, + IntegrationState.ABSENT, + ) + output = StringIO() + with redirect_stdout(output): + main(["integration", "status", "dispatch", "--format", "json"]) + payload = json.loads(output.getvalue()) + self.assertEqual(payload["integration"], "dispatch") + self.assertEqual(payload["state"], "absent") + + installed = install_integration("dispatch", yes=True) + + self.assertEqual(installed.status.state, IntegrationState.CURRENT) + self.assertTrue(self.dispatch_mirror.joinpath("SKILL.md").is_file()) + self.assertTrue(self.dispatch_avatar.is_symlink()) + self.assertEqual( + self.dispatch_avatar.resolve(), + self.dispatch_mirror.resolve(), + ) + manifest = json.loads(self.dispatch_manifest.read_text(encoding="utf-8")) + self.assertEqual(manifest["integration"], "dispatch") + self.assertFalse(self.mirror.exists()) + self.assertFalse(self.avatar.exists() or self.avatar.is_symlink()) + + removed = uninstall_integration("dispatch", yes=True) + self.assertEqual(removed.status.state, IntegrationState.ABSENT) + self.assertFalse(self.dispatch_mirror.exists()) + self.assertFalse( + self.dispatch_avatar.exists() or self.dispatch_avatar.is_symlink() + ) + + def test_dispatch_skill_status_and_dry_run_are_zero_write(self) -> None: + before = self._tree_snapshot() + status = integration_status("dispatch") + plan = install_integration("dispatch", yes=False, dry_run=True) + uninstall_plan = uninstall_integration( + "dispatch", + yes=False, + dry_run=True, + ) + + self.assertEqual(status.state, IntegrationState.ABSENT) + self.assertEqual(plan.status.state, IntegrationState.ABSENT) + self.assertEqual(uninstall_plan.status.state, IntegrationState.ABSENT) + self.assertEqual(self._tree_snapshot(), before) + + def test_dispatch_and_control_plane_skills_coexist_and_uninstall_independently( + self, + ) -> None: + install_integration("skill", yes=True) + install_integration("dispatch", yes=True) + + self.assertEqual( + integration_status("skill").state, + IntegrationState.CURRENT, + ) + self.assertEqual( + integration_status("dispatch").state, + IntegrationState.CURRENT, + ) + self.assertTrue(self.avatar.is_symlink()) + self.assertTrue(self.dispatch_avatar.is_symlink()) + + uninstall_integration("dispatch", yes=True) + + self.assertEqual( + integration_status("dispatch").state, + IntegrationState.ABSENT, + ) + self.assertEqual( + integration_status("skill").state, + IntegrationState.CURRENT, + ) + self.assertTrue(self.avatar.is_symlink()) + self.assertTrue(self.mirror.joinpath("SKILL.md").is_file()) + + def test_dispatch_skill_sync_is_upgrade_only(self) -> None: + self.assertIsNone( + sync_managed_skill( + "dispatch", + yes=True, + allow_first_install=False, + ) + ) + install_integration("dispatch", yes=True) + manifest = json.loads(self.dispatch_manifest.read_text(encoding="utf-8")) + manifest["asset_version"] = manifest["asset_version"] + 1 + self.dispatch_manifest.write_text( + json.dumps(manifest, ensure_ascii=True, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + output = StringIO() + with redirect_stdout(output): + main(["integration", "sync", "dispatch", "--yes"]) + + self.assertIn("dispatch", output.getvalue()) + self.assertEqual( + integration_status("dispatch").state, + IntegrationState.CURRENT, + ) + def test_integration_status_json_is_structured(self) -> None: output = StringIO() with redirect_stdout(output): @@ -688,10 +839,23 @@ def test_sync_managed_skill_upgrades_outdated(self) -> None: assert plan is not None self.assertEqual(plan.status.state, IntegrationState.CURRENT) + def test_setup_isolates_every_host_env_var(self) -> None: + for spec in manager.HOSTS: + if not spec.env_var: + continue + if spec.env_var == "CODEX_HOME": + self.assertEqual(os.environ.get(spec.env_var), str(self.codex_home)) + continue + self.assertFalse( + os.environ.get(spec.env_var, "").strip(), + msg=spec.env_var, + ) + def test_plan_surfaces_missing_host_blocker(self) -> None: with patch.dict(os.environ): - for key in ("CODEX_HOME", "CLAUDE_HOME", "AGENTS_HOME", "CURSOR_HOME"): - os.environ.pop(key, None) + for spec in manager.HOSTS: + if spec.env_var: + os.environ.pop(spec.env_var, None) plan = install_integration("skill", yes=False, dry_run=True) self.assertEqual(plan.status.state, IntegrationState.ABSENT) self.assertFalse(plan.status.avatars) @@ -759,5 +923,28 @@ def test_cli_status_dry_run_install_and_confirmation_gate(self) -> None: self.assertEqual(integration_status("skill").state, IntegrationState.ABSENT) + def test_pi_skill_host_uses_coding_agent_dir(self) -> None: + spec = next(host for host in manager.HOSTS if host.host_id == "pi") + self.assertEqual(spec.env_var, "PI_CODING_AGENT_DIR") + self.assertEqual(spec.default_dirname, ".pi/agent") + + pi_home = self.root / "pi-agent" + install_integration("skill", yes=True, host_homes={"pi": pi_home}) + avatar = pi_home / "skills" / "dyro-control-plane" + self.assertTrue(avatar.is_symlink() or avatar.is_dir()) + self.assertTrue((avatar / "SKILL.md").is_file()) + + def test_dsh_skill_host_uses_dsh_home(self) -> None: + spec = next(host for host in manager.HOSTS if host.host_id == "dsh") + self.assertEqual(spec.env_var, "DSH_HOME") + self.assertEqual(spec.default_dirname, ".dsh") + + dsh_home = self.root / "dsh-home" + install_integration("skill", yes=True, host_homes={"dsh": dsh_home}) + avatar = dsh_home / "skills" / "dyro-control-plane" + self.assertTrue(avatar.is_symlink() or avatar.is_dir()) + self.assertTrue((avatar / "SKILL.md").is_file()) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_local_agent_dispatch_l1_l4.py b/tests/test_local_agent_dispatch_l1_l4.py index 0377de1..1be99b8 100644 --- a/tests/test_local_agent_dispatch_l1_l4.py +++ b/tests/test_local_agent_dispatch_l1_l4.py @@ -11,6 +11,7 @@ import experiments.local_agent_dispatch.adapters.registry as registry_module from experiments.local_agent_dispatch.adapters.registry import get_adapter, probe_backends from experiments.local_agent_dispatch.cli import main as cli_main +from experiments.local_agent_dispatch.context_guard import check_content from experiments.local_agent_dispatch.errors import DispatchValidationError from experiments.local_agent_dispatch.gc import gc from experiments.local_agent_dispatch.lease import SlotManager @@ -65,6 +66,18 @@ def test_acquire_and_release(self) -> None: mgr.release_all(leases2) +class SecretGuardTests(unittest.TestCase): + def test_rejects_google_stripe_and_jwt_credentials(self) -> None: + samples = ( + "AIza" + ("A" * 35), + "sk_live_" + ("a" * 24), + "eyJheader123.payload123.signature123", + ) + for sample in samples: + with self.subTest(sample=sample[:8]): + self.assertFalse(check_content(sample).allowed) + + class SupervisorEchoTests(unittest.TestCase): def test_sync_run_and_strict_shadow(self) -> None: with tempfile.TemporaryDirectory() as tmp: @@ -120,12 +133,89 @@ def test_default_panel_never_falls_back_to_offline_simulation(self) -> None: with self.assertRaisesRegex(DispatchValidationError, "no authenticated"): resolve_panel_members(None) + def test_default_panel_can_use_non_codex_ready_provider(self) -> None: + with patch( + "experiments.local_agent_dispatch.panel.probe_backends", + return_value=[ + { + "id": "grok", + "available": True, + "authenticated": True, + "supported": True, + "execution_kind": "provider", + } + ], + ): + self.assertEqual(resolve_panel_members(None), ["grok"]) + + def test_all_panel_members_selects_every_ready_provider(self) -> None: + rows = [ + { + "id": backend, + "available": True, + "authenticated": True, + "supported": True, + "execution_kind": "provider", + } + for backend in ("pi", "claude", "codex", "grok", "hermes") + ] + rows.append( + { + "id": "echo", + "available": True, + "authenticated": True, + "supported": False, + "execution_kind": "offline-simulation", + } + ) + with patch( + "experiments.local_agent_dispatch.panel.probe_backends", + return_value=rows, + ): + self.assertEqual( + resolve_panel_members(["all"]), + ["codex", "claude", "grok", "hermes", "pi"], + ) + + def test_all_panel_member_cannot_be_mixed_with_backend_ids(self) -> None: + with self.assertRaisesRegex(DispatchValidationError, "cannot be combined"): + resolve_panel_members(["all", "codex"]) + + def test_requested_panel_members_are_deduplicated(self) -> None: + self.assertEqual(resolve_panel_members(["echo", "echo"]), ["echo"]) + + def test_panel_member_failure_is_recorded_without_losing_board(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + home = _home(tmp) + with patch.object( + DispatchSupervisor, + "execute", + side_effect=DispatchValidationError("simulated member failure"), + ): + board = run_panel( + _task(), + project_root=_project(tmp), + members=["echo"], + home=home, + timeout_seconds=30, + ) + self.assertEqual(board["runs"][0]["status"], "failed") + self.assertEqual( + board["runs"][0]["error"], "simulated member failure" + ) + persisted = DispatchSupervisor(home=home).store.load( + str(board["runs"][0]["run_id"]) + ) + self.assertEqual(persisted.status, "failed") + self.assertTrue(Path(str(board["board_path"])).is_file()) + class SkillAndGcTests(unittest.TestCase): def test_skill_render_and_gc(self) -> None: with tempfile.TemporaryDirectory() as tmp: home = _home(tmp) text = render_skill_markdown(home=home) + self.assertIn("name: dyro-dispatch", text) self.assertIn("Available backends", text) self.assertIn("never `**/*`", text) path = write_skill(home=home) @@ -202,6 +292,7 @@ def test_dry_run_validates_contract_and_known_backend_without_state_or_probe(sel str(task_path), ] ) + self.assertEqual(code, 0) self.assertTrue(json.loads(output.getvalue())["valid"]) self.assertTrue( @@ -241,17 +332,68 @@ def test_auto_and_echo(self) -> None: get_adapter("auto") rows = probe_backends() self.assertTrue(any(r["id"] == "echo" for r in rows)) - self.assertTrue(any(r["id"] == "opencode" and not r["supported"] for r in rows)) + for provider_id in ( + "cursor-agent", + "opencode", + "grok", + "hermes", + "kimi", + "dsh", + "pi", + ): + row = next(r for r in rows if r["id"] == provider_id) + self.assertTrue(row["supported"]) + self.assertEqual(row["execution_kind"], "provider") + self.assertEqual(row["command"], provider_id) + + def test_dry_run_backend_and_doctor_surfaces_never_probe_authentication(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + home = _home(tmp) + with patch.object( + registry_module, + "adapter_is_authenticated", + side_effect=AssertionError("active auth probe is forbidden"), + ): + for command in ("backends", "doctor"): + output = io.StringIO() + with self.subTest(command=command), redirect_stdout(output): + code = cli_main( + [ + "--home", + str(home), + "--dry-run", + command, + ] + ) + self.assertEqual(code, 0) + rows = json.loads(output.getvalue())["backends"] + self.assertTrue(rows) + self.assertTrue( + all( + row["authentication_probe"] == "not_run" + for row in rows + ) + ) + self.assertFalse(home.exists()) def test_routes_reject_simulation_and_unknown_providers(self) -> None: with tempfile.TemporaryDirectory() as tmp: home = _home(tmp) - for backend in ("echo", "not-a-provider", "opencode"): + for backend in ("echo", "not-a-provider"): with self.subTest(backend=backend), self.assertRaises(DispatchValidationError): save_route("default", backend, home=home) class ContractRejectTests(unittest.TestCase): + def test_named_provider_credential_assignment_is_rejected(self) -> None: + for assignment in ( + "KIMI_API_KEY=ordinary-kimi-credential", + "DEEPSEEK_API_KEY: ordinary-deepseek-credential", + ): + with self.subTest(assignment=assignment): + verdict = check_content(assignment) + self.assertFalse(verdict.allowed) + def test_forbidden_star(self) -> None: with self.assertRaises(DispatchValidationError): parse_task_contract( diff --git a/tests/test_local_agent_orchestration.py b/tests/test_local_agent_orchestration.py new file mode 100644 index 0000000..2cd07b8 --- /dev/null +++ b/tests/test_local_agent_orchestration.py @@ -0,0 +1,930 @@ +from __future__ import annotations + +from contextlib import redirect_stdout +import io +import json +import os +from pathlib import Path +import subprocess +import tempfile +import threading +import time +import unittest +from unittest.mock import patch + +from experiments.local_agent_dispatch.cli import main as dispatch_cli_main +from experiments.local_agent_dispatch.adapters.registry import ( + adapter_execution_profile_sha256, + get_adapter, +) +from experiments.local_agent_dispatch.errors import DispatchValidationError +from experiments.local_agent_dispatch.fileset import ( + collect_guarded_context, + guarded_context_sha256, +) +from experiments.local_agent_dispatch.gc import gc +from experiments.local_agent_dispatch.orchestration import ( + cancel_batch, + get_batch_result, + get_batch_status, + plan_batch, + start_batch, +) +import experiments.local_agent_dispatch.orchestration_store as orchestration_store_module +from experiments.local_agent_dispatch.orchestration_store import OrchestrationStore +from experiments.local_agent_dispatch.run_store import RunStore +from experiments.local_agent_dispatch.supervisor import DispatchSupervisor +from experiments.local_agent_dispatch.task_contract import parse_task_contract + + +def _codex_home(root: Path) -> Path: + home = root / "codex-home" + home.mkdir() + (home / "config.toml").write_text('model = "test-model"\n', encoding="utf-8") + return home + + +def _project(root: Path) -> Path: + project = root / "project" + (project / "src").mkdir(parents=True) + (project / "src" / "app.py").write_text( + "def hello():\n return 'hello'\n", + encoding="utf-8", + ) + return project + + +def _contract(backend: str, *, objective: str) -> dict[str, object]: + return { + "schema_version": 1, + "backend": backend, + "mode": "read-only", + "strict": False, + "allow_unconfined_provider": True, + "allow_offline_simulation": False, + "files": ["src/app.py"], + "task": { + "briefing": "Inspect the supplied implementation.", + "locations": "src/app.py", + "objective": objective, + "constraints": "Do not modify source or perform production actions.", + "output_contract": "Return bounded JSON summary and evidence.", + }, + } + + +def _batch_payload(*, request_id: str = "request-001") -> dict[str, object]: + return { + "schema_version": 1, + "request_id": request_id, + "strategy": "independent", + "members": [ + { + "role_id": "finder", + "timeout_seconds": 30, + "contract": _contract( + "codex", objective="Find one correctness risk." + ), + }, + { + "role_id": "verifier", + "timeout_seconds": 30, + "contract": _contract( + "claude", objective="Independently verify the implementation." + ), + }, + ], + } + + +class _ReadyAdapter: + strict_isolation = False + supported_modes = frozenset({"read-only", "edit"}) + + def __init__(self, backend: str) -> None: + self.id = backend + self.command = backend + + def available(self) -> bool: + return True + + def authenticated(self) -> bool: + return True + + +class BatchPlanningTests(unittest.TestCase): + def test_plan_is_side_effect_free_and_context_bound(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + home = root / "dispatch-home" + project = _project(root) + payload = _batch_payload() + + with ( + patch( + "experiments.local_agent_dispatch.orchestration." + "candidate_provider_ids", + return_value=["codex", "claude"], + ), + patch( + "experiments.local_agent_dispatch.orchestration.get_adapter", + side_effect=lambda backend: _ReadyAdapter(backend), + ), + ): + first = plan_batch(payload, project_root=project, home=home) + self.assertFalse(home.exists()) + (project / "src" / "app.py").write_text( + "def hello():\n return 'changed'\n", + encoding="utf-8", + ) + second = plan_batch(payload, project_root=project, home=home) + + self.assertNotEqual(first.plan_sha256, second.plan_sha256) + self.assertEqual(first.effects["starts_provider_processes"], 2) + self.assertTrue(first.effects["may_use_network_or_bill"]) + + def test_plan_never_calls_active_authentication_probe(self) -> None: + class _PassiveOnlyAdapter(_ReadyAdapter): + def authenticated(self) -> bool: + raise AssertionError("planning must not start an auth CLI") + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + project = _project(root) + with ( + patch( + "experiments.local_agent_dispatch.orchestration." + "candidate_provider_ids", + return_value=["codex", "claude"], + ), + patch( + "experiments.local_agent_dispatch.orchestration.get_adapter", + side_effect=lambda backend: _PassiveOnlyAdapter(backend), + ), + ): + plan = plan_batch(_batch_payload(), project_root=project) + self.assertEqual(len(plan.members), 2) + + def test_plan_digest_binds_inner_provider_and_model_profile(self) -> None: + selected_model = {"value": "model-a"} + + class _ProfileAdapter(_ReadyAdapter): + def execution_profile(self) -> dict[str, str]: + return { + "backend": self.id, + "command_path": self.command, + "provider": "provider-a", + "model": selected_model["value"], + } + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + home = root / "dispatch-home" + project = _project(root) + with ( + patch( + "experiments.local_agent_dispatch.orchestration." + "candidate_provider_ids", + return_value=["codex", "claude"], + ), + patch( + "experiments.local_agent_dispatch.orchestration.get_adapter", + side_effect=lambda backend: _ProfileAdapter(backend), + ), + ): + first = plan_batch(_batch_payload(), project_root=project) + selected_model["value"] = "model-b" + second = plan_batch(_batch_payload(), project_root=project) + with self.assertRaisesRegex( + DispatchValidationError, "plan digest changed" + ): + start_batch( + _batch_payload(), + expected_plan_sha256=first.plan_sha256, + project_root=project, + home=home, + ) + + self.assertNotEqual(first.plan_sha256, second.plan_sha256) + self.assertFalse(home.exists()) + + def test_plan_distributes_auto_members_across_ready_providers(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + project = _project(root) + payload = _batch_payload() + for member in payload["members"]: # type: ignore[index] + member["contract"]["backend"] = "auto" # type: ignore[index] + + with ( + patch( + "experiments.local_agent_dispatch.orchestration." + "candidate_provider_ids", + return_value=["codex", "claude"], + ), + patch( + "experiments.local_agent_dispatch.orchestration.get_adapter", + side_effect=lambda backend: _ReadyAdapter(backend), + ), + ): + plan = plan_batch(payload, project_root=project) + + self.assertEqual( + [member.resolved_backend for member in plan.members], + ["codex", "claude"], + ) + + def test_plan_rejects_unsupported_mode_without_creating_state(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + home = root / "dispatch-home" + project = _project(root) + payload = _batch_payload() + payload["members"][0]["contract"]["mode"] = "edit" # type: ignore[index] + + class _ReadOnlyAdapter(_ReadyAdapter): + supported_modes = frozenset({"read-only"}) + + with ( + patch( + "experiments.local_agent_dispatch.orchestration." + "candidate_provider_ids", + return_value=["codex", "claude"], + ), + patch( + "experiments.local_agent_dispatch.orchestration.get_adapter", + side_effect=lambda backend: _ReadOnlyAdapter(backend), + ), + self.assertRaisesRegex(DispatchValidationError, "does not support"), + ): + plan_batch(payload, project_root=project, home=home) + self.assertFalse(home.exists()) + + def test_edit_plan_binds_clean_head_and_rejects_selected_file_drift(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + project = _project(root) + for arguments in ( + ["git", "init", "-q", str(project)], + ["git", "-C", str(project), "config", "user.name", "Dyro Test"], + [ + "git", + "-C", + str(project), + "config", + "user.email", + "dyro@example.invalid", + ], + ["git", "-C", str(project), "add", "src/app.py"], + ["git", "-C", str(project), "commit", "-q", "-m", "base"], + ): + subprocess.run( + arguments, + check=True, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + payload = _batch_payload() + payload["members"][0]["contract"]["mode"] = "edit" # type: ignore[index] + + with ( + patch( + "experiments.local_agent_dispatch.orchestration." + "candidate_provider_ids", + return_value=["codex", "claude"], + ), + patch( + "experiments.local_agent_dispatch.orchestration.get_adapter", + side_effect=lambda backend: _ReadyAdapter(backend), + ), + ): + plan = plan_batch(payload, project_root=project) + self.assertRegex(plan.members[0].base_head or "", r"^[0-9a-f]{40}$") + (project / "src" / "app.py").write_text( + "def hello():\n return 'dirty'\n", + encoding="utf-8", + ) + with self.assertRaisesRegex( + DispatchValidationError, "differs from.*HEAD" + ): + plan_batch(payload, project_root=project) + + +class BatchLifecycleTests(unittest.TestCase): + def _patch_ready(self): + return ( + patch( + "experiments.local_agent_dispatch.orchestration." + "candidate_provider_ids", + return_value=["codex", "claude"], + ), + patch( + "experiments.local_agent_dispatch.orchestration.get_adapter", + side_effect=lambda backend: _ReadyAdapter(backend), + ), + ) + + def test_start_is_idempotent_and_status_groups_member_runs(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + home = root / "dispatch-home" + project = _project(root) + payload = _batch_payload() + ready, adapters = self._patch_ready() + + def reserve_only(supervisor, run_id, **_kwargs): + record = supervisor.store.load(run_id) + if not record.worker_token: + record = supervisor.store.reserve_async_worker( + run_id, + worker_token=f"worker-{run_id}", + ) + return record + + with ( + ready, + adapters, + patch( + "experiments.local_agent_dispatch.orchestration." + "DispatchSupervisor.execute", + autospec=True, + side_effect=reserve_only, + ) as execute, + ): + plan = plan_batch(payload, project_root=project, home=home) + first = start_batch( + payload, + expected_plan_sha256=plan.plan_sha256, + project_root=project, + home=home, + ) + second = start_batch( + payload, + expected_plan_sha256=plan.plan_sha256, + project_root=project, + home=home, + ) + + self.assertEqual(first["orchestration_id"], second["orchestration_id"]) + self.assertEqual(execute.call_count, 2) + self.assertEqual(first["status"], "running") + self.assertEqual(len(first["members"]), 2) + self.assertEqual( + get_batch_status(first["orchestration_id"], home=home)["status"], + "running", + ) + + def test_start_rejects_plan_drift_before_creating_state(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + home = root / "dispatch-home" + project = _project(root) + payload = _batch_payload() + ready, adapters = self._patch_ready() + with ready, adapters: + plan = plan_batch(payload, project_root=project, home=home) + payload["members"][0]["contract"]["task"]["objective"] = ( # type: ignore[index] + "A changed objective." + ) + with self.assertRaisesRegex( + DispatchValidationError, "plan.*changed|digest" + ): + start_batch( + payload, + expected_plan_sha256=plan.plan_sha256, + project_root=project, + home=home, + ) + self.assertFalse(home.exists()) + + def test_cancel_is_batch_scoped_and_result_preserves_healthy_member(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + home = root / "dispatch-home" + project = _project(root) + payload = _batch_payload() + ready, adapters = self._patch_ready() + with ( + ready, + adapters, + patch( + "experiments.local_agent_dispatch.orchestration." + "DispatchSupervisor.execute", + autospec=True, + side_effect=lambda supervisor, run_id, **_kwargs: ( + supervisor.store.load(run_id) + ), + ), + ): + plan = plan_batch(payload, project_root=project, home=home) + started = start_batch( + payload, + expected_plan_sha256=plan.plan_sha256, + project_root=project, + home=home, + ) + + store = RunStore(home) + first_id = started["members"][0]["run_id"] + store.update_status( + first_id, + "completed", + result={ + "summary": "healthy", + "confidence": "high", + "evidence": [], + "warnings": [], + "patch_ref": None, + }, + ) + cancelled = cancel_batch(started["orchestration_id"], home=home) + self.assertEqual(cancelled["status"], "partial") + result = get_batch_result(started["orchestration_id"], home=home) + self.assertTrue(result["ready"]) + self.assertEqual(result["members"][0]["summary"], "healthy") + self.assertEqual(result["members"][1]["status"], "cancelled") + + def test_gc_protects_active_batch_then_removes_terminal_batch_together(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + home = root / "dispatch-home" + project = _project(root) + payload = _batch_payload() + ready, adapters = self._patch_ready() + with ( + ready, + adapters, + patch( + "experiments.local_agent_dispatch.orchestration." + "DispatchSupervisor.execute", + autospec=True, + side_effect=lambda supervisor, run_id, **_kwargs: ( + supervisor.store.load(run_id) + ), + ), + ): + plan = plan_batch(payload, project_root=project, home=home) + started = start_batch( + payload, + expected_plan_sha256=plan.plan_sha256, + project_root=project, + home=home, + ) + + active = gc(home=home, max_age_seconds=0, dry_run=False) + self.assertEqual(active["removed_runs"], []) + self.assertEqual(active["removed_orchestrations"], []) + + store = RunStore(home) + for member in started["members"]: + store.update_status(member["run_id"], "completed", result={}) + patch_root = home / "patches" / member["run_id"] + patch_root.mkdir(parents=True) + (patch_root / "changes.patch").write_text( + "patch evidence", + encoding="utf-8", + ) + (home / "runs" / f"{member['run_id']}.worker.log").write_text( + "bounded log", + encoding="utf-8", + ) + (home / "runs" / f"{member['run_id']}.backend.lifetime").touch() + terminal = gc(home=home, max_age_seconds=0, dry_run=False) + self.assertCountEqual( + terminal["removed_runs"], + [member["run_id"] for member in started["members"]], + ) + self.assertEqual(len(terminal["removed_orchestrations"]), 1) + tombstones = list( + (home / "orchestrations").glob("request-*.json") + ) + self.assertEqual(len(tombstones), 1) + with self.assertRaisesRegex( + DispatchValidationError, + "already executed and garbage-collected", + ): + OrchestrationStore(home).create_or_load(plan) + for member in started["members"]: + self.assertFalse((home / "patches" / member["run_id"]).exists()) + self.assertFalse( + (home / "runs" / f"{member['run_id']}.worker.log").exists() + ) + self.assertFalse( + ( + home + / "runs" + / f"{member['run_id']}.backend.lifetime" + ).exists() + ) + + def test_gc_heals_missing_request_tombstone_before_manifest_removal(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + home = root / "dispatch-home" + project = _project(root) + payload = _batch_payload(request_id="request-heal-tombstone") + ready, adapters = self._patch_ready() + with ( + ready, + adapters, + patch( + "experiments.local_agent_dispatch.orchestration." + "DispatchSupervisor.execute", + autospec=True, + side_effect=lambda supervisor, run_id, **_kwargs: ( + supervisor.store.load(run_id) + ), + ), + ): + plan = plan_batch(payload, project_root=project, home=home) + started = start_batch( + payload, + expected_plan_sha256=plan.plan_sha256, + project_root=project, + home=home, + ) + store = RunStore(home) + for member in started["members"]: + store.update_status(member["run_id"], "completed", result={}) + tombstone = next((home / "orchestrations").glob("request-*.json")) + tombstone.unlink() + + report = gc(home=home, max_age_seconds=0, dry_run=False) + + self.assertEqual(len(report["removed_orchestrations"]), 1) + self.assertEqual( + len(list((home / "orchestrations").glob("request-*.json"))), + 1, + ) + with self.assertRaisesRegex( + DispatchValidationError, + "already executed and garbage-collected", + ): + OrchestrationStore(home).create_or_load(plan) + + def test_gc_cannot_delete_batch_while_member_states_initialize(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + home = root / "dispatch-home" + project = _project(root) + payload = _batch_payload(request_id="request-init-gc-race") + ready, adapters = self._patch_ready() + with ready, adapters: + plan = plan_batch(payload, project_root=project, home=home) + + entered = threading.Event() + release = threading.Event() + start_errors: list[Exception] = [] + start_results: list[dict[str, object]] = [] + gc_results: list[dict[str, object]] = [] + original_ensure = RunStore.ensure_created + first = True + + def paused_ensure(store, **kwargs): + nonlocal first + if first: + first = False + entered.set() + if not release.wait(timeout=2.0): + raise RuntimeError("test did not release batch initialization") + return original_ensure(store, **kwargs) + + def launch() -> None: + try: + start_results.append( + start_batch( + payload, + expected_plan_sha256=plan.plan_sha256, + project_root=project, + home=home, + ) + ) + except Exception as exc: # noqa: BLE001 - asserted below + start_errors.append(exc) + + def collect() -> None: + gc_results.append( + gc(home=home, max_age_seconds=0, dry_run=False) + ) + + ready, adapters = self._patch_ready() + with ( + ready, + adapters, + patch.object(RunStore, "ensure_created", new=paused_ensure), + patch( + "experiments.local_agent_dispatch.orchestration." + "DispatchSupervisor.execute", + autospec=True, + side_effect=lambda supervisor, run_id, **_kwargs: ( + supervisor.store.load(run_id) + ), + ), + ): + start_thread = threading.Thread(target=launch) + start_thread.start() + self.assertTrue(entered.wait(timeout=1.0)) + gc_thread = threading.Thread(target=collect) + gc_thread.start() + time.sleep(0.05) + self.assertTrue(gc_thread.is_alive()) + release.set() + start_thread.join(timeout=2.0) + gc_thread.join(timeout=2.0) + + self.assertFalse(start_thread.is_alive()) + self.assertFalse(gc_thread.is_alive()) + self.assertEqual(start_errors, []) + self.assertEqual(len(start_results), 1) + self.assertEqual(gc_results[0]["removed_orchestrations"], []) + self.assertEqual(gc_results[0]["removed_runs"], []) + manifest = OrchestrationStore(home).load( + start_results[0]["orchestration_id"] + ) + self.assertEqual(len(manifest.members), 2) + run_store = RunStore(home) + for member in manifest.members: + self.assertEqual(run_store.load(member.run_id).status, "accepted") + + def test_gc_serializes_with_cancellation_and_cannot_resurrect_manifest( + self, + ) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + home = root / "dispatch-home" + project = _project(root) + payload = _batch_payload(request_id="request-cancel-gc-race") + ready, adapters = self._patch_ready() + with ( + ready, + adapters, + patch( + "experiments.local_agent_dispatch.orchestration." + "DispatchSupervisor.execute", + autospec=True, + side_effect=lambda supervisor, run_id, **_kwargs: ( + supervisor.store.load(run_id) + ), + ), + ): + plan = plan_batch(payload, project_root=project, home=home) + started = start_batch( + payload, + expected_plan_sha256=plan.plan_sha256, + project_root=project, + home=home, + ) + run_store = RunStore(home) + for member in started["members"]: + run_store.update_status(member["run_id"], "completed", result={}) + time.sleep(0.6) + + entered_write = threading.Event() + release_write = threading.Event() + errors: list[Exception] = [] + reports: list[dict[str, object]] = [] + original_write = orchestration_store_module.atomic_write_json + + def paused_write(path, mapping) -> None: + if ( + Path(path).name + == f"{started['orchestration_id']}.json" + and mapping.get("cancel_requested") is True + ): + entered_write.set() + if not release_write.wait(timeout=2.0): + raise RuntimeError("test did not release cancellation write") + original_write(path, mapping) + + def request_cancel() -> None: + try: + OrchestrationStore(home).request_cancel( + started["orchestration_id"] + ) + except Exception as exc: # noqa: BLE001 - asserted below + errors.append(exc) + + def collect() -> None: + try: + reports.append( + gc(home=home, max_age_seconds=0.5, dry_run=False) + ) + except Exception as exc: # noqa: BLE001 - asserted below + errors.append(exc) + + with patch.object( + orchestration_store_module, + "atomic_write_json", + side_effect=paused_write, + ): + cancel_thread = threading.Thread(target=request_cancel) + cancel_thread.start() + self.assertTrue(entered_write.wait(timeout=1.0)) + gc_thread = threading.Thread(target=collect) + gc_thread.start() + time.sleep(0.05) + self.assertTrue(gc_thread.is_alive()) + release_write.set() + cancel_thread.join(timeout=2.0) + gc_thread.join(timeout=2.0) + + self.assertFalse(cancel_thread.is_alive()) + self.assertFalse(gc_thread.is_alive()) + self.assertEqual(errors, []) + self.assertEqual(len(reports), 1) + self.assertEqual(reports[0]["removed_orchestrations"], []) + self.assertEqual(reports[0]["removed_runs"], []) + manifest = OrchestrationStore(home).load( + started["orchestration_id"] + ) + self.assertTrue(manifest.cancel_requested) + for member in started["members"]: + self.assertEqual( + run_store.load(member["run_id"]).status, + "completed", + ) + + def test_gc_recovers_when_one_terminal_member_record_is_already_missing(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + home = root / "dispatch-home" + project = _project(root) + payload = _batch_payload(request_id="request-gc-recovery") + ready, adapters = self._patch_ready() + with ( + ready, + adapters, + patch( + "experiments.local_agent_dispatch.orchestration." + "DispatchSupervisor.execute", + autospec=True, + side_effect=lambda supervisor, run_id, **_kwargs: ( + supervisor.store.load(run_id) + ), + ), + ): + plan = plan_batch(payload, project_root=project, home=home) + started = start_batch( + payload, + expected_plan_sha256=plan.plan_sha256, + project_root=project, + home=home, + ) + store = RunStore(home) + for member in started["members"]: + store.update_status(member["run_id"], "completed", result={}) + store.delete(started["members"][0]["run_id"]) + + report = gc(home=home, max_age_seconds=0, dry_run=False) + self.assertEqual( + report["removed_runs"], + [started["members"][1]["run_id"]], + ) + self.assertEqual(len(report["removed_orchestrations"]), 1) + + def test_mutated_batch_run_binding_is_attention_and_cannot_execute(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + home = root / "dispatch-home" + project = _project(root) + payload = _batch_payload(request_id="request-binding") + ready, adapters = self._patch_ready() + with ( + ready, + adapters, + patch( + "experiments.local_agent_dispatch.orchestration." + "DispatchSupervisor.execute", + autospec=True, + side_effect=lambda supervisor, run_id, **_kwargs: ( + supervisor.store.load(run_id) + ), + ), + ): + plan = plan_batch(payload, project_root=project, home=home) + started = start_batch( + payload, + expected_plan_sha256=plan.plan_sha256, + project_root=project, + home=home, + ) + run_id = started["members"][0]["run_id"] + state_path = home / "runs" / f"{run_id}.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + state["planned_context_sha256"] = "" + state_path.write_text(json.dumps(state), encoding="utf-8") + + status = get_batch_status( + started["orchestration_id"], + home=home, + reconcile=False, + ) + self.assertEqual(status["status"], "attention_required") + with self.assertRaisesRegex( + DispatchValidationError, "does not match" + ): + DispatchSupervisor(home=home).execute( + run_id, + timeout_seconds=5, + sync=True, + ) + finished = RunStore(home).load(run_id) + self.assertEqual(finished.status, "failed") + self.assertIn("does not match", finished.error) + + def test_non_reconciling_status_does_not_create_missing_home(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + home = Path(tmp) / "missing-home" + with self.assertRaisesRegex( + DispatchValidationError, "orchestration not found" + ): + get_batch_status( + "orch-0000000000000000", + home=home, + reconcile=False, + ) + self.assertFalse(home.exists()) + + def test_worker_rejects_context_drift_after_batch_start(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + home = root / "dispatch-home" + project = _project(root) + contract = parse_task_contract( + _contract("codex", objective="Review the stable snapshot.") + ) + digest = guarded_context_sha256( + collect_guarded_context(contract.files, project) + ) + store = RunStore(home) + with patch.dict( + os.environ, + {"CODEX_HOME": str(_codex_home(root))}, + clear=False, + ): + execution_profile = get_adapter("codex").execution_profile() + record = store.create( + contract=contract, + project_root=project, + backend="codex", + thread_id="finder", + planned_context_sha256=digest, + planned_execution_profile_sha256=( + adapter_execution_profile_sha256(get_adapter("codex")) + ), + planned_execution_profile=execution_profile, + ) + (project / "src" / "app.py").write_text( + "def hello():\n return 'drifted'\n", + encoding="utf-8", + ) + + finished = DispatchSupervisor(home=home).execute( + record.run_id, + timeout_seconds=5, + sync=True, + ) + self.assertEqual(finished.status, "failed") + self.assertIn("context changed", finished.error) + + +class BatchCliTests(unittest.TestCase): + def test_batch_plan_cli_emits_plan_without_state(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + home = root / "dispatch-home" + project = _project(root) + stream = io.StringIO() + with ( + patch( + "experiments.local_agent_dispatch.orchestration." + "candidate_provider_ids", + return_value=["codex", "claude"], + ), + patch( + "experiments.local_agent_dispatch.orchestration.get_adapter", + side_effect=lambda backend: _ReadyAdapter(backend), + ), + patch("sys.stdin", io.StringIO(json.dumps(_batch_payload()))), + redirect_stdout(stream), + ): + code = dispatch_cli_main( + [ + "--home", + str(home), + "batch-plan", + "--project", + str(project), + "--stdin", + ] + ) + + self.assertEqual(code, 0) + output = json.loads(stream.getvalue()) + self.assertEqual(output["kind"], "local-agent-dispatch-batch-plan") + self.assertFalse(home.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_release_source.py b/tests/test_release_source.py index 5bd4bc6..f2c0777 100644 --- a/tests/test_release_source.py +++ b/tests/test_release_source.py @@ -93,6 +93,8 @@ def test_publish_workflow_requires_successful_exact_sha_ci_gate(self) -> None: "Agent Bridge source/wheel/sdist gate (Ubuntu 24.04)", workflow ) self.assertNotIn("bridge-gate-run.tsv", workflow) + self.assertIn("dyro-dispatch','SKILL.md", workflow) + self.assertIn("dyro-dispatch','agents','openai.yaml", workflow) def test_ci_no_longer_ships_agent_bridge_zero_effect_gate(self) -> None: workflow = (ROOT / ".github" / "workflows" / "ci.yml").read_text( @@ -103,3 +105,5 @@ def test_ci_no_longer_ships_agent_bridge_zero_effect_gate(self) -> None: self.assertNotIn("dyro-bridge-zero-effect-evidence", workflow) self.assertNotIn("verify_bridge_zero_effects.py", workflow) self.assertNotIn("Agent Bridge source/wheel/sdist gate", workflow) + self.assertIn("dyro-dispatch','SKILL.md", workflow) + self.assertIn("dyro-dispatch','agents','openai.yaml", workflow) diff --git a/tests/test_tooling.py b/tests/test_tooling.py index 5e29090..9c161a9 100644 --- a/tests/test_tooling.py +++ b/tests/test_tooling.py @@ -10,12 +10,14 @@ from unittest.mock import patch from dyro.errors import DyroError, ValidationError +from dyro.profile import launchable_preset_ids, preset_adapter from dyro.tooling import ( ToolPreferences, install_tool, load_tool_preferences, save_tool_preferences, tool_definition, + tool_runtime_issue, ) @@ -59,10 +61,30 @@ def test_catalog_includes_desktop_and_new_terminal_tools(self) -> None: antigravity = tool_definition("antigravity") codex_desktop = tool_definition("codex-desktop") claude_desktop = tool_definition("claude-desktop") + dsh = tool_definition("dsh") + pi = tool_definition("pi") qoder = tool_definition("qoder") zcode = tool_definition("zcode") self.assertEqual(antigravity.command if antigravity else "", "agy") + self.assertEqual(dsh.command if dsh else "", "dsh") + self.assertEqual(dsh.launch if dsh else (), ("dsh", "web")) + self.assertEqual( + dsh.install.argv if dsh and dsh.install else (), + ("npm", "install", "-g", "@deepseek-ai/dsh@latest"), + ) + self.assertEqual(pi.command if pi else "", "pi") + self.assertEqual(pi.launch if pi else (), ("pi",)) + self.assertEqual( + pi.install.argv if pi and pi.install else (), + ( + "npm", + "install", + "-g", + "--ignore-scripts", + "@earendil-works/pi-coding-agent@latest", + ), + ) self.assertEqual(qoder.command if qoder else "", "qodercli") self.assertEqual(zcode.interface if zcode else "", "desktop") self.assertEqual(codex_desktop.interface if codex_desktop else "", "desktop") @@ -71,6 +93,18 @@ def test_catalog_includes_desktop_and_new_terminal_tools(self) -> None: qoder.install.argv if qoder and qoder.install else (), ("npm", "install", "-g", "@qoder-ai/qodercli"), ) + self.assertNotIn("dsh", launchable_preset_ids()) + self.assertNotIn("pi", launchable_preset_ids()) + with self.assertRaisesRegex(ValidationError, "任务 adapter"): + preset_adapter("dsh", "dsh") + with self.assertRaisesRegex(ValidationError, "任务 adapter"): + preset_adapter("pi", "pi") + + self.assertEqual(pi.runtime.command if pi and pi.runtime else "", "node") + self.assertEqual( + pi.runtime.minimum_version if pi and pi.runtime else (), + (22, 19, 0), + ) def test_command_recipe_is_explicit_argv_and_requires_confirmation(self) -> None: calls: list[tuple[str, ...]] = [] @@ -173,6 +207,78 @@ def run(argv: tuple[str, ...], **_: object) -> subprocess.CompletedProcess[str]: ): install_tool("openclaw", yes=True, dry_run=False, run=run) + def test_pi_installation_rejects_incompatible_node_before_mutation(self) -> None: + calls: list[tuple[str, ...]] = [] + + def run(argv: tuple[str, ...], **_: object) -> subprocess.CompletedProcess[str]: + calls.append(argv) + return subprocess.CompletedProcess(argv, 0, stdout="v22.15.1\n") + + executables = {"npm": "/fake/npm", "node": "/fake/node"} + with ( + patch( + "dyro.tooling.shutil.which", + side_effect=lambda name: executables.get(name), + ), + self.assertRaisesRegex(DyroError, "Node.js >= 22.19.0"), + ): + install_tool("pi", yes=True, dry_run=False, run=run) + + self.assertEqual(calls, [("/fake/node", "--version")]) + + def test_pi_runtime_probe_captures_the_default_node_version_output(self) -> None: + pi = tool_definition("pi") + self.assertIsNotNone(pi) + completed = subprocess.CompletedProcess( + ("/fake/node", "--version"), + 0, + stdout="v22.19.0\n", + ) + with ( + patch("dyro.tooling.shutil.which", return_value="/fake/node"), + patch("dyro.tooling.subprocess.run", return_value=completed) as run, + ): + issue = tool_runtime_issue(pi) + + self.assertEqual(issue, "") + self.assertTrue(run.call_args.kwargs["text"]) + self.assertEqual(run.call_args.kwargs["stderr"], subprocess.STDOUT) + + def test_pi_installation_accepts_compatible_node_and_verifies_tool(self) -> None: + calls: list[tuple[str, ...]] = [] + + def run(argv: tuple[str, ...], **_: object) -> subprocess.CompletedProcess[str]: + calls.append(argv) + stdout = "v22.19.0\n" if argv == ("/fake/node", "--version") else "" + return subprocess.CompletedProcess(argv, 0, stdout=stdout) + + executables = { + "npm": "/fake/npm", + "node": "/fake/node", + "pi": "/fake/pi", + } + with patch( + "dyro.tooling.shutil.which", + side_effect=lambda name: executables.get(name), + ): + installed = install_tool("pi", yes=True, dry_run=False, run=run) + + self.assertTrue(installed) + self.assertEqual( + calls, + [ + ("/fake/node", "--version"), + ( + "/fake/npm", + "install", + "-g", + "--ignore-scripts", + "@earendil-works/pi-coding-agent@latest", + ), + ("/fake/pi", "--version"), + ], + ) + def test_unknown_install_recipe_fails_closed(self) -> None: with self.assertRaisesRegex(DyroError, "没有内置安装方案"): install_tool("grok", yes=True, dry_run=False) diff --git a/tests/test_updates.py b/tests/test_updates.py index 2384a56..145aa30 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -12,7 +12,7 @@ import sys import tempfile import unittest -from unittest.mock import Mock, patch +from unittest.mock import Mock, call, patch from dyro import __version__ from dyro.errors import DyroError, ValidationError @@ -589,24 +589,135 @@ def test_startup_syncs_outdated_managed_skill(self) -> None: from dyro.cli import _maybe_sync_managed_skill from dyro.integrations import IntegrationState, IntegrationStatus - status = IntegrationStatus( - "skill", - IntegrationState.OUTDATED, - Path("/tmp/mirror"), - Path("/tmp/manifest"), - "outdated", - ) + statuses = { + "skill": IntegrationStatus( + "skill", + IntegrationState.OUTDATED, + Path("/tmp/mirror"), + Path("/tmp/manifest"), + "outdated", + ), + "dispatch": IntegrationStatus( + "dispatch", + IntegrationState.OUTDATED, + Path("/tmp/dispatch"), + Path("/tmp/dispatch.json"), + "outdated", + ), + } plan = Mock() plan.changes = ("升级镜像",) output = StringIO() with ( - patch("dyro.cli.integration_status", return_value=status), + patch("dyro.cli.integration_status", side_effect=statuses.__getitem__), + patch("dyro.cli.sync_managed_skill", return_value=plan) as sync, + redirect_stdout(output), + ): + _maybe_sync_managed_skill() + self.assertEqual( + sync.call_args_list, + [ + call("skill", yes=True, allow_first_install=False), + call("dispatch", yes=True, allow_first_install=False), + ], + ) + self.assertIn("Dyro Skills 已同步", output.getvalue()) + + def test_startup_auto_installs_dispatch_for_managed_control_plane(self) -> None: + from dyro.cli import _maybe_sync_managed_skill + from dyro.integrations import IntegrationState, IntegrationStatus + + statuses = { + "skill": IntegrationStatus( + "skill", + IntegrationState.CURRENT, + Path("/tmp/mirror"), + Path("/tmp/manifest"), + "current", + ), + "dispatch": IntegrationStatus( + "dispatch", + IntegrationState.ABSENT, + Path("/tmp/dispatch"), + Path("/tmp/dispatch.json"), + "absent", + ), + } + plan = Mock(changes=("创建 Dispatch Skill",)) + output = StringIO() + with ( + patch("dyro.cli.integration_status", side_effect=statuses.__getitem__), patch("dyro.cli.sync_managed_skill", return_value=plan) as sync, redirect_stdout(output), ): _maybe_sync_managed_skill() - sync.assert_called_once_with(yes=True, allow_first_install=False) - self.assertIn("控制面 Skill 已同步", output.getvalue()) + + sync.assert_called_once_with( + "dispatch", yes=True, allow_first_install=True + ) + self.assertIn("自动安装 / 同步", output.getvalue()) + + def test_startup_does_not_first_install_skills_without_prior_opt_in(self) -> None: + from dyro.cli import _maybe_sync_managed_skill + from dyro.integrations import IntegrationState, IntegrationStatus + + def absent(integration: str) -> IntegrationStatus: + return IntegrationStatus( + integration, + IntegrationState.ABSENT, + Path(f"/tmp/{integration}"), + Path(f"/tmp/{integration}.json"), + "absent", + ) + + with ( + patch("dyro.cli.integration_status", side_effect=absent), + patch("dyro.cli.sync_managed_skill") as sync, + ): + _maybe_sync_managed_skill() + + sync.assert_not_called() + + def test_post_update_refresh_installs_dispatch_companion(self) -> None: + from dyro.cli import _refresh_skill_via_new_cli + from dyro.integrations import IntegrationState, IntegrationStatus + + statuses = { + "skill": IntegrationStatus( + "skill", + IntegrationState.CURRENT, + Path("/tmp/control"), + Path("/tmp/control.json"), + "current", + ), + "dispatch": IntegrationStatus( + "dispatch", + IntegrationState.ABSENT, + Path("/tmp/dispatch"), + Path("/tmp/dispatch.json"), + "absent", + ), + } + completed = Mock(returncode=0, stdout="synced\n", stderr="") + with ( + patch("dyro.cli.integration_status", side_effect=statuses.__getitem__), + patch( + "dyro.cli._fresh_dyro_argv", + side_effect=lambda *args: ["dyro", *args], + ) as argv, + patch("dyro.cli.subprocess.run", return_value=completed) as run, + redirect_stdout(StringIO()), + ): + _refresh_skill_via_new_cli() + + self.assertEqual( + argv.call_args_list, + [ + call("integration", "sync", "skill", "--yes"), + call("integration", "install", "dispatch", "--yes"), + ], + ) + self.assertEqual(run.call_count, 2) def test_auto_patch_refresh_skips_same_turn_inprocess_skill_sync(self) -> None: """P0 regression: successful refresh must not be overwritten in-process.""" @@ -637,6 +748,7 @@ def test_auto_patch_refresh_skips_same_turn_inprocess_skill_sync(self) -> None: def test_refresh_skill_uses_install_bound_argv(self) -> None: from dyro.cli import _fresh_dyro_argv, _refresh_skill_via_new_cli + from dyro.integrations import IntegrationState, IntegrationStatus argv = _fresh_dyro_argv("integration", "sync", "skill", "--yes") self.assertIn("integration", argv) @@ -647,8 +759,16 @@ def test_refresh_skill_uses_install_bound_argv(self) -> None: ) completed = Mock(returncode=0, stdout="synced\n", stderr="") + absent = IntegrationStatus( + "skill", + IntegrationState.ABSENT, + Path("/tmp/mirror"), + Path("/tmp/manifest"), + "absent", + ) output = StringIO() with ( + patch("dyro.cli.integration_status", return_value=absent), patch("dyro.cli._fresh_dyro_argv", return_value=["dyro", "integration", "sync", "skill", "--yes"]), patch("dyro.cli.subprocess.run", return_value=completed) as run, redirect_stdout(output), diff --git a/uv.lock b/uv.lock index f88d285..5b4f482 100644 --- a/uv.lock +++ b/uv.lock @@ -286,7 +286,7 @@ wheels = [ [[package]] name = "dyro" -version = "0.6.7" +version = "0.6.8" source = { editable = "." } dependencies = [ { name = "cryptography" },