From 480ab1f4a34418c2db8faff0be10000e0e80ff5b Mon Sep 17 00:00:00 2001 From: DandreYang <13072547+Dandre126@users.noreply.github.com> Date: Mon, 31 Aug 2026 01:28:09 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(=E6=8E=A7=E5=88=B6=E9=9D=A2):=20?= =?UTF-8?q?=E6=9C=AA=E5=8F=91=E5=B8=83=E5=88=86=E6=94=AF=E5=91=8A=E8=AD=A6?= =?UTF-8?q?=E3=80=81=E5=A4=8D=E6=A0=B8=E9=80=80=E5=87=BA=E7=A0=81=E4=B8=8E?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E5=85=B3=E9=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 未 push 的开发线不再把 next/start 卡成 needs_repair;task close 拒绝符号链接工作树;dry-run 与复核拒绝按契约执行。 --- CHANGELOG.md | 33 + README.md | 4 +- ...ty-and-p0-remediation-adversarial-board.md | 266 +++++++ src/dyro/cli.py | 70 +- src/dyro/events.py | 73 +- .../assets/dyro-dispatch/SKILL.md | 7 +- src/dyro/tasks.py | 242 ++++++- src/dyro/workspace.py | 26 +- tests/support.py | 17 + tests/test_blueprint.py | 6 +- tests/test_cli.py | 51 +- tests/test_continuation_supervision.py | 12 +- tests/test_events.py | 6 +- tests/test_hub.py | 6 +- tests/test_proof_cli.py | 6 +- tests/test_proof_decay.py | 10 +- tests/test_proof_derive.py | 10 +- tests/test_review_remediation.py | 673 ++++++++++++++++++ tests/test_tasks.py | 39 +- tests/test_workspace.py | 48 +- 20 files changed, 1432 insertions(+), 173 deletions(-) create mode 100644 docs/reviews/2026-08-31-honesty-and-p0-remediation-adversarial-board.md create mode 100644 tests/test_review_remediation.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e1b748..950741a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,39 @@ ## Unreleased +对使用者的影响:未 push 的开发线不再把 `dyro next` / `dyro start` +卡成 needs_repair;doctor 只 WARN 缺 `origin/`。`line spawn` +从父线本地 HEAD 起,不再跟过期远端。`task create` 可多仓、默认本机 +adapter,并把 Profile `verify` 写成门禁。`task review` 拒绝时非零退出。 +`--dry-run host compile` / `proof export` 不再落盘;本地 Profile 的 +`task gates --dry-run` 会真跑门禁,外部 Profile 则拒绝在本机执行。 +`task merge --dry-run` 会探测冲突并 abort。新增 `task close`。 + +- Missing `origin/` is a doctor WARN, not FAIL. Dyro still + does not push; `line create` prints the `git push -u` command to run + yourself. `dyro next` / `dyro start` stay ready when that is the only + gap. `line spawn` uses the parent line's local branch, not a stale + `origin/`. +- `task create --repository` may be repeated. New tasks copy Profile + `repositories.*.verify` into `[[gates]]`, default executor/reviewer to + the first configured adapter, require the line worktree to sit on + `line.branch` before releasing a dependency, rotate `events.jsonl` + instead of failing at 2 MiB, keep status writes if event append fails, + and discard a leftover `receipt.md` at the start of each run. +- `--dry-run` no longer writes host projections, proof zip files, or a + fake merged task. Gate dry-run executes the gate argv on a local + Profile; an external Profile refuses to run argv on the operator host. + `task review` exits non-zero on reject, including a binding mismatch, + without treating that reject as an uncertain supervised action. + `task close` removes the task worktree and `task/` branch after + done/failed: symlink mounts are refused, done close requires a clean + tree already merged into `line.branch`, and failed close force-removes + a dirty tree. Empty-`verify` multi-repo tasks get unique + `diff-check-` gate names. Overlay event readers stitch + `events.jsonl.` archives when the current file is missing and + ignore non-seq suffixes such as `.bak`. Dispatch Skill no longer cites + a nonexistent `--mode` flag. + ## 0.7.12 - 2026-08-23 - Fix `dyro update` for uv-managed installs that were pinned with an exact diff --git a/README.md b/README.md index 5a9449d..ba2de62 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ The dashboard surfaces workspace health, attention, task execution counts, activ - Gates are executed by the orchestrator; an agent's self-report is not evidence of success. - Review is bound to the execution receipt and exact per-repository task HEADs; source drift invalidates it. - A task needs independent review before it becomes `done`; merge and push require explicit confirmation by default. -- A completed dependency releases downstream work only after its exact task HEADs are integrated into the owning development line. +- A completed dependency releases downstream work only after its exact task HEADs are integrated into the owning development line **and** that line worktree is on `line.branch` (enforced). Detached/wrong-branch is a merge/dispatch preflight, not Proof decay. - Executable configuration is represented as argv arrays. The core never runs TOML-provided shell strings. ## Architecture and flow diagrams @@ -646,7 +646,7 @@ This README is maintained in English, Simplified Chinese, Korean, Spanish, Frenc ## Current boundaries -DyroEngineeringFlow provides a complete local workflow loop and policy controls for keeping stricter teams in planning-only local mode. It does not create remote repositories, ship SaaS credentials, or provision external runners; it does provide a portable evidence-package contract for external execution. The optional local dispatch harness ships as `experiments.local_agent_dispatch` and is available as `dyro dispatch …`; it is advisory and never replaces gates, review, signoff, or merge. Local multi-repository merges are preflighted and recovered as one operation; remote Git servers cannot provide atomic cross-repository push, so partial push failure is recorded for recovery. Automatic merge requires permission in both the task manifest and local policy. It is available under the [MIT License](LICENSE) and as [`dyro` on PyPI](https://pypi.org/project/dyro/). +DyroEngineeringFlow provides a complete local workflow loop and policy controls for keeping stricter teams in planning-only local mode. It does not create remote repositories, ship SaaS credentials, provision external runners, or `git push` unless `policy.allow_push` is on and the command is explicit. Unpublished `origin/` is a doctor WARN, not a FAIL. It does provide a portable evidence-package contract for external execution. The optional local dispatch harness ships as `experiments.local_agent_dispatch` and is available as `dyro dispatch …`; it is advisory and never replaces gates, review, signoff, or merge. Local multi-repository merges are preflighted and recovered as one operation; remote Git servers cannot provide atomic cross-repository push, so partial push failure is recorded for recovery. Automatic merge requires permission in both the task manifest and local policy. `--dry-run` means no Dyro writes and, for merge, a real conflict probe that is aborted. It is available under the [MIT License](LICENSE) and as [`dyro` on PyPI](https://pypi.org/project/dyro/). ### Graph Engineering (optional reading) diff --git a/docs/reviews/2026-08-31-honesty-and-p0-remediation-adversarial-board.md b/docs/reviews/2026-08-31-honesty-and-p0-remediation-adversarial-board.md new file mode 100644 index 0000000..ca3aaad --- /dev/null +++ b/docs/reviews/2026-08-31-honesty-and-p0-remediation-adversarial-board.md @@ -0,0 +1,266 @@ +# Dyro missing-origin / 诚实性 / 任务关闭 会审 + +Date: 2026-08-31 + +Scope: 未发布开发线的 doctor/next 行为、`task create` 多仓与门禁、`events.jsonl` 轮转、`--dry-run` / 复核退出码、`task close` 与 leftover receipt。不是 `task review` PASS,不是 Proof,不是发版。 + +SSOT: 当前工作区源码(`feat/dev_0814` 工作树,相对生产基线的未提交变更)。本记录不是交付门。 + +## Rules + +1. 每位评审员只写自己的签字章节,不改写他人章节。 +2. 源码和现场契约高于会话里的设计口头约定与同批席位意见。 +3. 无法证明标 `须人工核`。 +4. 仲裁只去重、裁定冲突、输出 P0/P1/P2 与 Go/No-Go。 +5. 本会审不 merge、不 signoff、不发布。commit/push 是用户本轮另授的交付动作,不是会审授权。 + +## Frozen Baseline (2026-08-31) + +| Object | Ref | SHA | Date | +| --- | --- | --- | --- | +| 生产基线 | `origin/main` | `f321e39df4b3994657e483d58e27d191a6765011` | 2026-08-23 | +| 当前开发线 | `feat/dev_0814`(与 `origin/main` 同 SHA,其上为本波未提交修复) | 同上 + working tree | 2026-08-31 | +| `origin/feat/dev_0814` | 落后本工作树所基于的 main | `6986f63de6bc65ee0a33cb6dc6d3fbe89368a22d` | — | +| 当前 `origin/release` | **不存在** | — | — | +| 历史 `origin/release/v0.6.1` | 历史线,不是当前生产 | `d90835e701658d31e78c196ab252825ead1c96f9` | — | + +**不得默认 main 的说明:** 协议默认生产是 `origin/release`。本仓当前没有 `origin/release`,生产线按 `origin/main` @ `f321e39` 记录。`main` 相对该生产基线领先 0 commit。先前 delivery-physics 会审曾用当时的 `origin/main` 当已发布基线;本轮因缺少现行 `origin/release` 继续用 `origin/main`,并显式写出原因。 + +`origin/feat/dev_0814` 落后 `origin/main` 22 commit。本波是在已与 `origin/main` 对齐的 `feat/dev_0814` 上的行为修复,不是从过期远端功能分支分叉。 + +## 先前会审 + +已检索 `docs/reviews/` 与 `docs/superpowers/reviews/`。 + +- 同开发线、**同主题**(missing-origin / dry-run 诚实性 / `task close`):**已检索·无先前会审**。 +- 同仓不同主题:`docs/reviews/2026-08-19-slash-review-and-task-merge.md`(斜杠包装层);`docs/superpowers/reviews/2026-08-15*` 与 `2026-08-16-delivery-physics-shipped-implementation-adversarial-review-board.md`(交付物理 / Proof)。那些 P0/P1 不在本波范围,**不记为本波已闭环**。 + +本目录沿用 `docs/reviews/`(与 2026-08-19 记录同一约定)。 + +## 席位 + +| Seat | Dispatch | Window | Status | +| --- | --- | --- | --- | +| security | 第一批派出 | ≥10 min,首派开始计时 | 交卷 ~15.7 min | +| silent-failure | 第一批派出 | 同上 | 交卷 ~11.3 min | +| python-cli | 第一批派出 | 同上 | 交卷 ~13.3 min | + +无 `逾期未交`。主席在交卷后对席位主张做了源码复核,并修了交卷后仍成立的 P1(见仲裁「交卷后补丁」)。 + +--- + +# Security Review Section + +Reviewer: security +Time: 2026-08-31 +Verdict: Conditional Go + +P0: none. P1: two (close confused-deputy; merge `--dry-run` mutates the line). Leftover `receipt.md` symlink delete-through and event-archive glob injection: 无 P0/P1 (disproved or fail-closed). + +### SEC-P1-01 — `close_task` jail is check/use split; `--force` can retarget the line worktree + +Severity: P1. Path: `src/dyro/tasks.py` remove loop vs jail only in validate loop. + +Trigger: Task is `failed` (or `done`). Loop 1 sees a real task worktree. Before loop 2, `destination` is replaced with a symlink pointing at the line worktree. Then `task close --yes`. + +Impact: Loop 2 did not re-run path jail. `git worktree remove --force` can realpath-match the line worktree. + +Disprove attempt: Static symlink-at-mount and symlink-at-task-root are refused. Tests only cover that static case. + +Must-fix: Re-apply path jail immediately before `worktree remove`. + +### SEC-P1-02 — `task merge --dry-run` is a real merge on the line, not a no-op + +Severity: P1. Path: `src/dyro/tasks.py` dry-run merge; `src/dyro/cli.py` `--yes` copy. + +Trigger: `dyro task merge --dry-run`. + +Impact: Dry-run runs `git merge --no-ff --no-commit` on the line worktree, then abort. Overlay is not written; git is mutated until abort. + +Disprove attempt: Product CHANGELOG discloses probe-and-abort. `--help` at seat time still said dry-run does not write Git. + +### P2 (security) + +- SEC-P2-01 local `run_gates(dry_run=True)` executes argv (documented). +- SEC-P2-02 leftover receipt unlink is POSIX-safe for symlink delete-through; leftover only deletes `task.directory/receipt.md`. +- SEC-P2-03 event archives name-scoped and fail-closed. +- SEC-P2-04 line-branch check does not walk parent symlinks. +- SEC-P2-05 review binding CLI `failed` ≠ task status `failed`. + +Hunts 无 P0: leftover symlink delete-through disproved; event glob injection disproved as P0/P1; static close jail mitigated, TOCTOU was P1-01. + +Independent check complete. + +--- + +# Silent-Failure Review Section + +Reviewer: silent-failure +Time: 2026-08-31 +Verdict: Conditional Go + +任务路径 9 项已闭环。席位交卷时:**开发线 merge/sync 仍把 `probed.append` 放在 `git()` 之后**。无 P0「CLI/overlay 把失败报成成功」。 + +### SF-1 — P1(交卷时开放) + +位置:`src/dyro/workspace.py` `_merge_line_repositories_locked`。`line merge` / `line sync`(含 `--dry-run`)在 `git merge --no-ff --no-commit` 超时后,`probed.append` 在 `git()` 之后,当前仓可留下 `MERGE_HEAD`。CLI 不会假成功。任务 merge 路径当时已先 append 再 git。 + +### SF-2 — P2 + +`set_status` 写入 status 后 `append_event` 失败:状态文件已变;overlay 缺 `task_status` 行且在日志仍合法时 `complete=True`。CLI 不假成功。Ledger 有 `event_append_failed` + `error_code`。 + +### 已闭环(席位对照当时源码) + +1. `set_status` 吞掉 `EventLogError` 无痕迹 — 已闭环(静默无痕迹)。Overlay 完整性见 SF-2。 +2. 缺 current `events.jsonl` 却有 archive 当成空且 complete — 已闭环。 +3. `_event_archive_files` 吃进 `.bak` — 已闭环。 +4. 任务 merge dry-run 超时留下 `MERGE_HEAD` — 任务路径已闭环。开发线路径为 SF-1。 +5. `close_task` `-d` 测 overlay HEAD — 已闭环(对 `line.branch` 做 ancestor,再 `-D`)。仍非多仓原子。 +6. 绑定失败 raise 导致监督 UNCERTAIN — 已闭环(`return "failed"` → `ActionStatus.FAILED`)。 +7. external dry-run 执行门禁 argv — 已闭环。 +8. cwd-git 走到 overlay — 已闭环。 +9. 重复 `diff-check` 名 — 已闭环。 +10. leftover receipt symlink 跳过 — 已闭环(`unlink` 链接本身)。 + +Independent check complete. + +--- + +# CLI Contract Review Section + +Reviewer: python-cli +Time: 2026-08-31 +Verdict: Conditional Go + +五条指定契约在写路径上成立。交卷时剩下的是 CLI 诚实性缺口。 + +### 交卷时 P1 + +1. 全局 `--dry-run` 帮助仍声称不写 Git,但 `task merge --dry-run` 会真实 `git merge`。`src/dyro/cli.py` `_add_common`。 +2. `task create --dry-run` 不执行空 adapters 拒绝。空 adapter 检查在 dry-run early-return 之后。 + +### 交卷时 P2 + +- `task merge --dry-run --push` 文案声称并推送,push 探测被 `git(..., dry_run=True)` 跳过。 +- `task loop` / `task daemon` 对复核 `"failed"` 不非零退出。 +- 事件归档 `str.isdigit()` 会把 Unicode 数字当归档名。 +- `done` 关闭的 dirty / ancestor 门没有自动化测试。 +- 轮转测试有一句恒真断言。 + +### 已闭环(席位) + +1. `--dry-run` 不写 Dyro overlay。 +2. 本地 gate dry-run 仍执行 argv;外部 dry-run 不执行 argv。 +3. `task review` 拒绝(含 binding mismatch)非零退出;不 `set_status(failed)`。 +4. 受监督 apply 把 `"failed"` 映射为 FAILED 而非 UNCERTAIN。 +5. `task close`:done 要干净且已是 `line.branch` ancestor;failed 用 `--force`;拒绝 symlink mount。 +6. 空 `verify` 每仓唯一门禁名;空 adapters 在写路径拒绝。 +7. 事件轮转 `events.jsonl.`;忽略 `.bak`;缺 current 仍缝归档。 + +Independent check complete. + +--- + +# Chair independent checks + +主席在席位交卷后对照**当前**源码复核,不把席位票当事实。 + +1. 无现行 `origin/release`。生产基线 = `origin/main` `f321e39`。已确认。 +2. `close_task` 静态 symlink / 任务根 symlink:源码 `_assert_task_worktree_path` 沿 leaf→root 拒绝 symlink,且 resolved 必须严格落在 worktree_root 下。测试 `test_task_close_refuses_symlink_mount_and_keeps_line_worktree`、`test_task_close_refuses_symlinked_task_root` 绿。 +3. SEC-P1-01 双循环 TOCTOU:交卷时成立。交卷后 mutate 循环在 `worktree remove` 前再次 `_assert_task_worktree_path` + `_validate_task_worktree`。同源进程在两次循环之间替换路径会被第二次 jail 拦住。仍不是跨进程锁。 +4. SEC-P1-02:本波产品契约就是 merge `--dry-run` 做真实 `--no-ff --no-commit` 探测再 abort(CHANGELOG / README 已写)。这不是「声称没跑 Git」。`--help` 交卷后已改成与 README 一致。不升为 P0,不阻断本波 commit。 +5. SF-1 开发线 merge:交卷时成立。交卷后 `workspace.py` 改为先 `probed.append` 再 `git()`。测试 `test_line_merge_timeout_still_aborts_merge_head` 绿。 +6. `task create --dry-run` 空 adapters:交卷后检查移到 early-return 之前。同一测试覆盖写路径与 dry-run,均 exit 2。 +7. 事件归档 Unicode digit:交卷后 `suffix.isascii() and suffix.isdigit()`。 +8. 恒真断言:交卷后改为 `records[0]["seq"] == 1`。 + +## 交卷后补丁(相对席位读到的树) + +| 项 | 动作 | +| --- | --- | +| SF-1 开发线 merge 超时 | `workspace.py` 先登记 probed | +| python-cli P1 空 adapters dry-run | `cmd_task_create` 先拒绝再 dry-run return | +| python-cli P1 `--help` | `--dry-run` 帮助与 README 对齐 | +| python-cli P2 Unicode 归档名 | `isascii()` | +| python-cli P2 恒真断言 | 断言 seq 从 1 起 | +| SEC-P1-01 remove 前再 jail | mutate 循环再次校验路径 | + +--- + +# Final Arbitration + +Arbiter: 会审主席 +Time: 2026-08-31 + +Final verdict: **Go for commit + push `feat/dev_0814`。No-Go for merge / tag / release / 生产。** + +会审 Go 不构成 merge、signoff、发版。用户本轮另外授权了 commit 与 push。 + +## P0 + +无。没有已证实的「本 CLI 在操作员未确认时会自己 push / 发布 / 删掉开发线 worktree(静态路径)」路径。SEC-P1-01 的静态 symlink 已拒绝;交卷后 remove 前再 jail。同用户进程 TOCTOU 降为残余 P2:同一用户已能直接删 line worktree。 + +## P1(本波已闭环) + +1. 未 push 的 `origin/` 不再把 next/start 卡成 needs_repair;doctor WARN。 +2. `line spawn` 从父线本地 HEAD 起。 +3. `task create --repository` 可重复;Profile `verify` 进门禁;空 verify 使用 `diff-check-`。 +4. leftover `receipt.md` 在新一轮执行开始时删除(含 symlink)。 +5. `events.jsonl` 按 `events.jsonl.` 轮转;读者忽略非 seq 后缀;缺 current 仍缝归档。 +6. `set_status` 在事件追加失败时保留状态写入并 ledger `error_code`。 +7. `task review` 绑定/哈希不匹配返回 `failed`,CLI 非零,监督层 FAILED 而非 UNCERTAIN;任务可留在 `review` 以便改 `review.md`。 +8. 外部 Profile 的 gates `--dry-run` 不在本机执行 argv。 +9. `task close` 拒绝 symlink mount / 任务根 symlink;done 要求干净且已合入 `line.branch`;failed 才 `--force`;remove 前再校验路径。 +10. 任务 merge 与开发线 merge 的 dry-run 探测:先登记 probed,超时仍 abort `MERGE_HEAD`。 +11. 空 adapters 在写路径与 `--dry-run` 均拒绝。 + +## P1(已接受残差,不阻断本波 push) + +- **Merge `--dry-run` 会短暂改 line 的 index / `MERGE_HEAD` 再 abort。** 这是本波明确的冲突探测契约,不是静默成功。抛开探测改成 `merge-tree` / 一次性 clone 是后续产品选择,不是本波 P0。 + +## P2 + +- overlay 在 `event_append_failed` 后仍可对缺行日志报 complete(CLI 不假成功)。 +- `task loop` / `task daemon` 对 review `"failed"` 仍退出 0。 +- `task merge --dry-run --push` 不真正预检 push。 +- done 关闭的 dirty / 未合入路径缺专项测试(实现已有门)。 +- 本地 gates `--dry-run` 仍执行 argv(已文档化)。 + +## Go / No-Go + +| 对象 | 结论 | +| --- | --- | +| 提交本波到 `feat/dev_0814` | **Go**(用户本轮授权 commit) | +| 推送到 `origin/feat/dev_0814` | **Go**(用户本轮授权 push;push ≠ 发布) | +| 合并进 `origin/main` / 打 tag / 发版 | **No-Go** | +| 用本会审代替 `task review` / Proof | **No-Go** | + +## 测试证据 + +命令(无 pipeline 吞退出码): + +```text +.venv/bin/python -m unittest tests.test_review_remediation +``` + +摘要:`Ran 26 tests in 13.826s` / `OK`(交卷后、remove 再 jail 之前的全模块)。 + +```text +.venv/bin/python -m unittest tests.test_review_remediation tests.test_tasks tests.test_workspace tests.test_events tests.test_cli.StartTests tests.test_blueprint tests.test_continuation_supervision tests.test_hub +``` + +摘要:`Ran 215 tests in 109.964s` / `OK` / exit 0。含 remove 再 jail 之后的工作树。stdout 中有预期的 `错误:任务 T1 复核拒绝` 与 `错误:未配置任何 Agent adapter,无法创建任务`(断言非零退出)。 + +未执行:完整 `tests/` 全仓、CI、真实多仓工作区上的 `task close` 对抗替换。标 **未执行**。 + +`test_legacy_board_status_stays_on_protocol_id` 不在本命令内。历史会话曾在隔离运行时看到 JSON `UNSAFE_FILE`;本波未改 integrations,**须人工核** 是否为环境问题。 + +## 须人工核 + +1. Windows junction:`Path.is_symlink()` 对 junction 可能为假。本波在 macOS 上验证。 +2. 真实 `subprocess.TimeoutExpired` 杀 git 子进程后,abort 是否总成功。测试用「merge 返回后再抛」模拟。 +3. 把 merge dry-run 改成完全不碰 line worktree,是否值得下一刀产品变更。 + +## Next human Dyro command + +无发明。本会审不查询未命名工作区的 `dyro next`。交付动作是用户已授权的 commit + push `feat/dev_0814`。 diff --git a/src/dyro/cli.py b/src/dyro/cli.py index 922841c..16efaa7 100644 --- a/src/dyro/cli.py +++ b/src/dyro/cli.py @@ -217,9 +217,11 @@ import_review_evidence, list_tasks, load_task, + close_task, loop_tasks, merge_task, plan_tasks, + profile_task_gates, review_task, run_gates, run_task, @@ -3025,6 +3027,8 @@ def _create_line(args: argparse.Namespace, kind: str) -> None: print( f"{'DRY RUN: ' if args.dry_run else ''}已创建 {line.kind} {line.id},分支 {line.branch},仓库基线:{bases}" ) + if not args.dry_run: + print(f"发布此线(Dyro 不会代你执行):git push -u origin {line.branch}") def cmd_line_create(args: argparse.Namespace) -> None: @@ -3221,20 +3225,39 @@ def cmd_task_create(args: argparse.Namespace) -> None: config = _config(args) validate_id(args.id, "任务 ID") get_line(config, args.line) - if args.repository not in config.repositories: - raise DyroError(f"未配置仓库:{args.repository}") + repository_ids: list[str] = [] + for repo_id in args.repository: + if repo_id not in config.repositories: + raise DyroError(f"未配置仓库:{repo_id}") + if repo_id not in repository_ids: + repository_ids.append(repo_id) path = config.task_specs_dir / args.id + if not config.adapters: + raise DyroError("未配置任何 Agent adapter,无法创建任务") if args.dry_run: print(f"DRY RUN: 将创建 {path}") return + executor = next(iter(config.adapters)) + primary = repository_ids[0] + extra = tuple(repository_ids[1:]) + gates = profile_task_gates(config, repository_ids) with exclusive_lock(config.task_specs_dir / ".tasks.lock"): if path.exists(): raise DyroError(f"任务目录已存在:{path}") path.mkdir(parents=True) - mount = config.repositories[args.repository].mount + mount = config.repositories[primary].mount atomic_write_text( path / "task.toml", - task_template(args.id, args.title, args.line, args.repository, mount), + task_template( + args.id, + args.title, + args.line, + primary, + mount, + executor=executor, + extra_repositories=extra, + gates=gates, + ), ) atomic_write_text( path / "handoff.md", f"# {args.title}\n\n- 目标:\n- 范围:\n- 验收:\n" @@ -3448,7 +3471,8 @@ def cmd_task_gates(args: argparse.Namespace) -> None: config = _config(args) task = load_task(config, args.id) passed = run_gates(config, task, dry_run=args.dry_run) - print("PASS" if passed else "FAIL") + prefix = "DRY RUN: " if args.dry_run else "" + print(f"{prefix}{'PASS' if passed else 'FAIL'}") if not passed: raise DyroError(f"任务 {task.id} 门禁未通过") @@ -3456,7 +3480,10 @@ def cmd_task_gates(args: argparse.Namespace) -> None: def cmd_task_review(args: argparse.Namespace) -> None: config = _config(args) task = load_task(config, args.id) - print(f"{task.id} -> {review_task(config, task, dry_run=args.dry_run)}") + result = review_task(config, task, dry_run=args.dry_run) + print(f"{task.id} -> {result}") + if result == "failed": + raise DyroError(f"任务 {task.id} 复核拒绝") def cmd_task_signoff(args: argparse.Namespace) -> None: @@ -3776,11 +3803,21 @@ def cmd_task_merge(args: argparse.Namespace) -> None: task = load_task(config, args.id) merge_task(config, task, push=args.push, dry_run=args.dry_run) print( - f"{'DRY RUN: ' if args.dry_run else ''}已合并 {task.id}" + f"{'DRY RUN: 可合并' if args.dry_run else '已合并'} {task.id}" + (" 并推送" if args.push else "") ) +def cmd_task_close(args: argparse.Namespace) -> None: + _require_yes(args, "关闭任务") + config = _config(args) + task = load_task(config, args.id) + close_task(config, task, dry_run=args.dry_run) + print( + f"{'DRY RUN: 将关闭' if args.dry_run else '已关闭'} {task.id} 的任务 worktree" + ) + + def _proofs_from_args(args: argparse.Namespace, *, proof_id: str | None = None): from .proof import list_proofs @@ -3838,6 +3875,9 @@ def cmd_proof_export(args: argparse.Namespace) -> None: if bool(args.proof_id) == bool(args.task): raise ValidationError("proof export 的位置参数 proof-id 与 --task 互斥,且必须提供其一") + if args.dry_run: + print(f"DRY RUN: 将导出 Proof 到 {args.bundle}") + return proofs = _proofs_from_args(args, proof_id=args.proof_id) if not proofs: raise DyroError("没有可导出的 Proof") @@ -4449,7 +4489,7 @@ def _add_common(parser: argparse.ArgumentParser) -> None: parser.add_argument( "--dry-run", action="store_true", - help="仅输出计划,不写文件、不调用 Agent 或 Git 写操作", + help="仅输出计划,不写 Dyro overlay;本地门禁会执行 argv;merge 会做可 abort 的冲突探测", ) @@ -4771,7 +4811,13 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", help="写入用户级 host-projections;默认只写当前工作区", ) - host_compile.add_argument("--dry-run", action="store_true") + host_compile.add_argument( + "--dry-run", + dest="dry_run", + action="store_true", + default=argparse.SUPPRESS, + help="只规划宿主投影,不写入 host-projections 或 host.lock(兼容全局 --dry-run)", + ) host_compile.add_argument("--format", choices=("text", "json"), default="text") host_compile.set_defaults(func=cmd_host_compile) host_status = host_sub.add_parser("status", help="查看已编译投影是否仍与当前 Card 一致") @@ -5513,7 +5559,7 @@ def build_parser() -> argparse.ArgumentParser: task_create.add_argument("id") task_create.add_argument("--title", required=True) task_create.add_argument("--line", required=True) - task_create.add_argument("--repository", required=True) + task_create.add_argument("--repository", action="append", required=True) task_create.set_defaults(func=cmd_task_create) task_sub.add_parser("list").set_defaults(func=cmd_task_list) task_sub.add_parser("board").set_defaults(func=cmd_task_board) @@ -5668,6 +5714,10 @@ def build_parser() -> argparse.ArgumentParser: task_merge.add_argument("--yes", action="store_true") task_merge.add_argument("--push", action="store_true") task_merge.set_defaults(func=cmd_task_merge) + task_close = task_sub.add_parser("close", help="移除已完成或失败任务的 worktree 与任务分支") + task_close.add_argument("id") + task_close.add_argument("--yes", action="store_true") + task_close.set_defaults(func=cmd_task_close) task_sub.add_parser("decisions").set_defaults(func=cmd_task_decisions) task_sub.add_parser("stats").set_defaults(func=cmd_task_stats) task_sub.add_parser("loop").set_defaults(func=cmd_task_loop) diff --git a/src/dyro/events.py b/src/dyro/events.py index 00a0896..db6d4f0 100644 --- a/src/dyro/events.py +++ b/src/dyro/events.py @@ -3,7 +3,9 @@ This is not Objective ``events.jsonl`` and not the delivery ledger. Rows are append-only, one ``kind`` each. Truncation, replacement, or a partial last line fail closed: readers refuse the log and writers refuse to invent the -missing rows. +missing rows. A current file that would exceed ``MAX_EVENT_LOG_BYTES`` is +rotated to ``events.jsonl.`` before the next append; readers stitch +archives in sequence order. """ from __future__ import annotations @@ -152,7 +154,28 @@ def _decode_event(raw: str) -> dict[str, object]: } -def _read_locked_records(path: Path) -> list[dict[str, object]]: +def _event_archive_seq(path: Path, item: Path) -> int | None: + prefix = path.name + "." + if not item.name.startswith(prefix): + return None + suffix = item.name[len(prefix) :] + if not suffix.isascii() or not suffix.isdigit(): + return None + seq = int(suffix) + return seq if seq >= 1 else None + + +def _event_archive_files(path: Path) -> list[Path]: + return [ + item + for item in path.parent.glob(path.name + ".*") + if item.is_file() + and not item.is_symlink() + and _event_archive_seq(path, item) is not None + ] + + +def _read_one_event_file(path: Path) -> list[dict[str, object]]: if path.is_symlink() or (path.exists() and not path.is_file()): raise EventLogError("EVENT_LOG_INVALID") if not path.exists(): @@ -171,18 +194,44 @@ def _read_locked_records(path: Path) -> list[dict[str, object]]: if not text.endswith("\n"): raise EventLogError("EVENT_LOG_INVALID") records: list[dict[str, object]] = [] - expected = 1 + expected: int | None = None for line in text.splitlines(): if not line: raise EventLogError("EVENT_LOG_INVALID") record = _decode_event(line) - if record["seq"] != expected: + seq = record["seq"] + if type(seq) is not int: + raise EventLogError("EVENT_LOG_INVALID") + if expected is None: + if seq < 1: + raise EventLogError("EVENT_LOG_INVALID") + expected = seq + elif seq != expected: raise EventLogError("EVENT_LOG_INVALID") records.append(record) expected += 1 return records +def _read_locked_records(path: Path) -> list[dict[str, object]]: + if path.is_symlink() or (path.exists() and not path.is_file()): + raise EventLogError("EVENT_LOG_INVALID") + chunks = [_read_one_event_file(item) for item in _event_archive_files(path)] + if path.exists(): + chunks.append(_read_one_event_file(path)) + chunks = [chunk for chunk in chunks if chunk] + chunks.sort(key=lambda chunk: int(chunk[0]["seq"])) + combined: list[dict[str, object]] = [] + expected = 1 + for chunk in chunks: + for record in chunk: + if record["seq"] != expected: + raise EventLogError("EVENT_LOG_INVALID") + combined.append(record) + expected += 1 + return combined + + def read_overlay_events(config: object) -> tuple[tuple[dict[str, object], ...], bool]: """Read overlay events without creating a lock or inventing rows. @@ -194,7 +243,7 @@ def read_overlay_events(config: object) -> tuple[tuple[dict[str, object], ...], path = events_path(config) # type: ignore[arg-type] if path.is_symlink(): return (), False - if not path.exists(): + if not path.exists() and not _event_archive_files(path): return (), True return tuple(_read_locked_records(path)), True except (EventLogError, OSError, TypeError, AttributeError): @@ -293,10 +342,16 @@ def append_event_locked( "family": family, "facts": cleaned, } - append_text( - path, - json.dumps(record, ensure_ascii=False, sort_keys=True) + "\n", - ) + encoded = json.dumps(record, ensure_ascii=False, sort_keys=True) + "\n" + current_size = path.stat().st_size if path.is_file() else 0 + if current_size and current_size + len(encoded.encode("utf-8")) > MAX_EVENT_LOG_BYTES: + current_records = _read_one_event_file(path) + last_seq = current_records[-1]["seq"] if current_records else seq - 1 + archive = path.with_name(f"{path.name}.{last_seq}") + if archive.exists() or archive.is_symlink(): + raise EventLogError("EVENT_LOG_INVALID") + path.replace(archive) + append_text(path, encoded) except EventLogError: raise except OSError as exc: diff --git a/src/dyro/integrations/assets/dyro-dispatch/SKILL.md b/src/dyro/integrations/assets/dyro-dispatch/SKILL.md index 8ca7785..f6813e6 100644 --- a/src/dyro/integrations/assets/dyro-dispatch/SKILL.md +++ b/src/dyro/integrations/assets/dyro-dispatch/SKILL.md @@ -15,9 +15,10 @@ Treat `dyro dispatch` as an outbound harness, separate from the read-only Provider. Starting one still requires this-turn parallel intent. - 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. 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. +- Use TaskContract `mode=edit` only when the user also authorizes code changes. + There is no `dispatch run --mode` CLI flag; `mode` is a stdin contract field. + Scratch edit runs stay in a detached worktree and return 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 diff --git a/src/dyro/tasks.py b/src/dyro/tasks.py index c23ed6e..26e628a 100644 --- a/src/dyro/tasks.py +++ b/src/dyro/tasks.py @@ -877,16 +877,26 @@ def set_status( ledger( config, task.id, "status", from_status=current, to_status=next_status ) - from .events import append_event + from .events import EventLogError, append_event - append_event( - config, - kind="task_status", - actor=task.line, - subject=task.id, - family=task.line, - facts={"from_status": current, "to_status": next_status}, - ) + try: + append_event( + config, + kind="task_status", + actor=task.line, + subject=task.id, + family=task.line, + facts={"from_status": current, "to_status": next_status}, + ) + except EventLogError as exc: + ledger( + config, + task.id, + "event_append_failed", + from_status=current, + to_status=next_status, + error_code=exc.code, + ) def _set_quality_gate_status( @@ -969,6 +979,7 @@ def check_dispatchable( f"任务 {task.id} 依赖 {dependency},当前状态为 {status(config, dependency_task)}" ) _assert_dependency_integrated(config, dependency_task) + _assert_line_worktree_on_branch(config, dependency_task) if task.conflict_group: active = [ other.id @@ -1273,9 +1284,32 @@ def _resolved_git_common_dir(path: Path) -> Path: ) +def _assert_task_worktree_path(config: Config, task: Task, destination: Path) -> None: + root = worktree_root(config, task) + current = destination + for _ in range(256): + if current.is_symlink(): + raise DyroError(f"任务 worktree 路径不能是符号链接:{current}") + if current == root: + break + parent = current.parent + if parent == current: + raise DyroError(f"任务 worktree 越界:{destination}") + current = parent + else: + raise DyroError(f"任务 worktree 路径过深:{destination}") + if root.is_symlink(): + raise DyroError(f"任务工作区根不能是符号链接:{root}") + resolved = destination.resolve() + root_resolved = root.resolve() + if resolved == root_resolved or not resolved.is_relative_to(root_resolved): + raise DyroError(f"任务 worktree 越界:{destination}") + + def _validate_task_worktree( config: Config, task: Task, repo_id: str, destination: Path, branch: str ) -> None: + _assert_task_worktree_path(config, task, destination) if git(destination, "rev-parse", "--is-inside-work-tree").stdout.strip() != "true": raise DyroError(f"不是有效的任务 Git worktree:{destination}") top_level = require_ok( @@ -1559,14 +1593,42 @@ def dependency_integration_state_bounded( return "integrated" +def _assert_line_worktree_on_branch(config: Config, task: Task) -> None: + line = get_line(config, task.line) + for repository_id in task.repositories: + destination = line_repository_path(config, line, repository_id) + if destination.is_symlink(): + raise DyroError( + f"任务 {task.id} 的开发线 {task.line} 仓库 {repository_id} " + f"路径不能是符号链接:{destination}" + ) + top = git_read(destination, "rev-parse", "--show-toplevel") + if top.code != 0: + raise DyroError( + f"任务 {task.id} 的开发线 {task.line} 仓库 {repository_id} " + f"不是 Git 工作树:{destination}" + ) + if Path(top.stdout.strip()).resolve() != destination.resolve(): + raise DyroError( + f"任务 {task.id} 的开发线 {task.line} 仓库 {repository_id} " + f"根目录错误:{destination} 实际为 {top.stdout.strip()}" + ) + current = git_read(destination, "branch", "--show-current") + branch = current.stdout.strip() if current.code == 0 else "" + if branch != line.branch: + raise DyroError( + f"任务 {task.id} 的开发线 {task.line} 仓库 {repository_id} " + f"当前 {branch or 'DETACHED'},期望 {line.branch}" + ) + + def _assert_dependency_integrated(config: Config, task: Task) -> None: line = get_line(config, task.line) heads = _load_task_heads(config, task) for repository_id, task_head in heads.items(): destination = line_repository_path(config, line, repository_id) - result = run( - ("git", "merge-base", "--is-ancestor", task_head, "HEAD"), - cwd=destination, + result = git_read( + destination, "merge-base", "--is-ancestor", task_head, "HEAD" ) if result.code != 0: raise DyroError( @@ -1592,7 +1654,7 @@ def _assert_task_heads_current(config: Config, task: Task) -> dict[str, str]: def _receipt_result(task: Task) -> str: receipt = resolve_evidence_path(task.directory, "receipt.md") - if not receipt.exists(): + if receipt.is_symlink() or not receipt.is_file(): return "" first = receipt.read_text(encoding="utf-8").splitlines() match = RESULT_RE.match(first[0]) if first else None @@ -1974,6 +2036,10 @@ def _require_external_claim(config: Config, task: Task) -> dict[str, object]: def run_gates(config: Config, task: Task, *, dry_run: bool = False) -> bool: _require_local_execution(config, "门禁", dry_run=dry_run) + if dry_run and config.policy.execution_mode == "external": + raise DyroError( + "当前 Profile 要求外部隔离执行器;--dry-run 不会在本机执行门禁 argv" + ) root = worktree_root(config, task) all_passed = True for index, gate in enumerate(task.gates, start=1): @@ -1981,7 +2047,7 @@ def run_gates(config: Config, task: Task, *, dry_run: bool = False) -> bool: argv = expand_argv( gate.argv, workspace=root, root=config.root, task=task.id, line=task.line ) - result = run(argv, cwd=cwd, timeout=gate.timeout_seconds, dry_run=dry_run) + result = run(argv, cwd=cwd, timeout=gate.timeout_seconds) _capture(task, f"gate-{index}.log", result.stdout, dry_run=dry_run) passed = result.code == 0 all_passed = all_passed and passed @@ -2184,6 +2250,12 @@ def _run_task( if not dry_run: set_status(config, task, "failed") raise + if not dry_run: + leftover = task.directory / "receipt.md" + if leftover.is_symlink() or leftover.is_file(): + leftover.unlink() + elif leftover.exists(): + raise DyroError(f"任务回执路径不是普通文件:{leftover}") result = _execute_task_agent( config, task, @@ -2527,7 +2599,7 @@ def _apply_review_decision(config: Config, task: Task, *, dry_run: bool = False) expected_plan_sha256=expected_binding[1] if expected_binding else "", reviewed_plan_sha256=reviewed_binding[1], ) - return "review" + return "failed" if verdict == "PASS": if ( reviewed_receipt_hash != receipt_hash @@ -2544,7 +2616,7 @@ def _apply_review_decision(config: Config, task: Task, *, dry_run: bool = False) expected_task_heads_sha256=task_heads_hash, reviewed_task_heads_sha256=reviewed_task_heads_hash, ) - return "review" + return "failed" next_status = ( "review_pending_signoff" if config.policy.require_external_signoff @@ -2940,19 +3012,27 @@ def _merge_task_repositories_locked( line, plans = _prepare_merge(config, task, push=push, dry_run=dry_run) message = f"merge(task): {task.id} {task.title}" if dry_run: - for plan in plans: - require_ok( - git( + probed: list[MergePlan] = [] + try: + for plan in plans: + probed.append(plan) + result = git( plan.target, "merge", "--no-ff", "--no-commit", plan.source_head, - dry_run=True, timeout=300, - ), - f"合并 {plan.repository}", - ) + ) + if result.code != 0: + raise DyroError( + f"预检合并 {plan.repository} 存在冲突,拒绝合并" + + (f"\n{result.stdout.strip()}" if result.stdout.strip() else "") + ) + finally: + recovery = _rollback_merges(probed, {}) + if recovery: + raise DyroError("预检合并后清理未完全成功:" + "; ".join(recovery)) return committed_heads: dict[str, str] = {} @@ -3044,6 +3124,56 @@ def _signoff_evidence_present(task: Task) -> bool: return (task.directory / "signoff.json").is_file() +def close_task(config: Config, task: Task, *, dry_run: bool = False) -> None: + current = status(config, task) + if current not in {"done", "failed"}: + raise DyroError(f"只能关闭 done 或 failed 任务,当前 {current}") + line = get_line(config, task.line) + branch = f"{config.policy.task_branch_prefix}{task.id}" + root = worktree_root(config, task) + for repo_id in task.repositories: + destination = root / config.repositories[repo_id].mount + anchor = repository_path(config, repo_id) + if destination.is_symlink() or worktree_root(config, task).is_symlink(): + raise DyroError(f"任务 worktree 路径不能是符号链接:{destination}") + if destination.exists(): + _validate_task_worktree(config, task, repo_id, destination, branch) + dirty = require_ok( + git(destination, "status", "--porcelain=v1", "-uall"), + f"读取 {repo_id} 任务 worktree 状态", + ).stdout.strip() + if dirty and current == "done": + raise DyroError(f"任务 worktree 不干净,拒绝关闭:{destination}") + if current == "done" and git( + anchor, "show-ref", "--verify", "--quiet", f"refs/heads/{branch}" + ).code == 0: + ancestor = git(anchor, "merge-base", "--is-ancestor", branch, line.branch) + if ancestor.code != 0: + raise DyroError( + f"任务分支 {branch} 尚未合入开发线 {line.branch},拒绝删除" + ) + for repo_id in task.repositories: + destination = root / config.repositories[repo_id].mount + anchor = repository_path(config, repo_id) + if destination.is_symlink() or destination.exists(): + _assert_task_worktree_path(config, task, destination) + _validate_task_worktree(config, task, repo_id, destination, branch) + remove_args: tuple[str, ...] = ("worktree", "remove") + if current == "failed": + remove_args += ("--force",) + require_ok( + git(anchor, *remove_args, str(destination), dry_run=dry_run), + f"移除 {repo_id} 任务 worktree", + ) + if git(anchor, "show-ref", "--verify", "--quiet", f"refs/heads/{branch}").code == 0: + require_ok( + git(anchor, "branch", "-D", branch, dry_run=dry_run), + f"删除 {repo_id} 任务分支 {branch}", + ) + if not dry_run: + ledger(config, task.id, "closed", line=line.id, branch=branch) + + def merge_task( config: Config, task: Task, *, push: bool = False, dry_run: bool = False ) -> None: @@ -3172,11 +3302,60 @@ def loop_tasks(config: Config, *, dry_run: bool = False) -> list[tuple[str, str] return outcomes +def profile_task_gates( + config: Config, repository_ids: Iterable[str] +) -> tuple[tuple[str, tuple[str, ...], str], ...]: + gates: list[tuple[str, tuple[str, ...], str]] = [] + for repo_id in repository_ids: + repo = config.repositories[repo_id] + if repo.verify: + for index, argv in enumerate(repo.verify, start=1): + gates.append((f"verify-{repo_id}-{index}", argv, repo.mount)) + else: + gates.append( + (f"diff-check-{repo_id}", ("git", "diff", "--check"), repo.mount) + ) + return tuple(gates) + + def task_template( - task_id: str, title: str, line: str, repository: str, mount: str + task_id: str, + title: str, + line: str, + repository: str, + mount: str, + *, + executor: str = "codex", + reviewer: str | None = None, + extra_repositories: tuple[str, ...] = (), + gates: tuple[tuple[str, tuple[str, ...], str], ...] | None = None, ) -> str: quoted_title = json.dumps(title, ensure_ascii=False) quoted_mount = json.dumps(mount, ensure_ascii=False) + quoted_executor = json.dumps(executor, ensure_ascii=False) + quoted_reviewer = json.dumps(reviewer or executor, ensure_ascii=False) + repo_blocks = [f'[[repositories]]\nid = "{repository}"\n'] + repo_blocks.extend( + f'[[repositories]]\nid = "{repo_id}"\n' for repo_id in extra_repositories + ) + if gates is None: + gate_blocks = [ + "[[gates]]\n" + 'name = "diff-check"\n' + 'argv = ["git", "diff", "--check"]\n' + f"cwd = {quoted_mount}\n" + "timeout_seconds = 120\n" + ] + else: + gate_blocks = [] + for name, argv, cwd in gates: + gate_blocks.append( + "[[gates]]\n" + f"name = {json.dumps(name, ensure_ascii=False)}\n" + f"argv = {json.dumps(list(argv), ensure_ascii=False)}\n" + f"cwd = {json.dumps(cwd, ensure_ascii=False)}\n" + "timeout_seconds = 120\n" + ) return f'''schema_version = 1 id = "{task_id}" title = {quoted_title} @@ -3190,20 +3369,13 @@ def task_template( conflict_group = "" [executor] -agent = "codex" +agent = {quoted_executor} [reviewer] -agent = "codex" - -[[repositories]] -id = "{repository}" - -[[gates]] -name = "diff-check" -argv = ["git", "diff", "--check"] -cwd = {quoted_mount} -timeout_seconds = 120 +agent = {quoted_reviewer} +{"".join(repo_blocks)} +{"".join(gate_blocks)} [merge] auto = false push = false diff --git a/src/dyro/workspace.py b/src/dyro/workspace.py index 015999a..6f8817a 100644 --- a/src/dyro/workspace.py +++ b/src/dyro/workspace.py @@ -665,12 +665,15 @@ def resolve_spawn_child_id(parent_id: str, child: str) -> str: def _spawn_base_for(config: Config, parent: Line, repo_id: str) -> str: + worktree = line_repository_path(config, parent, repo_id) + if _rev_parse(worktree, parent.branch) or _rev_parse(worktree, "HEAD"): + return parent.branch anchor = repository_path(config, repo_id) + if _rev_parse(anchor, parent.branch): + return parent.branch remote = _expected_remote_branch(parent.branch) if _ref_exists(anchor, f"refs/remotes/{remote}"): return remote - if _rev_parse(anchor, parent.branch): - return parent.branch raise DyroError( f"{repo_id} 找不到父线分支 {parent.branch} 或其 origin 跟踪引用" ) @@ -945,6 +948,7 @@ def _merge_line_repositories_locked( probed: list[_LineMergePlan] = [] try: for plan in plans: + probed.append(plan) result = git( plan.target, "merge", @@ -953,7 +957,6 @@ def _merge_line_repositories_locked( plan.source_head, timeout=300, ) - probed.append(plan) if result.code != 0: raise DyroError( f"预检合并 {plan.repository} 存在冲突,拒绝合并" @@ -1418,7 +1421,7 @@ def doctor(config: Config, *, read_budget: ReadBudget | None = None) -> list[str worktree, f"refs/remotes/{expected_remote}", read_budget=read_budget ): findings.append( - f"FAIL {line.kind}:{line.id}/{repo_id}: missing {expected_remote}" + f"WARN {line.kind}:{line.id}/{repo_id}: missing {expected_remote}" ) continue upstream = _branch_upstream(worktree, read_budget=read_budget) @@ -1447,23 +1450,24 @@ def doctor(config: Config, *, read_budget: ReadBudget | None = None) -> list[str _SAFE_FINDING_ID = r"[A-Za-z0-9][A-Za-z0-9._-]{0,79}" _MISSING_ORIGIN_FINDING = re.compile( - rf"^FAIL (?:line|hotfix):{_SAFE_FINDING_ID}/{_SAFE_FINDING_ID}: missing origin/\S+$" + rf"^(?:FAIL|WARN) (?:line|hotfix):{_SAFE_FINDING_ID}/{_SAFE_FINDING_ID}: missing origin/\S+$" ) def is_missing_origin_finding(finding: str) -> bool: - """True only for doctor FAILs that mean origin/ is absent. + """True only for doctor findings that mean origin/ is absent. Matches the constructed shape - ``FAIL :/: missing origin/`` and nothing else. + ``WARN|FAIL :/: missing origin/`` and nothing else. A path or message that merely embeds ``: missing origin/`` does not match. - Join completion, setup post-doctor, home create-and-open, and - ``existing_line_workspace`` / ``dyro open`` skip only this constructed + Dyro does not push, so an unpublished line branch is advisory. Join + completion, setup post-doctor, home create-and-open, and + ``existing_line_workspace`` / ``dyro open`` skip this constructed shape so SHA-pinned / local-only lines can exist (and be opened) before the remote-tracking ref is published. Every other doctor FAIL — including workspace-level ``FAIL external Profile requires …`` — - still blocks open. ``dyro next``, ``dyro start``, and Isolated Console - do not skip: a FAIL is not ready. + still blocks open. ``dyro next`` and ``dyro start`` only stop on FAIL, + so a missing-origin WARN is ready. """ return _MISSING_ORIGIN_FINDING.fullmatch(finding) is not None diff --git a/tests/support.py b/tests/support.py index 07fb46b..d32e7a9 100644 --- a/tests/support.py +++ b/tests/support.py @@ -1,5 +1,6 @@ from __future__ import annotations +from contextlib import contextmanager import os from pathlib import Path import subprocess @@ -41,6 +42,22 @@ def shell(*args: str, cwd: Path) -> None: subprocess.run(args, cwd=cwd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) +@contextmanager +def executor_writes_receipt(task_directory: Path, content: str = "result: DONE\n"): + """Write the task receipt during executor run, not before the attempt starts.""" + from dyro import tasks as tasks_mod + + original = tasks_mod._execute_task_agent + + def wrapped(*args, **kwargs): + result = original(*args, **kwargs) + task_directory.joinpath("receipt.md").write_text(content, encoding="utf-8") + return result + + with patch.object(tasks_mod, "_execute_task_agent", side_effect=wrapped): + yield + + def publish_origin_branch(repo: Path, branch: str) -> Path: """Publish HEAD to origin/ without minting a local branch.""" remote = repo.parent / f"{repo.name}.origin.git" diff --git a/tests/test_blueprint.py b/tests/test_blueprint.py index d5bd4cc..e7d8b4a 100644 --- a/tests/test_blueprint.py +++ b/tests/test_blueprint.py @@ -176,8 +176,10 @@ def test_join_creates_detached_anchors_and_selected_development_line(self) -> No self.assertEqual(line.base_for("web"), self.web_head) findings = doctor(config) failures = [item for item in findings if item.startswith("FAIL")] - self.assertTrue(failures, findings) - self.assertTrue(all(is_missing_origin_finding(item) for item in failures), findings) + self.assertFalse(failures, findings) + origin = [item for item in findings if is_missing_origin_finding(item)] + self.assertTrue(origin, findings) + self.assertTrue(all(item.startswith("WARN ") for item in origin), origin) self.assertEqual( target.joinpath(".dyro/join.json").read_text(encoding="utf-8").count('"status": "complete"'), 1, diff --git a/tests/test_cli.py b/tests/test_cli.py index aeb6734..1ee6d1b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -35,7 +35,7 @@ spawn_line, ) -from .support import WorkspaceCase, publish_origin_branch +from .support import WorkspaceCase, executor_writes_receipt, publish_origin_branch class CliTests(unittest.TestCase): @@ -829,16 +829,12 @@ def test_start_dry_run_uses_selected_line_and_adapter(self) -> None: self.assertIn("座位 控制面 · dyro-control-plane", rendered) self.assertIn("先观察 next / attention", rendered) - def test_start_refuses_when_doctor_has_missing_origin_only(self) -> None: + def test_start_proceeds_when_doctor_has_missing_origin_only(self) -> None: config = load(self.root) create_line(config, line_id="local-only", branch="feat/local-only", base="main") stdout = StringIO() stderr = StringIO() - with ( - redirect_stdout(stdout), - redirect_stderr(stderr), - self.assertRaises(SystemExit) as raised, - ): + with redirect_stdout(stdout), redirect_stderr(stderr): main( [ "--root", @@ -851,11 +847,9 @@ def test_start_refuses_when_doctor_has_missing_origin_only(self) -> None: "noop", ] ) - self.assertEqual(raised.exception.code, 2) combined = stdout.getvalue() + stderr.getvalue() - self.assertIn("missing origin/feat/local-only", combined) - self.assertIn("尚未就绪", combined) - self.assertNotIn("座位", stdout.getvalue()) + self.assertNotIn("尚未就绪", combined) + self.assertNotIn("missing origin/feat/local-only", combined) def test_start_refuses_fail_whose_path_embeds_missing_origin_token(self) -> None: config_path = self.root / "dyro.toml" @@ -1207,20 +1201,23 @@ def test_control_plane_next_preserves_an_explicit_workspace_selector(self) -> No ) payload = json.loads(output.getvalue()) - self.assertEqual(payload["state"], "needs_repair") - self.assertNotEqual(payload["state"], "ready") - self.assertEqual( - payload["commands"], - ["dyro --workspace selected doctor"], - ) - self.assertFalse(payload["mutation_available"]) - self.assertTrue( - any( - "missing origin/" in item.get("message", "") - for item in payload.get("findings", []) - ), - payload, - ) + self.assertEqual(payload["state"], "ready") + self.assertNotEqual(payload["state"], "needs_repair") + advertised = [ + item + for item in ( + *(payload.get("commands") or []), + *(payload.get("diagnostic_commands") or []), + ((payload.get("briefing") or {}) or {}).get("command") + if isinstance(payload.get("briefing"), dict) + else None, + ) + if isinstance(item, str) + ] + for command in advertised: + self.assertNotIn("--workspace test-workspace", command) + if "--workspace" in command: + self.assertIn("--workspace selected", command) def test_control_plane_next_uses_canonical_alias_spelling(self) -> None: with tempfile.TemporaryDirectory(prefix="dyro-registry-") as registry_home: @@ -2107,9 +2104,9 @@ def test_daemon_once_dispatches_backlog_task(self) -> None: encoding="utf-8", ) task_path.joinpath("handoff.md").write_text("# handoff\n", encoding="utf-8") - task_path.joinpath("receipt.md").write_text("result: DONE\n", encoding="utf-8") - main(["--root", str(self.root), "task", "daemon", "--once", "--parallel", "1"]) + with executor_writes_receipt(task_path): + main(["--root", str(self.root), "task", "daemon", "--once", "--parallel", "1"]) self.assertEqual(status(config, load_task(config, "TASK-ONCE")), "review") diff --git a/tests/test_continuation_supervision.py b/tests/test_continuation_supervision.py index 3858075..e2a0b90 100644 --- a/tests/test_continuation_supervision.py +++ b/tests/test_continuation_supervision.py @@ -21,7 +21,7 @@ from dyro.tasks import load_task, set_status, status, task_template from dyro.workspace import create_line -from .support import WorkspaceCase +from .support import WorkspaceCase, executor_writes_receipt def _contract(*, max_actions: int = 20) -> str: @@ -90,10 +90,10 @@ def invoke(config, task, *, expected_contract_sha256, **_unused): self.assertEqual(record.intent.budget_reservation.attempts, 1) def test_supervised_execute_uses_the_real_local_task_path_after_action_start(self) -> None: - self.task_directory.joinpath("receipt.md").write_text("result: DONE\n", encoding="utf-8") wave = self._wave() - outcomes = apply_supervised_wave(self.config, wave, clock=lambda: self.now) + with executor_writes_receipt(self.task_directory): + outcomes = apply_supervised_wave(self.config, wave, clock=lambda: self.now) self.assertEqual([(item.status, item.result) for item in outcomes], [(ActionStatus.SUCCEEDED, "review")]) self.assertEqual(status(self.config, load_task(self.config, "TASK-A")), "review") @@ -201,11 +201,9 @@ def test_supervised_apply_ignores_untrusted_usage_even_with_provider_cap(self) - ) self.config = load(self.root) compile_hosts(self.config) - self.task_directory.joinpath("receipt.md").write_text( - "result: DONE\n", encoding="utf-8" - ) wave = self._wave() - outcomes = apply_supervised_wave(self.config, wave, clock=lambda: self.now) + with executor_writes_receipt(self.task_directory): + outcomes = apply_supervised_wave(self.config, wave, clock=lambda: self.now) self.assertEqual( [(item.status, item.result) for item in outcomes], [(ActionStatus.SUCCEEDED, "review")], diff --git a/tests/test_events.py b/tests/test_events.py index 85bd121..d54cf0a 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -16,7 +16,7 @@ from dyro.tasks import _execute_task_agent, load_task, set_status, task_template from dyro.workspace import create_line, line_repository_path, merge_line, spawn_line, sync_line -from .support import WorkspaceCase, publish_origin_branch, shell +from .support import WorkspaceCase, executor_writes_receipt, publish_origin_branch, shell class WorkspaceEventLogTests(WorkspaceCase): @@ -350,7 +350,6 @@ def test_apply_supervised_wave_writes_an_event(self) -> None: encoding="utf-8", ) task_dir.joinpath("handoff.md").write_text("# handoff\n", encoding="utf-8") - task_dir.joinpath("receipt.md").write_text("result: DONE\n", encoding="utf-8") create_objective( self.config, '''schema_version = 1 @@ -373,7 +372,8 @@ def test_apply_supervised_wave_writes_an_event(self) -> None: ) now = datetime(2026, 8, 20, 12, 0, tzinfo=timezone.utc) wave = build_supervised_wave(self.config, "release", clock=lambda: now) - apply_supervised_wave(self.config, wave, clock=lambda: now) + with executor_writes_receipt(task_dir): + apply_supervised_wave(self.config, wave, clock=lambda: now) kinds = self._event_kinds() self.assertIn("objective_wave", kinds) page, _last = read_events(self.config) diff --git a/tests/test_hub.py b/tests/test_hub.py index 38ce118..4565be2 100644 --- a/tests/test_hub.py +++ b/tests/test_hub.py @@ -1708,7 +1708,7 @@ def test_implicit_json_next_does_not_advertise_colliding_alias(self) -> None: rendered = output.getvalue() payload = json.loads(rendered) self.assertEqual(payload["kind"], "next_step") - self.assertEqual(payload["state"], "needs_repair") + self.assertEqual(payload["state"], "ready") commands = payload.get("commands") or [] diagnostic = payload.get("diagnostic_commands") or [] briefing = payload.get("briefing") or {} @@ -1720,11 +1720,11 @@ def test_implicit_json_next_does_not_advertise_colliding_alias(self) -> None: for item in (*commands, *diagnostic, briefing_command) if isinstance(item, str) ] - self.assertTrue(advertised) for command in advertised: self.assertNotIn("--workspace Demo", command) self.assertNotIn("--workspace demo", command) - self.assertTrue(any("--root" in item for item in advertised)) + if advertised: + self.assertTrue(any("--root" in item for item in advertised)) def _profile_named(self, root: Path, name: str, *, remote: bool = False) -> None: text = root.joinpath("dyro.toml").read_text(encoding="utf-8") diff --git a/tests/test_proof_cli.py b/tests/test_proof_cli.py index f29f1a2..37c6138 100644 --- a/tests/test_proof_cli.py +++ b/tests/test_proof_cli.py @@ -19,7 +19,7 @@ from dyro.tasks import load_task, review_task, run_task, task_template from dyro.workspace import create_line -from .support import WorkspaceCase +from .support import WorkspaceCase, executor_writes_receipt def _write_bound_review(task_path: Path) -> None: @@ -48,9 +48,9 @@ def _reviewed_task(self, task_id: str = "TASK-CLI"): encoding="utf-8", ) task_path.joinpath("handoff.md").write_text("# handoff\n", encoding="utf-8") - task_path.joinpath("receipt.md").write_text("result: DONE\n", encoding="utf-8") task = load_task(config, task_id) - self.assertEqual(run_task(config, task), "review") + with executor_writes_receipt(task_path): + self.assertEqual(run_task(config, task), "review") _write_bound_review(task_path) self.assertEqual(review_task(config, task), "done") return task_id diff --git a/tests/test_proof_decay.py b/tests/test_proof_decay.py index 6886acb..7005027 100644 --- a/tests/test_proof_decay.py +++ b/tests/test_proof_decay.py @@ -49,7 +49,7 @@ ) from dyro.workspace import create_line -from .support import WorkspaceCase, shell +from .support import WorkspaceCase, executor_writes_receipt, shell CLOCK = datetime(2026, 8, 15, 5, 20, tzinfo=timezone.utc) @@ -302,9 +302,9 @@ def _reviewed_task(self, task_id: str): encoding="utf-8", ) task_path.joinpath("handoff.md").write_text("# handoff\n", encoding="utf-8") - task_path.joinpath("receipt.md").write_text("result: DONE\n", encoding="utf-8") task = load_task(config, task_id) - self.assertEqual(run_task(config, task), "review") + with executor_writes_receipt(task_path): + self.assertEqual(run_task(config, task), "review") _write_bound_review(task_path) self.assertEqual(review_task(config, task), "done") return config, load_task(config, task_id) @@ -402,11 +402,11 @@ def test_explain_blocks_on_unintegrated_done_dependency(self) -> None: encoding="utf-8", ) task_path.joinpath("handoff.md").write_text("# handoff\n", encoding="utf-8") - task_path.joinpath("receipt.md").write_text("result: QUESTION\n", encoding="utf-8") task = load_task(config, "TASK-UP") from dyro.tasks import answer_task - self.assertEqual(run_task(config, task), "waiting_answer") + with executor_writes_receipt(task_path, "result: QUESTION\n"): + self.assertEqual(run_task(config, task), "waiting_answer") repository = self.root / "worktrees/alpha/TASK-UP/services/api" repository.joinpath("UNMERGED.md").write_text("pending\n", encoding="utf-8") shell("git", "add", "UNMERGED.md", cwd=repository) diff --git a/tests/test_proof_derive.py b/tests/test_proof_derive.py index d1d63ce..f269554 100644 --- a/tests/test_proof_derive.py +++ b/tests/test_proof_derive.py @@ -34,7 +34,7 @@ from dyro.tasks import answer_task, load_task, review_task, run_task, task_template from dyro.workspace import create_line -from .support import WorkspaceCase, shell +from .support import WorkspaceCase, executor_writes_receipt, shell def _write_bound_review(task_path: Path) -> None: @@ -76,9 +76,9 @@ def _reviewed_task(self, task_id: str = "TASK-PROOF"): encoding="utf-8", ) task_path.joinpath("handoff.md").write_text("# handoff\n", encoding="utf-8") - task_path.joinpath("receipt.md").write_text("result: DONE\n", encoding="utf-8") task = load_task(config, task_id) - self.assertEqual(run_task(config, task), "review") + with executor_writes_receipt(task_path): + self.assertEqual(run_task(config, task), "review") _write_bound_review(task_path) self.assertEqual(review_task(config, task), "done") return config, load_task(config, task_id) @@ -115,9 +115,9 @@ def test_unmerged_task_commit_marks_integration_pending(self) -> None: encoding="utf-8", ) task_path.joinpath("handoff.md").write_text("# handoff\n", encoding="utf-8") - task_path.joinpath("receipt.md").write_text("result: QUESTION\n", encoding="utf-8") task = load_task(config, "TASK-PENDING") - self.assertEqual(run_task(config, task), "waiting_answer") + with executor_writes_receipt(task_path, "result: QUESTION\n"): + self.assertEqual(run_task(config, task), "waiting_answer") repository = self.root / "worktrees/alpha/TASK-PENDING/services/api" repository.joinpath("PROOF.md").write_text("drift\n", encoding="utf-8") shell("git", "add", "PROOF.md", cwd=repository) diff --git a/tests/test_review_remediation.py b/tests/test_review_remediation.py new file mode 100644 index 0000000..7f99ea1 --- /dev/null +++ b/tests/test_review_remediation.py @@ -0,0 +1,673 @@ +from __future__ import annotations + +from contextlib import redirect_stdout +from io import StringIO +import hashlib +import json +import subprocess +from pathlib import Path +from unittest.mock import patch + +from dyro.config import load +from dyro.errors import DyroError +from dyro.workspace import ( + create_line, + doctor, + get_line, + is_missing_origin_finding, + line_repository_path, + merge_line, + repository_path, + spawn_line, +) + +from tests.support import WorkspaceCase, executor_writes_receipt, publish_origin_branch, shell + + +def shell_stdout(*args: str, cwd: Path, check: bool = True) -> str: + return subprocess.run( + args, cwd=cwd, check=check, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True + ).stdout.strip() + + +def commit_file(worktree: Path, name: str, message: str) -> str: + (worktree / name).write_text(f"{message}\n", encoding="utf-8") + shell("git", "add", name, cwd=worktree) + shell("git", "commit", "-m", message, cwd=worktree) + return shell_stdout("git", "rev-parse", "HEAD", cwd=worktree) + + +class MissingOriginIsAdvisoryTests(WorkspaceCase): + """Dyro never pushes, so an unpublished line branch is a WARN, not a FAIL.""" + + def test_doctor_reports_missing_origin_as_warn(self) -> None: + config = load(self.root) + create_line(config, line_id="local-only", branch="feat/local-only", base="main") + findings = doctor(config) + origin = [item for item in findings if "missing origin/feat/local-only" in item] + self.assertEqual(len(origin), 1, findings) + self.assertTrue(origin[0].startswith("WARN "), origin[0]) + self.assertFalse(any(item.startswith("FAIL") for item in findings), findings) + + def test_next_is_ready_when_only_origin_is_missing(self) -> None: + from dyro.cli import main + + config = load(self.root) + create_line(config, line_id="local-only", branch="feat/local-only", base="main") + output = StringIO() + with redirect_stdout(output): + main(["--root", str(self.root), "next"]) + rendered = output.getvalue() + self.assertIn("工作区已就绪", rendered) + self.assertNotIn("还不能开始任务", rendered) + json_out = StringIO() + with redirect_stdout(json_out): + main(["--root", str(self.root), "next", "--format", "json"]) + self.assertEqual(json.loads(json_out.getvalue())["state"], "ready") + + def test_missing_origin_matcher_accepts_warn_shape(self) -> None: + self.assertTrue( + is_missing_origin_finding("WARN line:alpha/api: missing origin/feat/alpha") + ) + self.assertFalse( + is_missing_origin_finding("WARN overlay 缺少 AGENTS.md: missing origin/x") + ) + + def test_line_create_prints_publish_hint(self) -> None: + from dyro.cli import main + + output = StringIO() + with redirect_stdout(output): + main(["--root", str(self.root), "line", "create", "local-only", "--yes"]) + self.assertIn("git push -u origin feat/local-only", output.getvalue()) + + +class SpawnBaseTests(WorkspaceCase): + def test_spawn_starts_from_parent_local_head_not_stale_origin(self) -> None: + publish_origin_branch(self.anchor, "feat/onboard") + config = load(self.root) + parent = create_line(config, line_id="onboard", branch="feat/onboard", base="main") + parent_wt = line_repository_path(config, parent, "api") + local_head = commit_file(parent_wt, "local.txt", "feat: parent local work") + child = spawn_line(config, "onboard", "tryon") + self.assertEqual(child.base, "feat/onboard") + child_wt = line_repository_path(load(self.root), child, "api") + self.assertEqual(shell_stdout("git", "rev-parse", "HEAD", cwd=child_wt), local_head) + + +class TaskCreateTests(WorkspaceCase): + def _create(self, *extra: str) -> Path: + from dyro.cli import main + + config = load(self.root) + create_line(config, line_id="dev", branch="feat/dev", base="main") + with redirect_stdout(StringIO()): + main( + [ + "--root", + str(self.root), + "task", + "create", + "T1", + "--title", + "trial", + "--line", + "dev", + *extra, + ] + ) + return self.root / ".dyro/tasks/T1/task.toml" + + def test_profile_verify_commands_become_task_gates(self) -> None: + manifest = self._create("--repository", "api").read_text(encoding="utf-8") + self.assertIn('name = "verify-api-1"', manifest) + self.assertIn('argv = ["git", "diff", "--check"]', manifest) + self.assertIn('cwd = "services/api"', manifest) + + def test_executor_defaults_to_a_configured_adapter(self) -> None: + manifest = self._create("--repository", "api").read_text(encoding="utf-8") + self.assertIn('agent = "noop"', manifest) + self.assertNotIn('agent = "codex"', manifest) + + def test_task_may_span_multiple_repositories(self) -> None: + (self.root / "dyro.toml").write_text( + (self.root / "dyro.toml").read_text(encoding="utf-8") + + '\n[repositories.web]\npath = "repositories/web"\nmount = "services/web"\nverify = []\n', + encoding="utf-8", + ) + web = self.root / "repositories/web" + web.mkdir(parents=True) + shell("git", "init", "-b", "main", cwd=web) + shell("git", "config", "user.name", "Test User", cwd=web) + shell("git", "config", "user.email", "test@example.com", cwd=web) + shell("git", "config", "commit.gpgsign", "false", cwd=web) + (web / "README.md").write_text("web\n", encoding="utf-8") + shell("git", "add", "README.md", cwd=web) + shell("git", "commit", "-m", "chore: initial", cwd=web) + manifest = self._create("--repository", "api", "--repository", "web").read_text( + encoding="utf-8" + ) + self.assertEqual(manifest.count("[[repositories]]"), 2) + self.assertIn('id = "web"', manifest) + + def _init_second_repo(self, repo_id: str, *, verify: str) -> None: + (self.root / "dyro.toml").write_text( + (self.root / "dyro.toml").read_text(encoding="utf-8") + + f'\n[repositories.{repo_id}]\npath = "repositories/{repo_id}"\n' + f'mount = "services/{repo_id}"\nverify = {verify}\n', + encoding="utf-8", + ) + repo = self.root / f"repositories/{repo_id}" + repo.mkdir(parents=True) + shell("git", "init", "-b", "main", cwd=repo) + shell("git", "config", "user.name", "Test User", cwd=repo) + shell("git", "config", "user.email", "test@example.com", cwd=repo) + shell("git", "config", "commit.gpgsign", "false", cwd=repo) + (repo / "README.md").write_text(f"{repo_id}\n", encoding="utf-8") + shell("git", "add", "README.md", cwd=repo) + shell("git", "commit", "-m", "chore: initial", cwd=repo) + + def test_empty_verify_gates_are_unique_per_repository(self) -> None: + from dyro.tasks import load_task + + (self.root / "dyro.toml").write_text( + (self.root / "dyro.toml").read_text(encoding="utf-8").replace( + 'verify = [["git", "diff", "--check"]]', + "verify = []", + ), + encoding="utf-8", + ) + self._init_second_repo("web", verify="[]") + manifest = self._create("--repository", "api", "--repository", "web").read_text( + encoding="utf-8" + ) + self.assertIn('name = "diff-check-api"', manifest) + self.assertIn('name = "diff-check-web"', manifest) + self.assertNotIn('name = "diff-check"\n', manifest) + load_task(load(self.root), "T1") + + +class DependencyReleaseTests(WorkspaceCase): + def test_dependency_requires_line_worktree_on_line_branch(self) -> None: + from dyro.tasks import _assert_line_worktree_on_branch, load_task + + config = load(self.root) + line = create_line(config, line_id="dev", branch="feat/dev", base="main") + line_wt = line_repository_path(config, line, "api") + head = shell_stdout("git", "rev-parse", "HEAD", cwd=line_wt) + task_dir = self.root / ".dyro/tasks/T1" + task_dir.mkdir(parents=True) + (task_dir / "task.toml").write_text( + 'schema_version = 1\nid = "T1"\ntitle = "t"\nline = "dev"\n' + '[executor]\nagent = "noop"\n[reviewer]\nagent = "noop"\n' + '[[repositories]]\nid = "api"\n', + encoding="utf-8", + ) + (task_dir / "task-heads.json").write_text( + json.dumps( + { + "schema_version": 1, + "task_id": "T1", + "line": "dev", + "branch": "task/T1", + "repositories": {"api": head}, + } + ), + encoding="utf-8", + ) + task = load_task(config, "T1") + _assert_line_worktree_on_branch(config, task) + shell("git", "checkout", "--detach", head, cwd=line_wt) + with self.assertRaisesRegex(DyroError, "feat/dev"): + _assert_line_worktree_on_branch(config, task) + + def test_line_branch_check_does_not_walk_to_overlay_git(self) -> None: + from dyro.tasks import _assert_line_worktree_on_branch, load_task + + config = load(self.root) + line = create_line(config, line_id="dev", branch="feat/dev", base="main") + dest = line_repository_path(config, line, "api") + shell("git", "worktree", "remove", str(dest), cwd=repository_path(config, "api")) + dest.mkdir(parents=True) + shell("git", "init", "-b", "feat/dev", cwd=self.root) + shell("git", "config", "user.name", "Test User", cwd=self.root) + shell("git", "config", "user.email", "test@example.com", cwd=self.root) + shell("git", "config", "commit.gpgsign", "false", cwd=self.root) + (self.root / "overlay.txt").write_text("overlay\n", encoding="utf-8") + shell("git", "add", "overlay.txt", cwd=self.root) + shell("git", "commit", "-m", "overlay", cwd=self.root) + task_dir = self.root / ".dyro/tasks/T1" + task_dir.mkdir(parents=True) + (task_dir / "task.toml").write_text( + 'schema_version = 1\nid = "T1"\ntitle = "t"\nline = "dev"\n' + '[executor]\nagent = "noop"\n[reviewer]\nagent = "noop"\n' + '[[repositories]]\nid = "api"\n', + encoding="utf-8", + ) + task = load_task(config, "T1") + with self.assertRaisesRegex(DyroError, "根目录错误"): + _assert_line_worktree_on_branch(config, task) + + +class EventLogRotationTests(WorkspaceCase): + def test_append_rotates_instead_of_failing_at_size_cap(self) -> None: + from dyro import events + + config = load(self.root) + with patch.object(events, "MAX_EVENT_LOG_BYTES", 600): + for index in range(12): + events.append_event( + config, kind="sync", actor="dev", subject=f"child{index}", family="dev" + ) + path = events.events_path(config) + self.assertLessEqual(path.stat().st_size, 600) + archives = sorted(path.parent.glob("events.jsonl.*")) + self.assertTrue(archives, "expected a rotated archive") + records, last_seq = events.read_events(config, after_seq=0) + self.assertEqual(last_seq, 12) + self.assertEqual(records[-1]["seq"], 12) + self.assertEqual(records[0]["seq"], 1) + newer, _ = events.read_events(config, after_seq=last_seq - 1) + self.assertEqual([item["seq"] for item in newer], [12]) + + def test_status_transition_survives_event_log_failure(self) -> None: + from dyro import tasks + from dyro.events import EventLogError + + config = load(self.root) + create_line(config, line_id="dev", branch="feat/dev", base="main") + task_dir = self.root / ".dyro/tasks/T1" + task_dir.mkdir(parents=True) + (task_dir / "task.toml").write_text( + 'schema_version = 1\nid = "T1"\ntitle = "t"\nline = "dev"\n' + '[executor]\nagent = "noop"\n[reviewer]\nagent = "noop"\n' + '[[repositories]]\nid = "api"\n', + encoding="utf-8", + ) + task = tasks.load_task(config, "T1") + with patch("dyro.events.append_event", side_effect=EventLogError("EVENT_LOG_INVALID")): + tasks.set_status(config, task, "assigned") + self.assertEqual(tasks.status(config, task), "assigned") + ledger = (self.root / ".dyro/ledger.jsonl").read_text(encoding="utf-8") + self.assertIn("event_append_failed", ledger) + self.assertIn('"error_code": "EVENT_LOG_INVALID"', ledger) + + def test_read_overlay_events_stitches_archives_when_current_missing(self) -> None: + from dyro import events + + config = load(self.root) + with patch.object(events, "MAX_EVENT_LOG_BYTES", 600): + for index in range(12): + events.append_event( + config, kind="sync", actor="dev", subject=f"child{index}", family="dev" + ) + path = events.events_path(config) + self.assertTrue(list(path.parent.glob("events.jsonl.*"))) + path.unlink() + records, complete = events.read_overlay_events(config) + self.assertTrue(complete) + self.assertGreaterEqual(len(records), 1) + self.assertEqual(records[0]["seq"], 1) + + def test_non_seq_archive_suffix_does_not_fail_closed(self) -> None: + from dyro import events + + config = load(self.root) + events.append_event( + config, kind="sync", actor="dev", subject="child", family="dev" + ) + path = events.events_path(config) + (path.parent / "events.jsonl.bak").write_text("not-json\n", encoding="utf-8") + records, last_seq = events.read_events(config) + self.assertEqual(last_seq, 1) + overlay, complete = events.read_overlay_events(config) + self.assertTrue(complete) + self.assertEqual(len(overlay), 1) + + +class StaleReceiptTests(WorkspaceCase): + def test_previous_receipt_is_not_reused_by_a_new_attempt(self) -> None: + from dyro.cli import main + from dyro.tasks import load_task, run_task + + config = load(self.root) + create_line(config, line_id="dev", branch="feat/dev", base="main") + with redirect_stdout(StringIO()): + main( + [ + "--root", + str(self.root), + "task", + "create", + "T1", + "--title", + "trial", + "--line", + "dev", + "--repository", + "api", + ] + ) + task_dir = self.root / ".dyro/tasks/T1" + (task_dir / "receipt.md").write_text("result: DONE\n", encoding="utf-8") + task = load_task(config, "T1") + self.assertEqual(run_task(config, task), "failed") + self.assertFalse((task_dir / "receipt.md").exists()) + + +class HonestyRepairTests(WorkspaceCase): + def test_global_dry_run_host_compile_does_not_write(self) -> None: + from dyro.cli import main + from dyro.host import projection_root + + create_line(load(self.root), line_id="dev", branch="feat/dev", base="main") + output = StringIO() + with redirect_stdout(output): + main(["--root", str(self.root), "--dry-run", "host", "compile"]) + self.assertIn("DRY RUN", output.getvalue()) + self.assertFalse( + (projection_root(load(self.root), user=False) / "cli").exists() + ) + self.assertFalse((self.root / ".dyro" / "host.lock").exists()) + + def test_proof_export_dry_run_does_not_write_zip(self) -> None: + from dyro.cli import main + + bundle = self.root / "proofs.zip" + with redirect_stdout(StringIO()): + main( + [ + "--root", + str(self.root), + "--dry-run", + "proof", + "export", + "--task", + "T1", + "--bundle", + str(bundle), + ] + ) + self.assertFalse(bundle.exists()) + + def test_task_close_removes_worktree_after_failure(self) -> None: + from dyro.cli import main + from dyro.tasks import close_task, load_task, run_task, status, worktree_root + + config = load(self.root) + create_line(config, line_id="dev", branch="feat/dev", base="main") + with redirect_stdout(StringIO()): + main( + [ + "--root", + str(self.root), + "task", + "create", + "T1", + "--title", + "trial", + "--line", + "dev", + "--repository", + "api", + ] + ) + task = load_task(config, "T1") + self.assertEqual(run_task(config, task), "failed") + self.assertEqual(status(config, task), "failed") + root = worktree_root(config, task) + self.assertTrue(root.exists()) + close_task(config, task) + self.assertFalse((root / "services/api").exists()) + + def test_review_fail_verdict_exits_nonzero(self) -> None: + from dyro.cli import main + from dyro.tasks import load_task, run_task + + config = load(self.root) + create_line(config, line_id="dev", branch="feat/dev", base="main") + with redirect_stdout(StringIO()): + main( + [ + "--root", + str(self.root), + "task", + "create", + "T1", + "--title", + "trial", + "--line", + "dev", + "--repository", + "api", + ] + ) + task_dir = self.root / ".dyro/tasks/T1" + task = load_task(config, "T1") + with executor_writes_receipt(task_dir): + self.assertEqual(run_task(config, task), "review") + task_dir.joinpath("review.md").write_text("verdict: FAIL\n", encoding="utf-8") + with redirect_stdout(StringIO()), self.assertRaises(SystemExit) as raised: + main(["--root", str(self.root), "task", "review", "T1"]) + self.assertEqual(raised.exception.code, 2) + from dyro.tasks import status + + self.assertEqual(status(config, task), "review") + + def _create_named_task(self, task_id: str = "T1") -> None: + from dyro.cli import main + + create_line(load(self.root), line_id="dev", branch="feat/dev", base="main") + with redirect_stdout(StringIO()): + main( + [ + "--root", + str(self.root), + "task", + "create", + task_id, + "--title", + "trial", + "--line", + "dev", + "--repository", + "api", + ] + ) + + def test_task_close_refuses_symlink_mount_and_keeps_line_worktree(self) -> None: + from dyro.tasks import close_task, load_task, run_task, worktree_root + + self._create_named_task() + config = load(self.root) + task = load_task(config, "T1") + self.assertEqual(run_task(config, task), "failed") + mount = worktree_root(config, task) / "services/api" + line_wt = line_repository_path(config, get_line(config, "dev"), "api") + shell("git", "worktree", "remove", str(mount), cwd=repository_path(config, "api")) + mount.symlink_to(line_wt) + with self.assertRaisesRegex(DyroError, "符号链接"): + close_task(config, task) + self.assertTrue(line_wt.is_dir()) + self.assertEqual( + shell_stdout("git", "rev-parse", "--is-inside-work-tree", cwd=line_wt), + "true", + ) + + def test_task_close_refuses_symlinked_task_root(self) -> None: + import shutil + + from dyro.tasks import close_task, load_task, run_task, worktree_root + from dyro.workspace import line_root + + self._create_named_task() + config = load(self.root) + task = load_task(config, "T1") + self.assertEqual(run_task(config, task), "failed") + root = worktree_root(config, task) + mount = root / "services/api" + line_dir = line_root(config, get_line(config, "dev")) + line_wt = line_repository_path(config, get_line(config, "dev"), "api") + shell("git", "worktree", "remove", str(mount), cwd=repository_path(config, "api")) + shutil.rmtree(root) + root.symlink_to(line_dir) + with self.assertRaisesRegex(DyroError, "符号链接"): + close_task(config, task) + self.assertTrue(line_wt.is_dir()) + self.assertEqual( + shell_stdout("git", "rev-parse", "--is-inside-work-tree", cwd=line_wt), + "true", + ) + + def test_merge_dry_run_timeout_still_aborts_merge_head(self) -> None: + from dyro import tasks as tasks_mod + from dyro.provenance import review_binding + from dyro.tasks import ( + answer_task, + load_task, + merge_task, + review_task, + run_task, + worktree_root, + ) + + self._create_named_task() + config = load(self.root) + task = load_task(config, "T1") + task_dir = task.directory + with executor_writes_receipt(task_dir, "result: QUESTION\n"): + self.assertEqual(run_task(config, task), "waiting_answer") + wt = worktree_root(config, task) / "services/api" + (wt / "change.txt").write_text("change\n", encoding="utf-8") + shell("git", "add", "change.txt", cwd=wt) + shell("git", "commit", "-m", "feat: change", cwd=wt) + task_dir.joinpath("receipt.md").write_text("result: DONE\n", encoding="utf-8") + self.assertEqual(answer_task(config, task, "continue"), "review") + receipt_hash = hashlib.sha256(task_dir.joinpath("receipt.md").read_bytes()).hexdigest() + heads_hash = hashlib.sha256(task_dir.joinpath("task-heads.json").read_bytes()).hexdigest() + binding = review_binding(task_dir) + task_dir.joinpath("review.md").write_text( + "verdict: PASS\n" + f"receipt_sha256: {receipt_hash}\n" + f"task_heads_sha256: {heads_hash}\n" + f"attempt_id: {binding[0]}\n" + f"plan_sha256: {binding[1]}\n", + encoding="utf-8", + ) + self.assertEqual(review_task(config, task), "done") + original = tasks_mod.git + + def flaky_git(repo, *args, dry_run=False, timeout=180): + result = original(repo, *args, dry_run=dry_run, timeout=timeout) + if args[:1] == ("merge",): + raise DyroError("命令超时(300s):git merge") + return result + + line_wt = line_repository_path(config, get_line(config, "dev"), "api") + with patch.object(tasks_mod, "git", side_effect=flaky_git): + with self.assertRaisesRegex(DyroError, "超时"): + merge_task(config, task, dry_run=True) + merge_head = subprocess.run( + ["git", "-C", str(line_wt), "rev-parse", "-q", "--verify", "MERGE_HEAD"], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + self.assertNotEqual(merge_head.returncode, 0) + + def test_external_profile_dry_run_gates_do_not_execute_argv(self) -> None: + from dyro.tasks import load_task, run_gates + + (self.root / "dyro.toml").write_text( + (self.root / "dyro.toml").read_text(encoding="utf-8").replace( + "require_clean_merge = true", + 'require_clean_merge = true\nexecution_mode = "external"\n', + ), + encoding="utf-8", + ) + self._create_named_task() + task = load_task(load(self.root), "T1") + with patch("dyro.tasks.run", side_effect=AssertionError("argv executed")): + with self.assertRaisesRegex(DyroError, "不会在本机执行门禁"): + run_gates(load(self.root), task, dry_run=True) + + def test_task_create_refuses_empty_adapters(self) -> None: + from dyro.cli import main + + (self.root / "dyro.toml").write_text( + (self.root / "dyro.toml").read_text(encoding="utf-8").replace( + '[adapters.noop]\nlaunch = ["/usr/bin/true"]\n' + 'read = ["/usr/bin/true"]\nwrite = ["/usr/bin/true"]\n\n', + "", + ), + encoding="utf-8", + ) + create_line(load(self.root), line_id="dev", branch="feat/dev", base="main") + with redirect_stdout(StringIO()), self.assertRaises(SystemExit) as raised: + main( + [ + "--root", + str(self.root), + "task", + "create", + "T1", + "--title", + "trial", + "--line", + "dev", + "--repository", + "api", + ] + ) + self.assertEqual(raised.exception.code, 2) + with redirect_stdout(StringIO()), self.assertRaises(SystemExit) as dry: + main( + [ + "--root", + str(self.root), + "--dry-run", + "task", + "create", + "T1", + "--title", + "trial", + "--line", + "dev", + "--repository", + "api", + ] + ) + self.assertEqual(dry.exception.code, 2) + + +class LineMergeProbeTests(WorkspaceCase): + def test_line_merge_timeout_still_aborts_merge_head(self) -> None: + from dyro import workspace as workspace_mod + + publish_origin_branch(self.anchor, "feat/onboard") + config = load(self.root) + parent = create_line(config, line_id="onboard", branch="feat/onboard", base="main") + child = spawn_line(config, "onboard", "tryon") + child_wt = line_repository_path(config, child, "api") + commit_file(child_wt, "child.txt", "feat: child work") + parent_wt = line_repository_path(config, parent, "api") + original = workspace_mod.git + + def flaky_git(repo, *args, dry_run=False, timeout=180): + result = original(repo, *args, dry_run=dry_run, timeout=timeout) + if args[:1] == ("merge",): + raise DyroError("命令超时(300s):git merge") + return result + + with patch.object(workspace_mod, "git", side_effect=flaky_git): + with self.assertRaisesRegex(DyroError, "超时"): + merge_line(config, child.id, parent.id, dry_run=True) + merge_head = subprocess.run( + ["git", "-C", str(parent_wt), "rev-parse", "-q", "--verify", "MERGE_HEAD"], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + self.assertNotEqual(merge_head.returncode, 0) diff --git a/tests/test_tasks.py b/tests/test_tasks.py index d2de565..01d83cd 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -26,10 +26,14 @@ ) from dyro.workspace import create_line -from .support import WorkspaceCase, shell +from .support import WorkspaceCase, executor_writes_receipt, shell class TaskTests(WorkspaceCase): + def _run_with_receipt(self, config, task, content: str = "result: DONE\n"): + with executor_writes_receipt(task.directory, content): + return run_task(config, task) + def _external_config(self): config_path = self.root / "dyro.toml" config_path.write_text( @@ -103,9 +107,8 @@ def test_run_review_and_merge_task(self) -> None: encoding="utf-8", ) task_path.joinpath("handoff.md").write_text("# handoff\n", encoding="utf-8") - task_path.joinpath("receipt.md").write_text("result: QUESTION\n", encoding="utf-8") task = load_task(config, "TASK-1") - self.assertEqual(run_task(config, task), "waiting_answer") + self.assertEqual(self._run_with_receipt(config, task, "result: QUESTION\n"), "waiting_answer") task_repository = self.root / "worktrees/alpha/TASK-1/services/api" task_repository.joinpath("README.md").write_text("task change\n", encoding="utf-8") shell("git", "add", "README.md", cwd=task_repository) @@ -138,9 +141,8 @@ def test_public_status_cannot_bypass_review_or_merge(self) -> None: encoding="utf-8", ) task_path.joinpath("handoff.md").write_text("# handoff\n", encoding="utf-8") - task_path.joinpath("receipt.md").write_text("result: DONE\n", encoding="utf-8") task = load_task(config, "TASK-REVIEW-GATE") - self.assertEqual(run_task(config, task), "review") + self.assertEqual(self._run_with_receipt(config, task), "review") with self.assertRaisesRegex(DyroError, "质量门"): set_status(config, task, "done") @@ -158,9 +160,8 @@ def test_merge_revalidates_accepted_review_binding(self) -> None: encoding="utf-8", ) task_path.joinpath("handoff.md").write_text("# handoff\n", encoding="utf-8") - task_path.joinpath("receipt.md").write_text("result: DONE\n", encoding="utf-8") task = load_task(config, "TASK-MERGE-RECHECK") - self.assertEqual(run_task(config, task), "review") + self.assertEqual(self._run_with_receipt(config, task), "review") self._write_bound_review(task_path) self.assertEqual(review_task(config, task), "done") task_path.joinpath("review.md").write_text("verdict: PASS\n", encoding="utf-8") @@ -237,9 +238,8 @@ def test_external_signoff_is_required_after_receipt_bound_review(self) -> None: encoding="utf-8", ) task_path.joinpath("handoff.md").write_text("# handoff\n", encoding="utf-8") - task_path.joinpath("receipt.md").write_text("result: DONE\n", encoding="utf-8") task = load_task(config, "TASK-SIGNOFF") - self.assertEqual(run_task(config, task), "review") + self.assertEqual(self._run_with_receipt(config, task), "review") self._write_bound_review(task_path) self.assertEqual(review_task(config, task), "review_pending_signoff") @@ -614,9 +614,8 @@ def test_allows_a_human_gate_name_without_using_it_as_a_log_path(self) -> None: encoding="utf-8", ) task_path.joinpath("handoff.md").write_text("# handoff\n", encoding="utf-8") - task_path.joinpath("receipt.md").write_text("result: DONE\n", encoding="utf-8") task = load_task(config, "TASK-GATE-NAME") - self.assertEqual(run_task(config, task), "review") + self.assertEqual(self._run_with_receipt(config, task), "review") self.assertTrue((task_path / "logs/gate-1.log").is_file()) self.assertFalse((task_path / "logs/unit tests / edge cases.log").exists()) @@ -647,9 +646,8 @@ def test_review_rejects_task_head_drift(self) -> None: encoding="utf-8", ) task_path.joinpath("handoff.md").write_text("# handoff\n", encoding="utf-8") - task_path.joinpath("receipt.md").write_text("result: DONE\n", encoding="utf-8") task = load_task(config, "TASK-DRIFT") - self.assertEqual(run_task(config, task), "review") + self.assertEqual(self._run_with_receipt(config, task), "review") self._write_bound_review(task_path) task_repository = self.root / "worktrees/alpha/TASK-DRIFT/services/api" @@ -681,9 +679,8 @@ def test_review_detects_reviewer_source_mutation(self) -> None: encoding="utf-8", ) task_path.joinpath("handoff.md").write_text("# handoff\n", encoding="utf-8") - task_path.joinpath("receipt.md").write_text("result: DONE\n", encoding="utf-8") task = load_task(config, "TASK-REVIEW-GUARD") - self.assertEqual(run_task(config, task), "review") + self.assertEqual(self._run_with_receipt(config, task), "review") self._write_bound_review(task_path) with self.assertRaisesRegex(DyroError, "复核期间任务源码发生变化"): @@ -728,11 +725,10 @@ def test_run_task_rejects_uncommitted_source_changes(self) -> None: encoding="utf-8", ) task_path.joinpath("handoff.md").write_text("# handoff\n", encoding="utf-8") - task_path.joinpath("receipt.md").write_text("result: DONE\n", encoding="utf-8") task = load_task(config, "TASK-DIRTY") with self.assertRaisesRegex(DyroError, "必须先提交全部改动"): - run_task(config, task) + self._run_with_receipt(config, task) self.assertEqual(status(config, task), "failed") def test_pass_review_never_runs_implicit_merge_or_push(self) -> None: @@ -748,9 +744,8 @@ def test_pass_review_never_runs_implicit_merge_or_push(self) -> None: encoding="utf-8", ) task_path.joinpath("handoff.md").write_text("# handoff\n", encoding="utf-8") - task_path.joinpath("receipt.md").write_text("result: DONE\n", encoding="utf-8") task = load_task(config, "TASK-AUTO") - self.assertEqual(run_task(config, task), "review") + self.assertEqual(self._run_with_receipt(config, task), "review") self._write_bound_review(task_path) with patch("dyro.tasks._merge_task_repositories") as merge_repositories: @@ -784,9 +779,8 @@ def test_cross_repository_merge_rolls_back_when_later_repository_conflicts(self) manifest = manifest.replace('[[gates]]', '[[repositories]]\nid = "web"\n\n[[gates]]', 1) task_path.joinpath("task.toml").write_text(manifest, encoding="utf-8") task_path.joinpath("handoff.md").write_text("# handoff\n", encoding="utf-8") - task_path.joinpath("receipt.md").write_text("result: QUESTION\n", encoding="utf-8") task = load_task(config, "TASK-TXN") - self.assertEqual(run_task(config, task), "waiting_answer") + self.assertEqual(self._run_with_receipt(config, task, "result: QUESTION\n"), "waiting_answer") for repository, content in (("api", "task api\n"), ("web", "task web\n")): task_repository = self.root / f"worktrees/alpha/TASK-TXN/services/{repository}" @@ -827,9 +821,8 @@ def test_merge_serializes_on_delivery_line_lock(self) -> None: encoding="utf-8", ) task_path.joinpath("handoff.md").write_text("# handoff\n", encoding="utf-8") - task_path.joinpath("receipt.md").write_text("result: DONE\n", encoding="utf-8") task = load_task(config, "TASK-LOCK") - self.assertEqual(run_task(config, task), "review") + self.assertEqual(self._run_with_receipt(config, task), "review") self._write_bound_review(task_path) self.assertEqual(review_task(config, task), "done") diff --git a/tests/test_workspace.py b/tests/test_workspace.py index 2f5222d..7d59e5c 100644 --- a/tests/test_workspace.py +++ b/tests/test_workspace.py @@ -87,7 +87,7 @@ def test_create_line_tracks_origin_feat_when_remote_exists(self) -> None: findings = doctor(config) self.assertFalse(any(item.startswith("FAIL") for item in findings), findings) - def test_local_only_line_creates_but_doctor_and_next_are_not_ready(self) -> None: + def test_local_only_line_creates_and_next_is_ready(self) -> None: from contextlib import redirect_stdout from io import StringIO import json @@ -110,36 +110,24 @@ def test_local_only_line_creates_but_doctor_and_next_are_not_ready(self) -> None ) self.assertIn(upstream, ("", "-")) findings = doctor(config) - self.assertTrue( - any("missing origin/feat/local-only" in item for item in findings), - findings, - ) + origin = [ + item for item in findings if "missing origin/feat/local-only" in item + ] + self.assertEqual(len(origin), 1, findings) + self.assertTrue(origin[0].startswith("WARN "), origin[0]) + self.assertFalse(any(item.startswith("FAIL") for item in findings), findings) output = StringIO() with redirect_stdout(output): main(["--root", str(self.root), "next"]) rendered = output.getvalue() - # doctor FAILs missing origin; next must not sell that as ready. - self.assertIn("missing origin/feat/local-only", rendered) - self.assertIn("还不能开始任务", rendered) - self.assertNotIn("工作区已就绪", rendered) - self.assertIn("dyro --workspace test-workspace doctor", rendered) + self.assertIn("工作区已就绪", rendered) + self.assertNotIn("还不能开始任务", rendered) json_out = StringIO() with redirect_stdout(json_out): main(["--root", str(self.root), "next", "--format", "json"]) payload = json.loads(json_out.getvalue()) - self.assertEqual(payload["state"], "needs_repair") - self.assertNotEqual(payload["state"], "ready") - self.assertIn( - "dyro --workspace test-workspace doctor", payload["commands"] - ) - self.assertFalse(payload["mutation_available"]) - self.assertTrue( - any( - "missing origin/feat/local-only" in item.get("message", "") - for item in payload.get("findings", []) - ), - payload, - ) + self.assertEqual(payload["state"], "ready") + self.assertNotEqual(payload["state"], "needs_repair") # Narrow exception: open / home create-and-open may still enter a # just-created local-only line. start and next refuse. line, workspace = existing_line_workspace(config, "local-only", "line") @@ -265,13 +253,20 @@ def test_doctor_fails_when_one_repo_missing_origin_feat(self) -> None: findings = doctor(config) self.assertTrue( any( - item.startswith("FAIL") + item.startswith("WARN") and "web" in item and "missing origin/feat/partial-remote" in item for item in findings ), findings, ) + self.assertFalse( + any( + item.startswith("FAIL") and "missing origin/feat/partial-remote" in item + for item in findings + ), + findings, + ) def test_doctor_fails_when_named_child_sits_on_parent(self) -> None: publish_origin_branch(self.anchor, "main") @@ -511,7 +506,7 @@ def test_spawn_writes_parent_inherited_repos_and_does_not_track_parent(self) -> self.assertEqual(child.id, "onboard_tryon") self.assertEqual(child.parent, "onboard") self.assertEqual(child.repositories, parent.repositories) - self.assertEqual(child.base, "origin/feat/onboard") + self.assertEqual(child.base, "feat/onboard") manifest = (config.lines_state_dir / "onboard_tryon.toml").read_text( encoding="utf-8" ) @@ -884,6 +879,9 @@ def test_recognizes_only_missing_origin_doctor_fails(self) -> None: self.assertTrue( is_missing_origin_finding("FAIL line:alpha/api: missing origin/feat/alpha") ) + self.assertTrue( + is_missing_origin_finding("WARN line:alpha/api: missing origin/feat/alpha") + ) self.assertTrue( is_missing_origin_finding( "FAIL hotfix:cut/api: missing origin/hotfix/cut" From 05315eccd56054b03c9161c58f310a8cc75c8799 Mon Sep 17 00:00:00 2001 From: DandreYang <13072547+Dandre126@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:53:46 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(=E6=8E=A7=E5=88=B6=E9=9D=A2):=20loop=20?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E9=80=80=E5=87=BA=E7=A0=81=E4=B8=8E=20overla?= =?UTF-8?q?y=20=E4=BA=8B=E4=BB=B6=E7=BC=BA=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit task loop/daemon 在复核失败时非零退出;事件追加失败标记 overlay 不完整;merge --dry-run --push 改为真实预检。 --- CHANGELOG.md | 8 ++- src/dyro/cli.py | 50 +++++++++++++------ src/dyro/events.py | 40 +++++++++++++-- src/dyro/tasks.py | 4 +- src/dyro/workspace.py | 1 - tests/test_review_remediation.py | 83 ++++++++++++++++++++++++++++++++ 6 files changed, 164 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 950741a..73cd2c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,8 +32,12 @@ adapter,并把 Profile `verify` 写成门禁。`task review` 拒绝时非零 a dirty tree. Empty-`verify` multi-repo tasks get unique `diff-check-` gate names. Overlay event readers stitch `events.jsonl.` archives when the current file is missing and - ignore non-seq suffixes such as `.bak`. Dispatch Skill no longer cites - a nonexistent `--mode` flag. + ignore non-seq suffixes such as `.bak`. A failed event append still + writes task status, and marks overlay events incomplete via + `.dyro/events.gap`. `task loop` / `task daemon` exit non-zero when a + review or run returns `failed`. Merge `--dry-run --push` actually runs + `git push --dry-run` and says so. Dispatch Skill no longer cites a + nonexistent `--mode` flag. ## 0.7.12 - 2026-08-23 diff --git a/src/dyro/cli.py b/src/dyro/cli.py index 16efaa7..22c96c6 100644 --- a/src/dyro/cli.py +++ b/src/dyro/cli.py @@ -3064,20 +3064,27 @@ def cmd_line_merge(args: argparse.Namespace) -> None: push=args.push, dry_run=args.dry_run, ) - print( - f"{'DRY RUN: ' if args.dry_run else ''}已将 {args.child} 合并入 {args.parent}" - + (" 并推送" if args.push else "") - ) + if args.dry_run: + extra = "(已预检 push)" if args.push else "" + print(f"DRY RUN: 已将 {args.child} 合并入 {args.parent}{extra}") + else: + print( + f"已将 {args.child} 合并入 {args.parent}" + + (" 并推送" if args.push else "") + ) def cmd_line_sync(args: argparse.Namespace) -> None: _require_yes(args, "同步父开发线") config = _config(args) sync_line(config, args.child, push=args.push, dry_run=args.dry_run) - print( - f"{'DRY RUN: ' if args.dry_run else ''}已将父线同步到 {args.child}" - + (" 并推送" if args.push else "") - ) + if args.dry_run: + extra = "(已预检 push)" if args.push else "" + print(f"DRY RUN: 已将父线同步到 {args.child}{extra}") + else: + print( + f"已将父线同步到 {args.child}" + (" 并推送" if args.push else "") + ) def cmd_line_post(args: argparse.Namespace) -> None: @@ -3802,10 +3809,11 @@ def cmd_task_merge(args: argparse.Namespace) -> None: config = _config(args) task = load_task(config, args.id) merge_task(config, task, push=args.push, dry_run=args.dry_run) - print( - f"{'DRY RUN: 可合并' if args.dry_run else '已合并'} {task.id}" - + (" 并推送" if args.push else "") - ) + if args.dry_run: + extra = "(已预检 push)" if args.push else "" + print(f"DRY RUN: 可合并 {task.id}{extra}") + else: + print(f"已合并 {task.id}" + (" 并推送" if args.push else "")) def cmd_task_close(args: argparse.Namespace) -> None: @@ -3924,8 +3932,13 @@ def cmd_task_stats(args: argparse.Namespace) -> None: def cmd_task_loop(args: argparse.Namespace) -> None: + failed: list[str] = [] for task_id, result in loop_tasks(_config(args), dry_run=args.dry_run): print(f"{task_id} -> {result}") + if result == "failed": + failed.append(task_id) + if failed: + raise DyroError("任务失败:" + ", ".join(failed)) def cmd_objective_start(args: argparse.Namespace) -> None: @@ -4434,6 +4447,7 @@ def cmd_task_daemon(args: argparse.Namespace) -> None: print(f"warning: {note}") for item in decision.deferred: print(f"defer {item.task.id}: {item.reason}") + failed: list[str] = [] if bound: overrides = {item.task_id: item.executor for item in decision.bindings} with ThreadPoolExecutor( @@ -4453,7 +4467,10 @@ def cmd_task_daemon(args: argparse.Namespace) -> None: for future in as_completed(futures): task = futures[future] try: - print(f"dispatch {task.id} -> {future.result()}") + result = future.result() + print(f"dispatch {task.id} -> {result}") + if result == "failed": + failed.append(task.id) except DyroError as exc: print(f"skip {task.id}: {exc}") review_queue = list(plan_tasks(config).review) @@ -4468,9 +4485,14 @@ def cmd_task_daemon(args: argparse.Namespace) -> None: for future in as_completed(futures): task = futures[future] try: - print(f"review {task.id} -> {future.result()}") + result = future.result() + print(f"review {task.id} -> {result}") + if result == "failed": + failed.append(task.id) except DyroError as exc: print(f"keep review {task.id}: {exc}") + if failed: + raise DyroError("任务失败:" + ", ".join(sorted(set(failed)))) if args.once or args.dry_run: return time.sleep(max(10, args.interval)) diff --git a/src/dyro/events.py b/src/dyro/events.py index db6d4f0..3fbc3ca 100644 --- a/src/dyro/events.py +++ b/src/dyro/events.py @@ -18,10 +18,11 @@ from .config import Config from .errors import DyroError, ValidationError -from .state import append_text, exclusive_lock +from .state import append_text, atomic_write_text, exclusive_lock EVENTS_FILE = ".dyro/events.jsonl" +EVENTS_GAP = ".dyro/events.gap" EVENTS_LOCK = ".dyro/events.lock" MAX_EVENT_LOG_BYTES = 2 * 1024 * 1024 EVENT_KINDS = frozenset( @@ -58,6 +59,34 @@ def events_path(config: Config) -> Path: return config.root / EVENTS_FILE +def events_gap_path(config: Config) -> Path: + return config.root / EVENTS_GAP + + +def record_event_gap(config: Config, *, code: str) -> None: + """Mark overlay events incomplete after a failed append. + + Status files remain the source of truth. Readers must not treat a + contiguous log as proof that every status write produced a row. + """ + token = ( + code + if isinstance(code, str) and code.isascii() and code.replace("_", "").isalnum() + else "EVENT_WRITE_INVALID" + ) + path = events_gap_path(config) + try: + with overlay_lock(config): + if path.exists() or path.is_symlink(): + return + atomic_write_text( + path, + json.dumps({"code": token}, ensure_ascii=False, sort_keys=True) + "\n", + ) + except OSError: + return + + def _utc(clock: Callable[[], datetime] | None) -> datetime: value = clock() if clock is not None else datetime.now(timezone.utc) if not isinstance(value, datetime) or value.tzinfo is None: @@ -244,8 +273,13 @@ def read_overlay_events(config: object) -> tuple[tuple[dict[str, object], ...], if path.is_symlink(): return (), False if not path.exists() and not _event_archive_files(path): - return (), True - return tuple(_read_locked_records(path)), True + records: tuple[dict[str, object], ...] = () + else: + records = tuple(_read_locked_records(path)) + gap = events_gap_path(config) # type: ignore[arg-type] + if gap.is_symlink() or gap.exists(): + return records, False + return records, True except (EventLogError, OSError, TypeError, AttributeError): return (), False diff --git a/src/dyro/tasks.py b/src/dyro/tasks.py index 26e628a..e814237 100644 --- a/src/dyro/tasks.py +++ b/src/dyro/tasks.py @@ -877,7 +877,7 @@ def set_status( ledger( config, task.id, "status", from_status=current, to_status=next_status ) - from .events import EventLogError, append_event + from .events import EventLogError, append_event, record_event_gap try: append_event( @@ -897,6 +897,7 @@ def set_status( to_status=next_status, error_code=exc.code, ) + record_event_gap(config, code=exc.code) def _set_quality_gate_status( @@ -2960,7 +2961,6 @@ def _prepare_merge( "--dry-run", "origin", line.branch, - dry_run=dry_run, ), f"预检推送 {plan.repository}", ) diff --git a/src/dyro/workspace.py b/src/dyro/workspace.py index 6f8817a..7428f88 100644 --- a/src/dyro/workspace.py +++ b/src/dyro/workspace.py @@ -831,7 +831,6 @@ def _prepare_line_merge_plans( "--dry-run", "origin", target.branch, - dry_run=dry_run, ), f"预检推送 {plan.repository}", ) diff --git a/tests/test_review_remediation.py b/tests/test_review_remediation.py index 7f99ea1..20c3d68 100644 --- a/tests/test_review_remediation.py +++ b/tests/test_review_remediation.py @@ -291,6 +291,11 @@ def test_status_transition_survives_event_log_failure(self) -> None: ledger = (self.root / ".dyro/ledger.jsonl").read_text(encoding="utf-8") self.assertIn("event_append_failed", ledger) self.assertIn('"error_code": "EVENT_LOG_INVALID"', ledger) + from dyro.events import read_overlay_events + + _records, complete = read_overlay_events(config) + self.assertFalse(complete) + self.assertTrue((self.root / ".dyro" / "events.gap").is_file()) def test_read_overlay_events_stitches_archives_when_current_missing(self) -> None: from dyro import events @@ -640,6 +645,84 @@ def test_task_create_refuses_empty_adapters(self) -> None: ) self.assertEqual(dry.exception.code, 2) + def _reach_done_with_commit(self, config, task): + from dyro.provenance import review_binding + from dyro.tasks import answer_task, review_task, run_task, worktree_root + + task_dir = task.directory + with executor_writes_receipt(task_dir, "result: QUESTION\n"): + self.assertEqual(run_task(config, task), "waiting_answer") + wt = worktree_root(config, task) / "services/api" + (wt / "change.txt").write_text("change\n", encoding="utf-8") + shell("git", "add", "change.txt", cwd=wt) + shell("git", "commit", "-m", "feat: change", cwd=wt) + task_dir.joinpath("receipt.md").write_text("result: DONE\n", encoding="utf-8") + self.assertEqual(answer_task(config, task, "continue"), "review") + receipt_hash = hashlib.sha256(task_dir.joinpath("receipt.md").read_bytes()).hexdigest() + heads_hash = hashlib.sha256( + task_dir.joinpath("task-heads.json").read_bytes() + ).hexdigest() + binding = review_binding(task_dir) + task_dir.joinpath("review.md").write_text( + "verdict: PASS\n" + f"receipt_sha256: {receipt_hash}\n" + f"task_heads_sha256: {heads_hash}\n" + f"attempt_id: {binding[0]}\n" + f"plan_sha256: {binding[1]}\n", + encoding="utf-8", + ) + self.assertEqual(review_task(config, task), "done") + return wt + + def test_task_close_refuses_dirty_done_worktree(self) -> None: + from dyro.tasks import close_task, load_task + + self._create_named_task() + config = load(self.root) + task = load_task(config, "T1") + wt = self._reach_done_with_commit(config, task) + (wt / "dirty.txt").write_text("dirty\n", encoding="utf-8") + with self.assertRaisesRegex(DyroError, "不干净"): + close_task(config, task) + + def test_task_close_refuses_unmerged_done_branch(self) -> None: + from dyro.tasks import close_task, load_task + + self._create_named_task() + config = load(self.root) + task = load_task(config, "T1") + self._reach_done_with_commit(config, task) + with self.assertRaisesRegex(DyroError, "尚未合入"): + close_task(config, task) + + def test_task_loop_exits_nonzero_on_review_failed(self) -> None: + from dyro.cli import main + from dyro.tasks import load_task, run_task + + self._create_named_task() + config = load(self.root) + task = load_task(config, "T1") + with executor_writes_receipt(task.directory): + self.assertEqual(run_task(config, task), "review") + task.directory.joinpath("review.md").write_text("verdict: FAIL\n", encoding="utf-8") + with redirect_stdout(StringIO()), self.assertRaises(SystemExit) as raised: + main(["--root", str(self.root), "task", "loop"]) + self.assertEqual(raised.exception.code, 2) + + def test_task_daemon_once_exits_nonzero_on_review_failed(self) -> None: + from dyro.cli import main + from dyro.tasks import load_task, run_task + + self._create_named_task() + config = load(self.root) + task = load_task(config, "T1") + with executor_writes_receipt(task.directory): + self.assertEqual(run_task(config, task), "review") + task.directory.joinpath("review.md").write_text("verdict: FAIL\n", encoding="utf-8") + with redirect_stdout(StringIO()), self.assertRaises(SystemExit) as raised: + main(["--root", str(self.root), "task", "daemon", "--once"]) + self.assertEqual(raised.exception.code, 2) + class LineMergeProbeTests(WorkspaceCase): def test_line_merge_timeout_still_aborts_merge_head(self) -> None: From 0cde94471e33fd62887def2026228aa3b20bd9d1 Mon Sep 17 00:00:00 2001 From: DandreYang <13072547+Dandre126@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:56:01 +0800 Subject: [PATCH 3/4] =?UTF-8?q?chore(=E5=8F=91=E5=B8=83):=20=E5=87=86?= =?UTF-8?q?=E5=A4=87=200.7.13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把 feat/dev_0814 上的控制面诚实性与任务关闭收口收进 0.7.13。 版本停在 0.7.x,不改 0.7.12 历史,不发明新功能。 --- CHANGELOG.md | 2 ++ pyproject.toml | 2 +- src/dyro/bridge/skill/SKILL.md | 2 +- tests/test_cli.py | 4 ++-- tests/test_console_artifacts.py | 4 ++-- tests/test_release_gates.py | 2 +- uv.lock | 2 +- 7 files changed, 10 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73cd2c5..47db5e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.7.13 - 2026-09-01 + 对使用者的影响:未 push 的开发线不再把 `dyro next` / `dyro start` 卡成 needs_repair;doctor 只 WARN 缺 `origin/`。`line spawn` 从父线本地 HEAD 起,不再跟过期远端。`task create` 可多仓、默认本机 diff --git a/pyproject.toml b/pyproject.toml index e86e58d..6168251 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "dyro" -version = "0.7.12" +version = "0.7.13" description = "DyroEngineeringFlow: local-first automation and delivery control for multi-repository teams" readme = "README.md" requires-python = ">=3.11" diff --git a/src/dyro/bridge/skill/SKILL.md b/src/dyro/bridge/skill/SKILL.md index 0f0ce45..775447d 100644 --- a/src/dyro/bridge/skill/SKILL.md +++ b/src/dyro/bridge/skill/SKILL.md @@ -50,7 +50,7 @@ release, publish, console, install, or any confirmation/approval field. ```json { "protocol": {"major": 1, "minor": 0}, - "client": {"name": "dyro-agent-bridge-skill", "version": "0.7.12"}, + "client": {"name": "dyro-agent-bridge-skill", "version": "0.7.13"}, "operation": "bridge.capabilities.compact", "input": {} } diff --git a/tests/test_cli.py b/tests/test_cli.py index 1ee6d1b..e546eff 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2253,6 +2253,6 @@ def test_package_version_matches_pyproject(self) -> None: from dyro import __version__ metadata = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8")) - self.assertEqual(metadata["project"]["version"], "0.7.12") - self.assertEqual(__version__, "0.7.12") + self.assertEqual(metadata["project"]["version"], "0.7.13") + self.assertEqual(__version__, "0.7.13") self.assertEqual(__version__, metadata["project"]["version"]) diff --git a/tests/test_console_artifacts.py b/tests/test_console_artifacts.py index c345860..32518cf 100644 --- a/tests/test_console_artifacts.py +++ b/tests/test_console_artifacts.py @@ -225,12 +225,12 @@ def test_page_and_manifest_keep_p3_fail_closed_pins(self) -> None: self.assertNotEqual(refresh_at, -1) self.assertNotIn(b"/artifacts", script.body[refresh_at:next_fn]) - def test_package_version_is_0_7_12(self) -> None: + def test_package_version_is_0_7_13(self) -> None: import tomllib from pathlib import Path metadata = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8")) - self.assertEqual(metadata["project"]["version"], "0.7.12") + self.assertEqual(metadata["project"]["version"], "0.7.13") class ConsoleArtifactServiceTests(WorkspaceCase): diff --git a/tests/test_release_gates.py b/tests/test_release_gates.py index 1b35fd3..eb4ac57 100644 --- a/tests/test_release_gates.py +++ b/tests/test_release_gates.py @@ -32,7 +32,7 @@ def test_physics_train_refuses_published_0_6_9_tag(self) -> None: def test_0_7_release_runs_gates_without_claiming_1_0(self) -> None: stdout = StringIO() with redirect_stdout(stdout): - code = main(["--root", str(ROOT), "--release-tag", "v0.7.12"]) + code = main(["--root", str(ROOT), "--release-tag", "v0.7.13"]) self.assertEqual(code, 0) self.assertIn("0.7 gates present", stdout.getvalue()) self.assertNotIn("1.0 gates present", stdout.getvalue()) diff --git a/uv.lock b/uv.lock index bfd3e6e..c1d737f 100644 --- a/uv.lock +++ b/uv.lock @@ -286,7 +286,7 @@ wheels = [ [[package]] name = "dyro" -version = "0.7.12" +version = "0.7.13" source = { editable = "." } dependencies = [ { name = "cryptography" }, From a417f22364c20bd5cf7d8bb1eb84c69be9255766 Mon Sep 17 00:00:00 2001 From: DandreYang <13072547+Dandre126@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:06:39 +0800 Subject: [PATCH 4/4] =?UTF-8?q?test(=E6=8E=A7=E5=88=B6=E9=9D=A2):=20?= =?UTF-8?q?=E7=BC=BA=E8=BF=9C=E7=AB=AF=E5=91=8A=E8=AD=A6=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E5=BD=93=20FAIL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit next / Console 在仅缺 origin/ 时保持 ready;测试改为断言 WARN 契约。 --- tests/test_cli.py | 19 ++++++++++++++++++- tests/test_console_inspection.py | 25 +++++++++++++------------ 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index e546eff..baf0b7f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1246,7 +1246,24 @@ def test_control_plane_next_uses_canonical_alias_spelling(self) -> None: self.assertEqual(load_registry().workspaces[0].name, "Acme") payload = json.loads(output.getvalue()) - self.assertEqual(payload["commands"], ["dyro --workspace Acme doctor"]) + self.assertEqual(payload["state"], "ready") + self.assertNotEqual(payload["state"], "needs_repair") + self.assertEqual(payload["commands"], []) + advertised = [ + item + for item in ( + *(payload.get("commands") or []), + *(payload.get("diagnostic_commands") or []), + ((payload.get("briefing") or {}) or {}).get("command") + if isinstance(payload.get("briefing"), dict) + else None, + ) + if isinstance(item, str) + ] + for command in advertised: + if "--workspace" in command: + self.assertIn("--workspace Acme", command) + self.assertNotIn("--workspace acme", command) def test_control_plane_json_runtime_errors_use_one_stable_envelope(self) -> None: with tempfile.TemporaryDirectory(prefix="dyro-registry-") as registry_home: diff --git a/tests/test_console_inspection.py b/tests/test_console_inspection.py index 2379b85..cc36077 100644 --- a/tests/test_console_inspection.py +++ b/tests/test_console_inspection.py @@ -763,7 +763,7 @@ def test_isolated_command_allowlist_rejects_task_next(self) -> None: ) self.assertTrue(IsolatedOverviewService._safe_command("", "demo")) - def test_missing_origin_fail_is_not_ready_or_a_bare_workspace_command(self) -> None: + def test_missing_origin_warn_is_not_fail_and_not_a_bare_workspace_command(self) -> None: from dyro.config import load from dyro.workspace import create_line, spawn_line @@ -787,23 +787,23 @@ def test_missing_origin_fail_is_not_ready_or_a_bare_workspace_command(self) -> N card = overview["data"]["workspaces"][0] reasons = {(item["reason"], item["line"]) for item in card["findings"]} - self.assertIn(("MISSING_ORIGIN", "core"), reasons) - self.assertIn(("MISSING_ORIGIN", "core_pay"), reasons) - self.assertIn(("MISSING_ORIGIN", "release_a"), reasons) - self.assertEqual(card["recommendation"]["command"], "dyro --workspace demo doctor") + self.assertNotIn(("MISSING_ORIGIN", "core"), reasons) + self.assertNotIn(("MISSING_ORIGIN", "core_pay"), reasons) + self.assertNotIn(("MISSING_ORIGIN", "release_a"), reasons) + self.assertEqual(card["health"], "healthy") self.assertNotEqual(card["recommendation"]["command"], "dyro --workspace demo") - self.assertEqual(card["health"], "degraded") - self.assertNotEqual(card["recommendation"]["reason"], "HOME_GUIDANCE") + self.assertNotEqual(card["recommendation"]["reason"], "MISSING_ORIGIN") + self.assertEqual(card["recommendation"]["reason"], "HOME_GUIDANCE") self.assertNotIn(str(self.root), repr(overview)) workspace = service.workspace("demo") - self.assertEqual( - workspace["data"]["workspace"]["recommendation"]["command"], - "dyro --workspace demo doctor", - ) self.assertNotEqual( workspace["data"]["workspace"]["recommendation"]["command"], "dyro --workspace demo", ) + self.assertNotEqual( + workspace["data"]["workspace"]["recommendation"]["reason"], + "MISSING_ORIGIN", + ) def test_fold_twin_cards_do_not_advertise_fail_closed_workspace_selector(self) -> None: from dyro.config import load @@ -912,7 +912,8 @@ def put(self, value: object) -> None: self.assertIs(seen.get("commands_loader"), next_commands) commands = next_commands(load(self.root), alias="demo") - self.assertIn("dyro --workspace demo doctor", commands) + self.assertEqual(commands, []) + self.assertNotIn("dyro --workspace demo doctor", commands) self.assertNotIn("dyro --workspace demo", commands) def test_worker_cannot_serve_or_write_artifacts_via_a_mutation_op(self) -> None: