diff --git a/CHANGELOG.md b/CHANGELOG.md index a1893b9..4ab2b9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 0.6.9 - 2026-08-15 + +- Make multi-harness writes a Core peer wave: each task worktree has one + executor, overlapping slices share a `conflict_group`, and wave members are + not live supervisors. Objective budgets now default to `max_parallel = 3`; + when write-capable Providers are ready the effective wave size is + `min(requested, ready_count)`. Empty `conflict_group` values warn in a + parallel wave. `task.executor` can run a ready dispatch adapter inside the + existing task worktree (Cursor edit stays fail-closed); scratch + `dispatch run --mode edit` remains detached and patch-only. `objective tick` + and `task daemon` preview idle-harness bindings and honor per-backend caps. + ## 0.6.8 - 2026-08-15 - Fix Console inspection falsely timing out healthy workspaces by budgeting for diff --git a/README.md b/README.md index 017da7c..a03f688 100644 --- a/README.md +++ b/README.md @@ -298,8 +298,10 @@ preview with `dyro integration install dispatch --dry-run`, then install with 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. +`batch-result`, or `batch-cancel`. Batch V1 is advisory independent fan-out with +at most one scratch edit writer; it is not the delivery write plane, a +dependency DAG, retry queue, or automatic judge. Simultaneous writes use Core +peer wave: one executor per task worktree, serialized only by `conflict_group`. Use explicit synchronous `panel --members all` only for a full-ready-Provider same-task comparison. diff --git a/README.zh-CN.md b/README.zh-CN.md index 8a5fe31..69ad3d6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -293,8 +293,10 @@ Dyro Skill 的机器不会被后台静默写入。 需要 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`。 +一个 scratch 编辑者的建议面 fan-out,不是交付写路径,也不是依赖 DAG、重试队列 +或自动终裁器。要同时改多块,走 Core Peer Wave:每条 task worktree 一个执行位, +只按 `conflict_group` 串行。只有用户明确要求全 Harness 同题比较时,才使用同步 +的 `panel --members all`。 交互运行 `dyro`、`dyro home` 或 `dyro start` 时,Dyro 每个本地自然日最多访问一次官方 PyPI;断网、超时或状态目录不可写都不会阻塞进入工作区。默认仍由用户确认更新: diff --git a/docs/agent-orchestration-discipline.md b/docs/agent-orchestration-discipline.md index b6b2186..5b89a7f 100644 --- a/docs/agent-orchestration-discipline.md +++ b/docs/agent-orchestration-discipline.md @@ -78,7 +78,7 @@ docs/reviews/YYYY-MM-DD--adversarial-board.md | 规则 | 说明 | | --- | --- | -| 一仓一 branch 同时仅一个写 agent | 并行写同一 checkout 会导致已验证修复被静默覆盖 | +| 一棵 task worktree / 同一 `conflict_group` 同时仅一个写 agent | 并行写同一 checkout 会覆盖已验证修复;不同 task 树或不同冲突组可以同时写 | | 改前 `git log -5 -- ` | 避免回退到已废弃的 workaround | | 精确 stage | 禁止盲目 `git add .`;同文件无关 hunk 必须拆分 | | 「验证后又坏了」 | 先查是否有后续 commit 覆盖,再查运行时 | @@ -112,5 +112,5 @@ docs/reviews/YYYY-MM-DD--adversarial-board.md 5. 验证者是否被要求「打开真产物并尝试证伪」? 6. 若 resume:substrate 是否变化? 7. 是否声明缩放妥协? -8. 写路径是否保证单写 agent? -9. 结果是否只回收摘要/契约字段,而非完整事件流? +8. 写路径是否保证每棵 task worktree / 每个 conflict_group 只有一个写 agent,且波次成员都是执行位? +9. 结果是否只回收摘要/契约字段,而非完整事件流? diff --git a/docs/architecture.md b/docs/architecture.md index 151bc69..f1c3212 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -234,4 +234,4 @@ Dyro 的交付拓扑与之**实质相近**:TaskGraph(`depends_on` / conflict 未来的 adapter、通知、签名规则、发布平台与审批系统应使用 Python entry point 或独立 Profile 扩展包接入;不要把某个组织的策略加入 core 默认行为。 -开发者侧的可选本地多 Agent 派发(五段式任务契约、注入前机密守卫、locator 核验、隔离 patch)与上述控制面分层并列,随 `dyro` 安装包分发(`dyro dispatch` / `import experiments.local_agent_dispatch`),但**不**替代 gates/合并;见 [`ADR-0002`](adr/0002-optional-local-agent-dispatch.md)、[`多智能体编排纪律`](agent-orchestration-discipline.md) 与 [`可选本地 Agent 派发设计`](designs/optional-local-agent-dispatch.md)。 +开发者侧的可选本地多 Agent 派发(五段式任务契约、注入前机密守卫、locator 核验、隔离 patch)与上述控制面分层并列,随 `dyro` 安装包分发(`dyro dispatch` / `import experiments.local_agent_dispatch`),但**不**替代 gates/合并。同时写多块走 Core Peer Wave(task worktree + `conflict_group`),见 [`peer-wave-execution.md`](designs/peer-wave-execution.md)、[`ADR-0002`](adr/0002-optional-local-agent-dispatch.md)、[`多智能体编排纪律`](agent-orchestration-discipline.md) 与 [`可选本地 Agent 派发设计`](designs/optional-local-agent-dispatch.md)。 diff --git a/docs/designs/native-continuation-engine.md b/docs/designs/native-continuation-engine.md index b07d4ab..d0443fb 100644 --- a/docs/designs/native-continuation-engine.md +++ b/docs/designs/native-continuation-engine.md @@ -61,7 +61,7 @@ max_actions = 20 max_attempts_per_task = 2 max_failures = 3 max_no_progress_cycles = 2 -max_parallel = 1 +max_parallel = 3 deadline = "2026-10-02T12:00:00Z" ``` diff --git a/docs/designs/optional-local-agent-dispatch.md b/docs/designs/optional-local-agent-dispatch.md index 49bd681..3de910b 100644 --- a/docs/designs/optional-local-agent-dispatch.md +++ b/docs/designs/optional-local-agent-dispatch.md @@ -288,7 +288,8 @@ judge 或多 edit writer。显式 `panel --members all` 仍是同步的全 ready | 场景 | 用哪条路径 | | --- | --- | -| 开发者要第二意见 / 大调研 / patch 竞赛 | ADR-0002 本设计 | +| 同时改多块交付代码 | Core Peer Wave:多 task + worktree + `conflict_group`;见 [peer-wave-execution.md](peer-wave-execution.md) | +| 开发者要第二意见 / 大调研 / scratch patch | ADR-0002 本设计 | | 高风险设计评审 | 本设计 panel + 对抗评审板 | | 生产 evidence / merge | 仅 Dyro 控制面;派发 harness 不可越权 | diff --git a/docs/designs/peer-wave-execution.md b/docs/designs/peer-wave-execution.md new file mode 100644 index 0000000..0dc064f --- /dev/null +++ b/docs/designs/peer-wave-execution.md @@ -0,0 +1,59 @@ +# Peer Wave:多 Harness 并行执行面 + +状态:已接受(随 0.6.x 落地) +范围:Dyro Core 写路径;不替代 gates / review / merge +关联:[多智能体编排纪律](../agent-orchestration-discipline.md) · [ADR-0002](../adr/0002-optional-local-agent-dispatch.md) · [可选本地 Agent 派发](optional-local-agent-dispatch.md) + +## 1. 产品判断 + +多 Harness 的默认画面是 **一波 peer 任务同时写**,不是一路改、其余盯梢。 + +写隔离已经在 Core: + +- 一条 Task 同一时刻只有一个 `executor` +- 每个 Task 有自己的 `task/` worktree +- 重叠切片用 `conflict_group` 串行;不重叠的进入同一波 + +0.6.8 的九路 headless 适配器接到这条写路径上:在 **既有 task worktree** 里跑,不再为交付执行另开 detached patch 树。 + +## 2. 硬规则 + +1. 一棵 task worktree 同一时刻只有一个写 harness。不是全工作区只有一个写。 +2. 同一 `conflict_group` 同一波只进一个 Task。空 `conflict_group` 在 `max_parallel > 1` 时警告,但不互斥。 +3. 波次里每个成员都是执行位。只读意见走 `dyro dispatch panel`;复核是该 Task 的下一阶段,审冻结 HEAD,不看 live 树。 +4. `cursor-agent` 不能进入写波次;其 edit 仍 fail-closed。 +5. Dispatch 仍然不能 merge / push / signoff。交付证据只走 Core gates。 + +## 3. 产品路由 + +| 意图 | 路径 | +| --- | --- | +| 同时改多块 | 拆成 N 条 Task,填 `conflict_group` 与 `executor`,`task daemon --parallel` 或 Objective `max_parallel` | +| 只要第二意见 | `dyro dispatch panel` | +| 还不成 Task 的试改 | `dyro dispatch run`(只读或 detached patch) | +| 禁止默认 | 一个 edit writer + 其余角色陪跑 | + +Batch V1 保持建议面:2–4 个独立角色、最多一个 edit。不要在 Batch 里重做 TaskGraph。 + +## 4. 执行桥 + +`task.executor` 若是已就绪且支持 edit 的 dispatch Provider,`run_task` 调用该适配器: + +- cwd = `_ensure_task_worktrees()` 的根 +- 分支 = `task/` +- 使用适配器自己的隔离 Home 与进程监督 +- 不创建 `EditWorkspace`,不回写源工作区之外的 detached tree +- 终态仍走 receipt → gates → review → merge + +未就绪或非 dispatch 的 executor 回退到 Profile `write` argv,避免没有本机登录时阻断既有工作区。`auto` 只从就绪的可写 Provider 里确定性分配。 + +## 5. 异构波次 + +`objective tick` 与 `task daemon` 预览为每个 ready Task 绑定空闲就绪 harness: + +- 钉死的 `executor` 优先;达到每后端上限则推迟,不静默换人 +- `auto` 按 Provider id 排序领取空闲可写 harness +- 遵守 Objective / daemon 并行容量、`conflict_group`、每后端上限 +- Cursor 作为写 executor 被拒绝 + +Objective 默认 `max_parallel = 3`。本机有就绪可写 Provider 时,有效容量为 `min(requested, ready_write_count)`;没有就绪 Provider 时保持 requested,以便 Profile-only 工作区继续跑。 diff --git a/docs/diagrams.en.md b/docs/diagrams.en.md index 44f399d..6a16668 100644 --- a/docs/diagrams.en.md +++ b/docs/diagrams.en.md @@ -269,7 +269,7 @@ flowchart TB Host -->|"explicit dyro commands"| Dyro ``` -See `docs/agent-orchestration-discipline.md`. The optional harness under `experiments/local_agent_dispatch/` ships with the installed package but never replaces Core gates or merge. +See `docs/agent-orchestration-discipline.md` and `docs/designs/peer-wave-execution.md`. `dyro dispatch` is advisory; simultaneous writes use Core task worktrees, not Batch supervisors. The optional harness under `experiments/local_agent_dispatch/` ships with the installed package but never replaces Core gates or merge. --- diff --git a/docs/diagrams.md b/docs/diagrams.md index fa10837..4da7410 100644 --- a/docs/diagrams.md +++ b/docs/diagrams.md @@ -269,7 +269,7 @@ flowchart TB Host -->|"显式 dyro 命令"| Dyro ``` -见 [`agent-orchestration-discipline.md`](agent-orchestration-discipline.md)。能力:`dyro dispatch` / `experiments.local_agent_dispatch`(随 `dyro` 安装;**不**替代 gates/merge)。 +见 [`agent-orchestration-discipline.md`](agent-orchestration-discipline.md) 与 [`peer-wave-execution.md`](designs/peer-wave-execution.md)。`dyro dispatch` 是建议面;同时写多块走 Core task worktree 波次,不在 Batch 里陪跑监工。能力:`dyro dispatch` / `experiments.local_agent_dispatch`(随 `dyro` 安装;**不**替代 gates/merge)。 --- diff --git a/experiments/local_agent_dispatch/README.md b/experiments/local_agent_dispatch/README.md index 701e61f..3254bbb 100644 --- a/experiments/local_agent_dispatch/README.md +++ b/experiments/local_agent_dispatch/README.md @@ -53,9 +53,10 @@ 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 +Batch V1 is the advisory plane: two to four independent member contracts, +installed Provider candidates without starting authentication CLIs, and at most +one scratch edit writer. Simultaneous delivery writes use Core peer wave +(`docs/designs/peer-wave-execution.md`), not a second Batch 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 diff --git a/pyproject.toml b/pyproject.toml index 487378c..966e69d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "dyro" -version = "0.6.8" +version = "0.6.9" description = "DyroEngineeringFlow: local-first automation and delivery control for multi-repository teams" readme = "README.md" requires-python = ">=3.11" diff --git a/src/dyro/cli.py b/src/dyro/cli.py index 990cb27..06de5bf 100644 --- a/src/dyro/cli.py +++ b/src/dyro/cli.py @@ -36,8 +36,8 @@ ) from .continuation.engine import ( build_scheduler_tick, - render_scheduler_tick_json, render_scheduler_tick_text, + scheduler_tick_payload, ) from .continuation.models import Operation, RequestedMode from .continuation.planner import ( @@ -3192,13 +3192,34 @@ def cmd_objective_tick(args: argparse.Namespace) -> None: ) ) plan = build_continuation_plan(snapshot) + from .peer_wave import ( + annotate_objective_tick, + discover_available_write_providers, + recommended_max_parallel, + ) + + available_write = discover_available_write_providers() tick = build_scheduler_tick( - snapshot, plan, max_parallel=record.objective.budget.max_parallel + snapshot, + plan, + max_parallel=recommended_max_parallel( + record.objective.budget.max_parallel, len(available_write) + ), ) + overlay = annotate_objective_tick(snapshot, plan, tick, available_write) if args.format == "json": - print(render_scheduler_tick_json(tick)) + payload = scheduler_tick_payload(tick) + payload.update(overlay) + print(json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2)) return print(render_scheduler_tick_text(tick)) + for note in overlay.get("peer_wave", {}).get("warnings", []): + print(f"Warning: {note}") + for binding in overlay.get("peer_wave", {}).get("executor_bindings", []): + print( + f"Harness: {binding['task_id']} -> {binding['executor']} " + f"({binding['source']})" + ) def cmd_objective_attention(args: argparse.Namespace) -> None: @@ -3458,7 +3479,20 @@ def cmd_task_daemon(args: argparse.Namespace) -> None: tasks = list_tasks(config) assert_legacy_scheduler_allowed(config, (task.id for task in tasks)) queued = _daemon_select_runnable(config, tasks, limit=max(1, args.parallel)) - if queued: + from .peer_wave import apply_harness_bindings, discover_available_write_providers + from .tasks import ScheduleWave + + available_write = discover_available_write_providers() + bound, decision = apply_harness_bindings( + ScheduleWave(tasks=tuple(queued), deferred=()), + available_write, + ) + for note in decision.warnings: + print(f"warning: {note}") + for item in decision.deferred: + print(f"defer {item.task.id}: {item.reason}") + if bound: + overrides = {item.task_id: item.executor for item in decision.bindings} with ThreadPoolExecutor( max_workers=max(1, args.parallel), thread_name_prefix="dyro-dispatch" ) as pool: @@ -3469,8 +3503,9 @@ def cmd_task_daemon(args: argparse.Namespace) -> None: task, dry_run=args.dry_run, legacy_scheduler=True, + executor_override=overrides.get(task.id), ): task - for task in queued + for task in bound } for future in as_completed(futures): task = futures[future] diff --git a/src/dyro/continuation/contracts.py b/src/dyro/continuation/contracts.py index f073dbc..0c7f1a3 100644 --- a/src/dyro/continuation/contracts.py +++ b/src/dyro/continuation/contracts.py @@ -19,7 +19,7 @@ max_attempts_per_task=2, max_failures=3, max_no_progress_cycles=2, - max_parallel=1, + max_parallel=3, deadline=None, ) MAX_TITLE_LENGTH = 240 diff --git a/src/dyro/continuation/supervision.py b/src/dyro/continuation/supervision.py index 4ec9a0d..6a12ff8 100644 --- a/src/dyro/continuation/supervision.py +++ b/src/dyro/continuation/supervision.py @@ -178,7 +178,19 @@ def build_supervised_wave( raise DyroError("当前 Objective 不是 supervised 模式;拒绝使用受监督 apply") snapshot = build_scheduler_snapshot(config, objective=record, clock=clock) plan = build_continuation_plan(snapshot) - tick = build_scheduler_tick(snapshot, plan, max_parallel=record.objective.budget.max_parallel) + from ..peer_wave import ( + discover_available_write_providers, + recommended_max_parallel, + ) + + tick = build_scheduler_tick( + snapshot, + plan, + max_parallel=recommended_max_parallel( + record.objective.budget.max_parallel, + len(discover_available_write_providers()), + ), + ) actions = tuple(action for action in tick.wave if action.kind in _SUPPORTED_ACTIONS) if len(actions) != len(tick.wave): raise DyroError("当前 wave 含尚未获受监督执行支持的 Action;拒绝部分执行") @@ -246,7 +258,19 @@ def _current_action( raise DyroError("Objective 模式已变化;拒绝执行已确认 Action") snapshot = build_scheduler_snapshot(config, objective=record, clock=clock) plan = build_continuation_plan(snapshot) - tick = build_scheduler_tick(snapshot, plan, max_parallel=record.objective.budget.max_parallel) + from ..peer_wave import ( + discover_available_write_providers, + recommended_max_parallel, + ) + + tick = build_scheduler_tick( + snapshot, + plan, + max_parallel=recommended_max_parallel( + record.objective.budget.max_parallel, + len(discover_available_write_providers()), + ), + ) actual = next((action for action in tick.wave if action == expected), None) if actual is None: raise DyroError("确认后的 Action 已不再位于当前安全 wave;请重新运行 objective apply") @@ -356,7 +380,15 @@ def _task_result_status(action: PlannedAction, result: object) -> tuple[ActionSt def _dispatch(config: Config, action: PlannedAction, task: Task, *, expected_contract_sha256: str) -> object: if action.kind is ActionKind.EXECUTE_TASK: - return run_task(config, task, expected_contract_sha256=expected_contract_sha256) + from ..peer_wave import bind_wave_executors, discover_available_write_providers + + decision = bind_wave_executors((task,), discover_available_write_providers()) + return run_task( + config, + task, + expected_contract_sha256=expected_contract_sha256, + executor_override=decision.executor_for(task.id), + ) if action.kind is ActionKind.REVIEW_TASK: return review_task(config, task, expected_contract_sha256=expected_contract_sha256) raise DyroError("受监督执行只支持 execute_task 与 review_task") diff --git a/src/dyro/integrations/assets/dyro-control-plane/SKILL.md b/src/dyro/integrations/assets/dyro-control-plane/SKILL.md index feea4e6..06ca859 100644 --- a/src/dyro/integrations/assets/dyro-control-plane/SKILL.md +++ b/src/dyro/integrations/assets/dyro-control-plane/SKILL.md @@ -22,7 +22,7 @@ When the request already supplies a workspace alias, skip global discovery and u - Objective explanation: `dyro --workspace objective explain --format json` - Objective blockers or human attention: `dyro --workspace objective attention --format json` - Objective dependency graph: `dyro --workspace objective graph --format json` -- Objective next-wave preview: `dyro --workspace objective tick --format json` +- Objective next-wave preview: `dyro --workspace objective tick --format json`. Treat `peer_wave.executor_bindings` as the intended peer executors for that wave, and `peer_wave.warnings` as missing `conflict_group` or harness-capacity notes. A wave member is an executor, not a live supervisor. - Objective plan: `dyro --workspace objective plan --format json` Use only an existing Objective or Change Set ID returned by Dyro or supplied by the user. A non-zero exit, unavailable workspace, pending transaction, failed finding, missing field, or partial observation is unknown or blocked—not ready. diff --git a/src/dyro/integrations/assets/dyro-dispatch/SKILL.md b/src/dyro/integrations/assets/dyro-dispatch/SKILL.md index 147198d..96a40fa 100644 --- a/src/dyro/integrations/assets/dyro-dispatch/SKILL.md +++ b/src/dyro/integrations/assets/dyro-dispatch/SKILL.md @@ -1,6 +1,6 @@ --- 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. +description: Plan and dispatch bounded advisory work to local coding-agent harnesses, or help split delivery work into parallel Core tasks. Use when the user explicitly asks to parallelize, delegate, compare harnesses, or obtain independent opinions. Default write-parallel work is Core peer wave (task worktrees), not one writer plus watchers. --- # Dyro Dispatch @@ -14,8 +14,9 @@ Treat `dyro dispatch` as an outbound harness, separate from the read-only 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. +- Use `mode=edit` only when the user also authorizes code changes. Scratch + `dispatch run --mode edit` stays in a detached worktree and returns a patch + reference. Delivery writes go to a Core task worktree, not a scratch tree. - 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 @@ -46,16 +47,20 @@ Treat `dyro dispatch` as an outbound harness, separate from the read-only 2. Choose the smallest useful strategy: + - Simultaneous writes on different slices: split into N Core tasks with + honest `conflict_group` values and distinct `executor` agents, then use + `task daemon --parallel` or an Objective `max_parallel` wave. Every wave + member is an executor. Do not park extra harnesses as live supervisors. - 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 + - Different advisory roles that are not yet tasks: use Batch V1. It remains + independent fan-out with at most one scratch edit writer. + - One delegated scratch task: use one asynchronous `run`. + - Review of finished work is the Core task `reviewer` phase on frozen HEADs, + or a later independent `run`. It is not a sibling watching a live writer. + - Cursor cannot join a write wave; its edit path stays fail-closed. + - Keep advisory 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`, diff --git a/src/dyro/peer_wave.py b/src/dyro/peer_wave.py new file mode 100644 index 0000000..1018ded --- /dev/null +++ b/src/dyro/peer_wave.py @@ -0,0 +1,312 @@ +"""Peer-wave scheduling: parallel task executors, not one writer plus watchers.""" + +from __future__ import annotations + +from dataclasses import dataclass +import threading +import time +from typing import Iterable, Mapping, Sequence + +from .errors import ValidationError +from .tasks import ScheduleBlock, ScheduleWave, Task + +_READY_TTL_SECONDS = 30.0 +_READY_LOCK = threading.Lock() +_READY_CACHE: tuple[float, tuple[str, ...]] | None = None + + +AUTO_EXECUTOR = "auto" +PEER_WAVE_CAP = 3 +MAX_PER_BACKEND = 2 +CURSOR_WRITE_PROVIDER = "cursor-agent" + + +@dataclass(frozen=True) +class ExecutorBinding: + task_id: str + executor: str + pinned: bool + source: str + + +@dataclass(frozen=True) +class HarnessDecision: + bindings: tuple[ExecutorBinding, ...] + deferred: tuple[ScheduleBlock, ...] + warnings: tuple[str, ...] + + @property + def bound_tasks(self) -> tuple[str, ...]: + return tuple(item.task_id for item in self.bindings) + + def executor_for(self, task_id: str) -> str | None: + for item in self.bindings: + if item.task_id == task_id: + return item.executor + return None + + +def write_capable_dispatch_ids() -> tuple[str, ...]: + from experiments.local_agent_dispatch.adapters.registry import ( + get_adapter, + list_real_provider_ids, + ) + + capable: list[str] = [] + for provider in list_real_provider_ids(): + adapter = get_adapter(provider) + modes = getattr( + adapter, "supported_modes", frozenset({"read-only", "edit"}) + ) + if "edit" in modes: + capable.append(provider) + return tuple(capable) + + +def discover_available_write_providers() -> tuple[str, ...]: + from experiments.local_agent_dispatch.adapters.registry import get_adapter + + return tuple( + provider + for provider in write_capable_dispatch_ids() + if get_adapter(provider).available() + ) + + +def discover_ready_write_providers(*, force: bool = False) -> tuple[str, ...]: + from experiments.local_agent_dispatch.adapters.registry import ( + adapter_is_authenticated, + get_adapter, + ) + + global _READY_CACHE + now = time.monotonic() + with _READY_LOCK: + if ( + not force + and _READY_CACHE is not None + and now - _READY_CACHE[0] < _READY_TTL_SECONDS + ): + return _READY_CACHE[1] + ready: list[str] = [] + for provider in write_capable_dispatch_ids(): + adapter = get_adapter(provider) + if adapter.available() and adapter_is_authenticated(adapter): + ready.append(provider) + found = tuple(ready) + with _READY_LOCK: + _READY_CACHE = (time.monotonic(), found) + return found + + +def recommended_max_parallel(requested: int, ready_count: int) -> int: + if type(requested) is not int or requested < 1: + raise ValidationError("max_parallel 必须是正整数") + if type(ready_count) is not int or ready_count < 0: + raise ValidationError("ready_count 必须是非负整数") + if ready_count <= 0: + return requested + return max(1, min(requested, ready_count)) + + +def empty_conflict_group_warnings( + tasks: Sequence[Task], *, max_parallel: int +) -> tuple[str, ...]: + if max_parallel <= 1: + return () + empty = tuple(task.id for task in tasks if not task.conflict_group) + if not empty: + return () + return ( + "parallel wave includes tasks without conflict_group: " + + ", ".join(empty), + ) + + +def assert_write_executor_allowed(executor: str, *, risk: str) -> None: + if risk == "write" and executor == CURSOR_WRITE_PROVIDER: + raise ValidationError( + "Cursor Agent 不能进入写波次;其 edit 在沙箱进程生命周期获证前保持 fail-closed" + ) + + +def bind_wave_executors( + tasks: Sequence[Task], + ready_write: Sequence[str], + *, + max_per_backend: int = MAX_PER_BACKEND, +) -> HarnessDecision: + if type(max_per_backend) is not int or max_per_backend < 1: + raise ValidationError("max_per_backend 必须是正整数") + ready = tuple(provider for provider in ready_write if provider) + write_ids = frozenset(write_capable_dispatch_ids()) + counts: dict[str, int] = {} + bindings: list[ExecutorBinding] = [] + deferred: list[ScheduleBlock] = [] + auto_pool = [provider for provider in ready if provider != CURSOR_WRITE_PROVIDER] + + for task in tasks: + try: + assert_write_executor_allowed(task.executor, risk=task.risk) + except ValidationError as exc: + deferred.append(ScheduleBlock(task=task, reason=str(exc))) + continue + if task.executor == AUTO_EXECUTOR: + chosen = _take_idle(auto_pool, counts, max_per_backend) + if chosen is None: + deferred.append( + ScheduleBlock( + task=task, + reason="没有空闲的可写 Harness 可分配给 auto executor", + ) + ) + continue + counts[chosen] = counts.get(chosen, 0) + 1 + bindings.append( + ExecutorBinding( + task_id=task.id, + executor=chosen, + pinned=False, + source="auto", + ) + ) + continue + if task.executor in write_ids and task.executor in ready: + if counts.get(task.executor, 0) >= max_per_backend: + deferred.append( + ScheduleBlock( + task=task, + reason=( + f"写 Harness {task.executor} 已达到每后端上限 " + f"{max_per_backend}" + ), + ) + ) + continue + counts[task.executor] = counts.get(task.executor, 0) + 1 + bindings.append( + ExecutorBinding( + task_id=task.id, + executor=task.executor, + pinned=True, + source="task", + ) + ) + continue + counts[task.executor] = counts.get(task.executor, 0) + 1 + bindings.append( + ExecutorBinding( + task_id=task.id, + executor=task.executor, + pinned=True, + source="profile", + ) + ) + warnings = empty_conflict_group_warnings(tasks, max_parallel=max(1, len(tasks))) + return HarnessDecision( + bindings=tuple(bindings), + deferred=tuple(deferred), + warnings=warnings, + ) + + +def apply_harness_bindings( + wave: ScheduleWave, + ready_write: Sequence[str], + *, + max_per_backend: int = MAX_PER_BACKEND, +) -> tuple[tuple[Task, ...], HarnessDecision]: + decision = bind_wave_executors( + wave.tasks, ready_write, max_per_backend=max_per_backend + ) + bound_ids = set(decision.bound_tasks) + bound_tasks = tuple(task for task in wave.tasks if task.id in bound_ids) + return bound_tasks, HarnessDecision( + bindings=decision.bindings, + deferred=wave.deferred + decision.deferred, + warnings=decision.warnings, + ) + + +def peer_wave_overlay( + *, + tasks: Sequence[Task], + max_parallel: int, + bindings: Sequence[ExecutorBinding] = (), + deferred: Sequence[ScheduleBlock] = (), + extra_warnings: Sequence[str] = (), +) -> dict[str, object]: + warnings = list(empty_conflict_group_warnings(tasks, max_parallel=max_parallel)) + warnings.extend(extra_warnings) + return { + "peer_wave": { + "schema_version": 1, + "warnings": warnings, + "executor_bindings": [ + { + "task_id": item.task_id, + "executor": item.executor, + "pinned": item.pinned, + "source": item.source, + } + for item in bindings + ], + "harness_deferred": [ + {"task_id": item.task.id, "reason": item.reason} for item in deferred + ], + } + } + + +def _take_idle( + pool: Sequence[str], counts: Mapping[str, int], max_per_backend: int +) -> str | None: + for provider in pool: + if counts.get(provider, 0) < max_per_backend: + return provider + return None + + +def annotate_objective_tick( + snapshot: object, + plan: object, + tick: object, + ready_write: Sequence[str], +) -> dict[str, object]: + from .continuation.models import ActionKind + + execute_tasks: list[Task] = [] + wave_tasks: list[Task] = [] + tasks_by_id = getattr(snapshot, "tasks_by_id", {}) + for action in getattr(plan, "selected_actions", ()): + if getattr(action, "kind", None) is not ActionKind.EXECUTE_TASK: + continue + item = tasks_by_id.get(action.subject_id) + if item is None: + continue + execute_tasks.append(item.task) + for action in getattr(tick, "wave", ()): + if getattr(action, "kind", None) is not ActionKind.EXECUTE_TASK: + continue + item = tasks_by_id.get(action.subject_id) + if item is not None: + wave_tasks.append(item.task) + decision = bind_wave_executors(wave_tasks, ready_write) + return peer_wave_overlay( + tasks=execute_tasks or wave_tasks, + max_parallel=int(getattr(tick, "max_parallel", 1)), + bindings=decision.bindings, + deferred=decision.deferred, + ) + + +def iter_bound_pairs( + tasks: Iterable[Task], decision: HarnessDecision +) -> tuple[tuple[Task, str], ...]: + by_id = {task.id: task for task in tasks} + pairs: list[tuple[Task, str]] = [] + for binding in decision.bindings: + task = by_id.get(binding.task_id) + if task is not None: + pairs.append((task, binding.executor)) + return tuple(pairs) diff --git a/src/dyro/task_dispatch.py b/src/dyro/task_dispatch.py new file mode 100644 index 0000000..459a0a2 --- /dev/null +++ b/src/dyro/task_dispatch.py @@ -0,0 +1,148 @@ +"""Run a Core task through a dispatch adapter inside its existing worktree.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Mapping + +from experiments.local_agent_dispatch.adapters.registry import get_adapter +from experiments.local_agent_dispatch.context_guard import guard_file +from experiments.local_agent_dispatch.errors import DispatchValidationError +from experiments.local_agent_dispatch.fileset import SKIP_DIRS +from experiments.local_agent_dispatch.task_contract import parse_task_contract + +from .errors import ValidationError +from .peer_wave import AUTO_EXECUTOR, assert_write_executor_allowed +from .process import Result +from .tasks import Task + + +_MAX_BOUND_FILES = 20 + + +def is_dispatch_provider(executor: str) -> bool: + from experiments.local_agent_dispatch.adapters.registry import list_real_provider_ids + + return executor in list_real_provider_ids() + + +def is_dispatch_write_ready(executor: str) -> bool: + if not is_dispatch_provider(executor): + return False + from experiments.local_agent_dispatch.adapters.registry import ( + adapter_is_authenticated, + ) + + adapter = get_adapter(executor) + modes = getattr(adapter, "supported_modes", frozenset({"read-only", "edit"})) + return ( + "edit" in modes + and adapter.available() + and adapter_is_authenticated(adapter) + ) + + +def collect_bound_files(workspace: Path) -> tuple[str, ...]: + root = Path(workspace).resolve(strict=True) + matched: list[str] = [] + for path in sorted(root.rglob("*")): + if not path.is_file(): + continue + relative = path.relative_to(root) + if any(part in SKIP_DIRS for part in relative.parts): + continue + try: + verdict = guard_file(path, root, read_content=True) + if not verdict.allowed: + continue + except DispatchValidationError: + continue + matched.append(relative.as_posix()) + if len(matched) >= _MAX_BOUND_FILES: + break + return tuple(matched) + + +def build_bound_contract( + task: Task, + *, + executor: str, + workspace: Path, + prompt: str, +) -> object: + files = collect_bound_files(workspace) + if not files: + raise ValidationError( + f"任务 {task.id} 的 worktree 没有可供派发的守卫文件" + ) + mounts = ", ".join(task.repositories) or workspace.name + return parse_task_contract( + { + "schema_version": 1, + "backend": executor, + "mode": "edit" if task.risk == "write" else "read-only", + "strict": False, + "allow_unconfined_provider": executor != "echo", + "allow_offline_simulation": executor == "echo", + "files": list(files), + "task": { + "briefing": f"Dyro task {task.id} on line {task.line}.", + "locations": f"Work only inside {workspace}; repositories: {mounts}.", + "objective": prompt, + "constraints": ( + "Do not merge, push, sign off, or leave the task worktree. " + "Write the receipt at the path given in the objective." + ), + "output_contract": ( + "Update the task receipt with result: DONE, result: BLOCKED, " + "or result: QUESTION." + ), + }, + } + ) + + +def run_task_bound_dispatch( + task: Task, + *, + executor: str, + workspace: Path, + prompt: str, + timeout_seconds: float, + dry_run: bool = False, +) -> Result: + if executor == AUTO_EXECUTOR: + raise ValidationError("auto executor 必须在派发前绑定到具体 Harness") + assert_write_executor_allowed(executor, risk=task.risk) + argv = ("dyro", "task-dispatch", executor, task.id) + if dry_run: + return Result(argv, 0, "") + if executor != "echo" and not is_dispatch_provider(executor): + raise ValidationError(f"executor 不是 dispatch Provider:{executor}") + adapter = get_adapter(executor) + modes = getattr(adapter, "supported_modes", frozenset({"read-only", "edit"})) + required_mode = "edit" if task.risk == "write" else "read-only" + if required_mode not in modes: + raise ValidationError( + f"dispatch Provider 不支持 mode={required_mode}:{executor}" + ) + contract = build_bound_contract( + task, executor=executor, workspace=workspace, prompt=prompt + ) + context: Mapping[str, str] = {} + try: + result = adapter.run( + contract=contract, + cwd=workspace, + context_files=context, + timeout_seconds=timeout_seconds, + ) + except DispatchValidationError as exc: + raise ValidationError(str(exc)) from exc + stdout = "\n".join( + part + for part in (result.summary, result.raw_preview, "\n".join(result.warnings)) + if part + ) + code = 0 if result.status == "ok" else 1 + return Result(argv, code, stdout) diff --git a/src/dyro/tasks.py b/src/dyro/tasks.py index ba74c98..87d38ef 100644 --- a/src/dyro/tasks.py +++ b/src/dyro/tasks.py @@ -1984,6 +1984,72 @@ def run_gates(config: Config, task: Task, *, dry_run: bool = False) -> bool: return all_passed +def _resolve_run_executor(task: Task, executor_override: str | None) -> str: + from .peer_wave import ( + AUTO_EXECUTOR, + bind_wave_executors, + discover_available_write_providers, + ) + + if executor_override: + return executor_override + if task.executor != AUTO_EXECUTOR: + return task.executor + decision = bind_wave_executors((task,), discover_available_write_providers()) + chosen = decision.executor_for(task.id) + if chosen is None: + reason = ( + decision.deferred[0].reason + if decision.deferred + else "无法绑定 auto executor" + ) + raise ValidationError(reason) + return chosen + + +def _execute_task_agent( + config: Config, + task: Task, + *, + workspace: Path, + prompt: str, + log_name: str, + dry_run: bool, + executor_override: str | None = None, +) -> object: + from .peer_wave import assert_write_executor_allowed + from .task_dispatch import is_dispatch_write_ready, run_task_bound_dispatch + + executor = _resolve_run_executor(task, executor_override) + if task.risk == "write": + assert_write_executor_allowed(executor, risk=task.risk) + if is_dispatch_write_ready(executor): + result = run_task_bound_dispatch( + task, + executor=executor, + workspace=workspace, + prompt=prompt, + timeout_seconds=float(task.timeout_minutes * 60), + dry_run=dry_run, + ) + elif executor in config.adapters: + argv = _adapter_argv( + config, + executor, + "write" if task.risk == "write" else "read", + workspace=workspace, + prompt=prompt, + task=task, + ) + result = run( + argv, cwd=workspace, timeout=task.timeout_minutes * 60, dry_run=dry_run + ) + else: + raise ValidationError(f"任务 {task.id} 使用的 Agent adapter 未配置:{executor}") + _capture(task, log_name, result.stdout, dry_run=dry_run) + return result + + def run_task( config: Config, task: Task, @@ -1991,11 +2057,14 @@ def run_task( dry_run: bool = False, legacy_scheduler: bool = False, expected_contract_sha256: str | None = None, + executor_override: str | None = None, ) -> str: _require_local_execution(config, "任务", dry_run=dry_run) if dry_run: _assert_expected_task_contract(task, expected_contract_sha256) - return _run_task(config, task, dry_run=True) + return _run_task( + config, task, dry_run=True, executor_override=executor_override + ) with exclusive_lock(_execution_lock_path(task), timeout_seconds=1.0): try: _reserve_local_execution( @@ -2020,12 +2089,23 @@ def run_task( config, task, attempt, - lambda: _run_task(config, task, dry_run=False, reserved=True), + lambda: _run_task( + config, + task, + dry_run=False, + reserved=True, + executor_override=executor_override, + ), ) def _run_task( - config: Config, task: Task, *, dry_run: bool, reserved: bool = False + config: Config, + task: Task, + *, + dry_run: bool, + reserved: bool = False, + executor_override: str | None = None, ) -> str: if not reserved: _reserve_local_execution( @@ -2042,25 +2122,22 @@ def _run_task( if not dry_run: set_status(config, task, "failed") raise - argv = _adapter_argv( + result = _execute_task_agent( config, - task.executor, - "write" if task.risk == "write" else "read", + task, workspace=workspace, prompt=_prompt(task, "executor", workspace), - task=task, - ) - result = run( - argv, cwd=workspace, timeout=task.timeout_minutes * 60, dry_run=dry_run + log_name="executor.log", + dry_run=dry_run, + executor_override=executor_override, ) - _capture(task, "executor.log", result.stdout, dry_run=dry_run) if not dry_run: ledger( config, task.id, "executor", agent=task.executor, - argv=list(argv), + argv=list(result.argv), exit_code=result.code, ) if result.code != 0: @@ -2332,18 +2409,14 @@ def _answer_task( if not dry_run: set_status(config, task, "failed") raise - argv = _adapter_argv( + result = _execute_task_agent( config, - task.executor, - "write" if task.risk == "write" else "read", + task, workspace=workspace, prompt=_prompt(task, "continuation", workspace), - task=task, - ) - result = run( - argv, cwd=workspace, timeout=task.timeout_minutes * 60, dry_run=dry_run + log_name="executor-continuation.log", + dry_run=dry_run, ) - _capture(task, "executor-continuation.log", result.stdout, dry_run=dry_run) if result.code != 0: set_status(config, task, "failed", dry_run=dry_run) return "failed" @@ -3033,6 +3106,7 @@ def task_template( review_timeout_minutes = 45 depends_on = [] blocked_on = [] +# Overlapping slices must share a conflict_group; distinct slices stay parallel. conflict_group = "" [executor] diff --git a/tests/test_continuation_contracts.py b/tests/test_continuation_contracts.py index 1bbea7c..7505dfe 100644 --- a/tests/test_continuation_contracts.py +++ b/tests/test_continuation_contracts.py @@ -37,7 +37,7 @@ def test_parses_minimal_contract_with_safe_defaults(self) -> None: self.assertEqual(objective.completion, CompletionRule.ALL_TARGETS_INTEGRATED) self.assertEqual(objective.requested_mode, RequestedMode.SUPERVISED) self.assertEqual(objective.operations, (Operation.EXECUTE, Operation.REVIEW)) - self.assertEqual(objective.budget.max_parallel, 1) + self.assertEqual(objective.budget.max_parallel, 3) self.assertEqual(objective.budget.max_attempts_per_task, 2) def test_parses_complete_contract(self) -> None: diff --git a/tests/test_continuation_supervision.py b/tests/test_continuation_supervision.py index 3a9d42d..b8374d8 100644 --- a/tests/test_continuation_supervision.py +++ b/tests/test_continuation_supervision.py @@ -70,7 +70,7 @@ def _wave(self, *, max_actions: int = 20): def test_action_start_precedes_task_api_and_success_receipt_is_bound(self) -> None: wave = self._wave() - def invoke(config, task, *, expected_contract_sha256): + def invoke(config, task, *, expected_contract_sha256, **_unused): records = list_objective_actions(config, "release") self.assertEqual(len(records), 1) self.assertIsNotNone(records[0].start) diff --git a/tests/test_peer_wave.py b/tests/test_peer_wave.py new file mode 100644 index 0000000..948b6ea --- /dev/null +++ b/tests/test_peer_wave.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from pathlib import Path +import tempfile +import unittest + +from dyro.errors import ValidationError +from dyro.peer_wave import ( + AUTO_EXECUTOR, + bind_wave_executors, + empty_conflict_group_warnings, + recommended_max_parallel, + write_capable_dispatch_ids, +) +from dyro.task_dispatch import run_task_bound_dispatch +from dyro.tasks import SchedulePlan, Task, select_task_wave + + +class PeerWaveTests(unittest.TestCase): + def test_recommended_parallel_keeps_request_when_no_ready_providers(self) -> None: + self.assertEqual(recommended_max_parallel(3, 0), 3) + self.assertEqual(recommended_max_parallel(3, 1), 1) + self.assertEqual(recommended_max_parallel(3, 9), 3) + + def test_empty_conflict_group_warns_only_in_parallel_waves(self) -> None: + tasks = (self._task("A"), self._task("B", conflict_group="api")) + self.assertEqual(empty_conflict_group_warnings(tasks, max_parallel=1), ()) + warnings = empty_conflict_group_warnings(tasks, max_parallel=3) + self.assertEqual(len(warnings), 1) + self.assertIn("A", warnings[0]) + self.assertNotIn("B", warnings[0]) + + def test_select_wave_allows_distinct_conflict_groups_and_blocks_same_group( + self, + ) -> None: + ready = ( + self._task("API-1", conflict_group="api"), + self._task("WEB-1", conflict_group="web"), + self._task("API-2", conflict_group="api"), + ) + wave = select_task_wave(SchedulePlan(ready=ready, blocked=()), limit=3) + self.assertEqual([task.id for task in wave.tasks], ["API-1", "WEB-1"]) + self.assertEqual(wave.deferred[0].task.id, "API-2") + self.assertIn("api", wave.deferred[0].reason) + + def test_bind_wave_assigns_auto_and_caps_ready_backends(self) -> None: + tasks = ( + self._task("T-A", executor=AUTO_EXECUTOR), + self._task("T-B", executor=AUTO_EXECUTOR), + self._task("T-C", executor=AUTO_EXECUTOR), + ) + decision = bind_wave_executors(tasks, ("claude", "kimi"), max_per_backend=1) + self.assertEqual( + [item.executor for item in decision.bindings], ["claude", "kimi"] + ) + self.assertEqual(decision.deferred[0].task.id, "T-C") + self.assertFalse(decision.bindings[0].pinned) + + def test_bind_wave_rejects_cursor_write_and_keeps_unready_pin_on_profile( + self, + ) -> None: + tasks = ( + self._task("T-CUR", executor="cursor-agent"), + self._task("T-PIN", executor="codex"), + ) + decision = bind_wave_executors(tasks, ()) + self.assertEqual(decision.deferred[0].task.id, "T-CUR") + self.assertEqual(decision.bindings[0].task_id, "T-PIN") + self.assertEqual(decision.bindings[0].source, "profile") + + def test_cursor_cannot_run_bound_write_dispatch(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workspace = Path(temporary) + (workspace / "module.py").write_text("value = 1\n", encoding="utf-8") + task = self._task("T-CUR", executor="cursor-agent") + with self.assertRaisesRegex(ValidationError, "写波次"): + run_task_bound_dispatch( + task, + executor="cursor-agent", + workspace=workspace, + prompt="do not write", + timeout_seconds=1.0, + ) + + def test_echo_bound_dispatch_writes_in_given_worktree_not_detached_tree( + self, + ) -> None: + with tempfile.TemporaryDirectory() as temporary: + workspace = Path(temporary) + (workspace / "module.py").write_text("value = 1\n", encoding="utf-8") + task = self._task("T-ECHO", executor="echo") + result = run_task_bound_dispatch( + task, + executor="echo", + workspace=workspace, + prompt="result: DONE", + timeout_seconds=1.0, + ) + self.assertEqual(result.code, 0) + self.assertIn("echo-adapter", result.stdout) + self.assertFalse(any(workspace.rglob("changes.patch"))) + self.assertTrue((workspace / "module.py").is_file()) + + def test_write_capable_ids_exclude_cursor(self) -> None: + self.assertIn("codex", write_capable_dispatch_ids()) + self.assertNotIn("cursor-agent", write_capable_dispatch_ids()) + + @staticmethod + def _task( + task_id: str, *, conflict_group: str = "", executor: str = "codex" + ) -> Task: + return Task( + id=task_id, + title=task_id, + line="alpha", + risk="write", + executor=executor, + reviewer="reviewer", + repositories=("api",), + conflict_group=conflict_group, + directory=Path("/tmp") / task_id, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock index 5b4f482..c0d5b5e 100644 --- a/uv.lock +++ b/uv.lock @@ -286,7 +286,7 @@ wheels = [ [[package]] name = "dyro" -version = "0.6.8" +version = "0.6.9" source = { editable = "." } dependencies = [ { name = "cryptography" },