From 0d160d28df23f8fef3bd23843323180d4686980f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Aug 2026 19:12:00 +0000 Subject: [PATCH 1/6] =?UTF-8?q?fix(=E9=9B=86=E6=88=90):=20never=20create?= =?UTF-8?q?=20missing=20host=20homes=20under=20overrides?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Env and host_homes paths must already exist as real directories. Absent overrides stay silent; symlink components stay fail-closed. Co-authored-by: Dandre Yang --- src/dyro/integrations/manager.py | 30 ++++++++++++++++++++++------- tests/test_integrations.py | 33 ++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/src/dyro/integrations/manager.py b/src/dyro/integrations/manager.py index d30c6d7..2839d2e 100644 --- a/src/dyro/integrations/manager.py +++ b/src/dyro/integrations/manager.py @@ -250,21 +250,33 @@ def _state_paths( ) +def _usable_existing_host_home(candidate: Path) -> Path | None: + """Accept only a real directory that already exists. Never create one.""" + if candidate.exists() and candidate.is_dir() and not candidate.is_symlink(): + if _symlink_component(candidate) is None: + return candidate + return None + + def _host_home( spec: HostSpec, overrides: Mapping[str, Path] | None ) -> Path | None: if overrides is not None and spec.host_id in overrides: - return _absolute_path(overrides[spec.host_id], f"{spec.host_id} home") + candidate = _absolute_path(overrides[spec.host_id], f"{spec.host_id} home") + # Fail-closed: surface symlink / non-dir path components so install + # refuses instead of mkdir-through or silent-skip. + if _symlink_component(candidate) is not None: + return candidate + return _usable_existing_host_home(candidate) if spec.env_var: raw = os.environ.get(spec.env_var, "").strip() if raw: - return _absolute_path(Path(raw), spec.env_var) + candidate = _absolute_path(Path(raw), spec.env_var) + if _symlink_component(candidate) is not None: + return candidate + return _usable_existing_host_home(candidate) candidate = _user_home() / spec.default_dirname - if candidate.exists() and candidate.is_dir() and not candidate.is_symlink(): - unsafe = _symlink_component(candidate) - if unsafe is None: - return candidate - return None + return _usable_existing_host_home(candidate) def _avatar_path(host_home: Path, spec: SkillIntegrationSpec) -> Path: @@ -1235,6 +1247,10 @@ def _install_avatars( f"{host_spec.host_id} skills 目录不安全:{unsafe}" ) continue + if not home.exists() or not home.is_dir() or home.is_symlink(): + # Never mkdir host homes. Missing env / host_homes overrides + # stay silent, matching absent default homes. + continue if _is_link(avatar) and _resolves_to(avatar, mirror): avatars[host_spec.host_id] = { "path": str(avatar), diff --git a/tests/test_integrations.py b/tests/test_integrations.py index 718f17c..7b41e3e 100644 --- a/tests/test_integrations.py +++ b/tests/test_integrations.py @@ -65,6 +65,7 @@ def setUp(self) -> None: self.dyro_home = self.root / "dyro" self.fake_home = self.root / "home" self.fake_home.mkdir() + self.codex_home.mkdir() isolated_hosts = { spec.env_var: "" for spec in manager.HOSTS if spec.env_var } @@ -1255,6 +1256,7 @@ def test_pi_skill_host_uses_coding_agent_dir(self) -> None: self.assertEqual(spec.default_dirname, ".pi/agent") pi_home = self.root / "pi-agent" + pi_home.mkdir() install_integration("skill", yes=True, host_homes={"pi": pi_home}) avatar = pi_home / "skills" / "dyro-control-plane" self.assertTrue(avatar.is_symlink() or avatar.is_dir()) @@ -1268,6 +1270,7 @@ def test_dsh_skill_host_uses_dsh_home(self) -> None: self.assertEqual(spec.default_dirname, ".dsh") dsh_home = self.root / "dsh-home" + dsh_home.mkdir() install_integration("skill", yes=True, host_homes={"dsh": dsh_home}) avatar = dsh_home / "skills" / "dyro-control-plane" self.assertTrue(avatar.is_symlink() or avatar.is_dir()) @@ -1283,6 +1286,8 @@ def test_opencode_and_hermes_host_specs_use_existing_homes(self) -> None: opencode_home = self.root / "opencode-home" hermes_home = self.root / "hermes-home" + opencode_home.mkdir() + hermes_home.mkdir() homes = {"opencode": opencode_home, "hermes": hermes_home} preview = integration_status("skill", host_homes=homes) self.assertEqual( @@ -1325,6 +1330,34 @@ def test_opencode_and_hermes_host_specs_use_existing_homes(self) -> None: self.assertFalse(opencode_avatar.exists() or opencode_avatar.is_symlink()) self.assertFalse(hermes_avatar.exists() or hermes_avatar.is_symlink()) + def test_explicit_missing_host_homes_are_not_created(self) -> None: + missing_env = self.root / "missing-opencode-env" + missing_override = self.root / "missing-hermes-override" + opencode = next(host for host in manager.HOSTS if host.host_id == "opencode") + hermes = next(host for host in manager.HOSTS if host.host_id == "hermes") + + with patch.dict(os.environ, {"OPENCODE_CONFIG_DIR": str(missing_env)}): + self.assertIsNone(manager._host_home(opencode, None)) + status = integration_status("skill") + self.assertNotIn("opencode", {row.host for row in status.avatars}) + install_integration("skill", yes=True) + self.assertFalse(missing_env.exists()) + + self.assertIsNone(manager._host_home(hermes, {"hermes": missing_override})) + status = integration_status("skill", host_homes={"hermes": missing_override}) + self.assertNotIn("hermes", {row.host for row in status.avatars}) + install_integration("skill", yes=True, host_homes={"hermes": missing_override}) + self.assertFalse(missing_override.exists()) + + def test_missing_override_only_refuses_isolated_mirror(self) -> None: + missing_only = self.root / "missing-only-host" + isolated = {spec.env_var: "" for spec in manager.HOSTS if spec.env_var} + with patch.dict(os.environ, isolated, clear=False): + with self.assertRaisesRegex(DyroError, "孤立镜像|未检测到宿主|没有可挂接"): + install_integration("skill", yes=True, host_homes={"pi": missing_only}) + self.assertFalse(missing_only.exists()) + self.assertEqual(integration_status("skill").state, IntegrationState.ABSENT) + def test_absent_opencode_and_hermes_homes_stay_silent(self) -> None: config_root = self.fake_home / ".config" default_opencode = config_root / "opencode" From 29ca41d3faa93b8f678b50b75ca601a64c936331 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Aug 2026 19:12:14 +0000 Subject: [PATCH 2/6] =?UTF-8?q?fix(=E5=B7=A5=E4=BD=9C=E5=8C=BA):=20parse?= =?UTF-8?q?=20missing-origin=20doctor=20FAILs=20instead=20of=20substring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only the constructed FAIL :/: missing origin/ shape matches. A path that embeds the token is not missing-origin. Co-authored-by: Dandre Yang --- src/dyro/workspace.py | 22 +++++++++++++++------- tests/test_workspace.py | 12 ++++++++++++ 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/src/dyro/workspace.py b/src/dyro/workspace.py index 1a81dc5..5fb4bb7 100644 --- a/src/dyro/workspace.py +++ b/src/dyro/workspace.py @@ -4,6 +4,7 @@ import json import os from pathlib import Path +import re import shutil import stat from typing import Iterable, Mapping @@ -1309,16 +1310,23 @@ def doctor(config: Config, *, read_budget: ReadBudget | None = None) -> list[str return findings -_MISSING_ORIGIN_TOKEN = ": missing origin/" +_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+$" +) def is_missing_origin_finding(finding: str) -> bool: """True only for doctor FAILs that mean origin/ is absent. - Join completion, setup post-doctor, start, and home-open skip these so - SHA-pinned / local-only lines can exist before the remote-tracking ref - is published. ``dyro next`` and Isolated Console do not: a FAIL is not - ready. Wrong upstream, wrong branch, missing worktree, common-dir, and - symlink FAILs still fail. + Matches the constructed shape + ``FAIL :/: missing origin/`` and nothing else. + A path or message that merely embeds ``: missing origin/`` does not match. + + Join completion and setup post-doctor skip these so SHA-pinned / + local-only lines can exist before the remote-tracking ref is published. + ``dyro next``, ``dyro start``, Isolated Console, and + ``existing_line_workspace`` do not: a FAIL is not ready. Wrong upstream, + wrong branch, missing worktree, common-dir, and symlink FAILs still fail. """ - return finding.startswith("FAIL ") and _MISSING_ORIGIN_TOKEN in finding + return _MISSING_ORIGIN_FINDING.fullmatch(finding) is not None diff --git a/tests/test_workspace.py b/tests/test_workspace.py index c47ad4b..de7cf8a 100644 --- a/tests/test_workspace.py +++ b/tests/test_workspace.py @@ -808,3 +808,15 @@ def test_recognizes_only_missing_origin_doctor_fails(self) -> None: "PASS line:alpha/api: missing origin/feat/alpha" ) ) + self.assertFalse( + is_missing_origin_finding( + "FAIL repository api: missing or not Git: " + "/tmp/repo: missing origin/evil" + ) + ) + self.assertFalse( + is_missing_origin_finding( + "FAIL line:alpha/api: missing worktree at " + "/tmp/checkout: missing origin/evil" + ) + ) From c95f1c0ffc62eb7923b0e452d726f40a277cfff2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Aug 2026 19:12:19 +0000 Subject: [PATCH 3/6] =?UTF-8?q?fix(=E6=8E=A7=E5=88=B6=E5=8F=B0):=20start?= =?UTF-8?q?=20refuses=20the=20same=20doctor=20FAILs=20as=20next?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Any doctor FAIL, including missing-origin-only, blocks dyro start and existing_line_workspace. Setup and join still skip missing-origin so a SHA-pinned local-only line can be created. Co-authored-by: Dandre Yang --- src/dyro/cli.py | 6 --- src/dyro/home.py | 2 - tests/test_cli.py | 83 ++++++++++++++++++++++++++++++++++++++++- tests/test_workspace.py | 3 ++ 4 files changed, 85 insertions(+), 9 deletions(-) diff --git a/src/dyro/cli.py b/src/dyro/cli.py index db23e6d..a928567 100644 --- a/src/dyro/cli.py +++ b/src/dyro/cli.py @@ -2483,14 +2483,8 @@ def cmd_start(args: argparse.Namespace) -> None: config = _config(args) findings = doctor(config) failures = [finding for finding in findings if finding.startswith("FAIL")] - blocking = [ - finding - for finding in failures - if not is_missing_origin_finding(finding) - ] if failures: print("\n".join(failures)) - if blocking: raise DyroError( "工作区尚未就绪;先修复 doctor 失败项,或运行 dyro bootstrap --yes" ) diff --git a/src/dyro/home.py b/src/dyro/home.py index a8518c1..de15df7 100644 --- a/src/dyro/home.py +++ b/src/dyro/home.py @@ -46,7 +46,6 @@ create_line, doctor, get_line, - is_missing_origin_finding, line_root, list_lines, preflight_line, @@ -753,7 +752,6 @@ def existing_line_workspace( finding for finding in doctor(config) if any(finding.startswith(prefix) for prefix in relevant) - and not is_missing_origin_finding(finding) ] if failures: raise DyroError( diff --git a/tests/test_cli.py b/tests/test_cli.py index 21d377f..324d967 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -26,7 +26,14 @@ from dyro.tasks import load_task, status, task_template from dyro.tooling import ToolState, load_tool_preferences from dyro.updates import load_update_state -from dyro.workspace import create_line, get_line, line_repository_path, spawn_line +from dyro.workspace import ( + create_line, + doctor, + get_line, + is_missing_origin_finding, + line_repository_path, + spawn_line, +) from .support import WorkspaceCase, publish_origin_branch @@ -801,6 +808,7 @@ def test_print_setup_completion_reflects_skill_failure(self) -> None: class StartTests(WorkspaceCase): def test_start_dry_run_uses_selected_line_and_adapter(self) -> None: + publish_origin_branch(self.anchor, "feat/alpha") config = load(self.root) create_line(config, line_id="alpha", branch="feat/alpha", base="main") output = StringIO() @@ -821,6 +829,78 @@ 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: + 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, + ): + main( + [ + "--root", + str(self.root), + "--dry-run", + "start", + "--line", + "local-only", + "--agent", + "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()) + + def test_start_refuses_fail_whose_path_embeds_missing_origin_token(self) -> None: + config_path = self.root / "dyro.toml" + config_path.write_text( + config_path.read_text(encoding="utf-8") + + "\n[repositories.evil]\n" + + 'path = "repositories/api: missing origin/evil"\n' + + 'mount = "evil"\n', + encoding="utf-8", + ) + findings = doctor(load(self.root)) + embedded = [ + item + for item in findings + if item.startswith("FAIL") and ": missing origin/evil" in item + ] + self.assertTrue(embedded, findings) + for item in embedded: + self.assertFalse(is_missing_origin_finding(item), item) + stdout = StringIO() + stderr = StringIO() + with ( + redirect_stdout(stdout), + redirect_stderr(stderr), + self.assertRaises(SystemExit) as raised, + ): + main( + [ + "--root", + str(self.root), + "--dry-run", + "start", + "--agent", + "noop", + ] + ) + self.assertEqual(raised.exception.code, 2) + combined = stdout.getvalue() + stderr.getvalue() + self.assertIn("尚未就绪", combined) + self.assertTrue( + any(": missing origin/evil" in item for item in embedded), + embedded, + ) + self.assertNotIn("座位", stdout.getvalue()) + def test_next_without_a_profile_explains_how_to_begin(self) -> None: with tempfile.TemporaryDirectory(prefix="dyro-cli-") as tmp: output = StringIO() @@ -927,6 +1007,7 @@ def test_config_and_agent_management_do_not_require_manual_toml_edits(self) -> N ) def test_start_can_launch_an_installed_tool_without_a_profile_adapter(self) -> None: + publish_origin_branch(self.anchor, "feat/alpha") create_line(load(self.root), line_id="alpha", branch="feat/alpha", base="main") launched: list[object] = [] diff --git a/tests/test_workspace.py b/tests/test_workspace.py index de7cf8a..42fc115 100644 --- a/tests/test_workspace.py +++ b/tests/test_workspace.py @@ -6,6 +6,7 @@ from dyro.config import load from dyro.errors import DyroError +from dyro.home import existing_line_workspace from dyro.process import Result from dyro.workspace import ( create_line, @@ -139,6 +140,8 @@ def test_local_only_line_creates_but_doctor_and_next_are_not_ready(self) -> None ), payload, ) + with self.assertRaisesRegex(DyroError, "尚未就绪"): + existing_line_workspace(config, "local-only", "line") def test_doctor_fails_when_one_repo_missing_origin_feat(self) -> None: web = self.root / "repositories/web" From b298f75764c5b9fe799c89bd9ea7de4be5e496d0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Aug 2026 19:12:21 +0000 Subject: [PATCH 4/6] docs: redact product-line token from public review record Replace the leaked token with the fictional example-line placeholder and scan docs for already-known banned identity tokens. Co-authored-by: Dandre Yang --- CHANGELOG.md | 23 +++++++++++++++---- .../2026-08-19-slash-review-and-task-merge.md | 4 ++-- tests/test_readme_identity.py | 10 ++++++++ 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8498f49..72bbfa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,25 @@ - Attach first-party Skill avatars to OpenCode and Hermes when those host homes already exist (`~/.config/opencode/skills/`, - `~/.hermes/skills/`). Detection stays fail-closed: absent homes - stay silent, and Dyro does not create OpenCode or Hermes directories. - `OPENCODE_CONFIG_DIR` and `HERMES_HOME` remain the existing overrides. - Pi (`PI_CODING_AGENT_DIR`, `~/.pi/agent`) is unchanged. + `~/.hermes/skills/`). Detection stays fail-closed: absent default + homes stay silent, and Dyro does not create host homes — including when + `host_homes` or host env vars (`OPENCODE_CONFIG_DIR`, `HERMES_HOME`, + `PI_CODING_AGENT_DIR`, and the other host overrides) point at a path that + is not already a real directory. Missing override homes are skipped + (isolated-mirror-only if no host remains). Symlink path components stay + fail-closed. Pi (`PI_CODING_AGENT_DIR`, `~/.pi/agent`) still uses the + same existing-home rule. +- `dyro start` now refuses the same way `dyro next` does when doctor has + any FAIL, including missing-origin-only. The 0.7.10 note that start + treated missing-origin as non-blocking is no longer the live contract. + `existing_line_workspace` / `open` agree. Setup and join still skip + missing-origin so a SHA-pinned local-only line can be created. +- `is_missing_origin_finding` parses the doctor FAIL shape + (`FAIL :/: missing origin/`) instead of a + substring, so a path that embeds `: missing origin/...` is not classified + as missing-origin and still blocks start. +- Redact a non-fictional product-line token from the public 2026-08-19 + slash-review record. Public docs keep fictional placeholders. ## 0.7.10 - 2026-08-21 diff --git a/docs/reviews/2026-08-19-slash-review-and-task-merge.md b/docs/reviews/2026-08-19-slash-review-and-task-merge.md index 22041aa..075a41e 100644 --- a/docs/reviews/2026-08-19-slash-review-and-task-merge.md +++ b/docs/reviews/2026-08-19-slash-review-and-task-merge.md @@ -2,7 +2,7 @@ Date: 2026-08-19 -Scope: 本会话新增的用户斜杠 `/dyro-task-merge` 与 `/dyro-review-board`(skills-library),不是 Huiyichu 产品线,也不是 `dyro` 包装源码。 +Scope: 本会话新增的用户斜杠 `/dyro-task-merge` 与 `/dyro-review-board`(skills-library),不是 example-line 产品线,也不是 `dyro` 包装源码。 SSOT: 当前 skill 文件 + 已安装 `dyro-board` + `dyroengineeringflow` 中 `merge_task` / `explain_task` / `cmd_next`。本记录不是 Proof,不是 `task review` PASS。 @@ -78,7 +78,7 @@ Final verdict: **No-Go for 提交 / 推送 / 发布。** 用户斜杠可继续 | 对象 | 结论 | | --- | --- | | 当作用户斜杠继续用 | Conditional Go(先收下 P1 文本) | -| 提交 / 推送 / 发布这些 skill 或任何 Dyro/Huiyichu 仓 | **No-Go** | +| 提交 / 推送 / 发布这些 skill 或任何 Dyro/example-line 仓 | **No-Go** | | 用本会审代替 `task review` / 发版 | **No-Go** | ## 须人工核 diff --git a/tests/test_readme_identity.py b/tests/test_readme_identity.py index c7fd465..5797076 100644 --- a/tests/test_readme_identity.py +++ b/tests/test_readme_identity.py @@ -30,3 +30,13 @@ def test_every_readme_language_locks_delivery_physics(self) -> None: self.assertIn("inconclusive", text, msg=name) self.assertNotIn("Symphony", text) self.assertNotIn("Gas Town", text) + + def test_docs_do_not_use_known_banned_identity_tokens(self) -> None: + banned = ("Symphony", "Gas Town") + docs = ROOT / "docs" + for path in docs.rglob("*"): + if not path.is_file() or path.suffix.lower() not in {".md", ".txt", ".toml"}: + continue + text = path.read_text(encoding="utf-8") + for token in banned: + self.assertNotIn(token, text, msg=str(path.relative_to(ROOT))) From 5f9703b4024ee00afb00bc8cfc4a239f5a335fae Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Aug 2026 19:28:51 +0000 Subject: [PATCH 5/6] =?UTF-8?q?fix(=E6=8E=A7=E5=88=B6=E5=8F=B0):=20keep=20?= =?UTF-8?q?home=20create-and-open=20skip=20for=20missing-origin=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit start and next still refuse any doctor FAIL. existing_line_workspace skips missing-origin-only so a just-created local-only line can be opened; other FAILs still block open. Co-authored-by: Dandre Yang --- CHANGELOG.md | 6 ++++-- src/dyro/home.py | 5 +++++ src/dyro/workspace.py | 11 ++++++----- tests/test_workspace.py | 7 +++++-- 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72bbfa6..86e1a80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,8 +15,10 @@ - `dyro start` now refuses the same way `dyro next` does when doctor has any FAIL, including missing-origin-only. The 0.7.10 note that start treated missing-origin as non-blocking is no longer the live contract. - `existing_line_workspace` / `open` agree. Setup and join still skip - missing-origin so a SHA-pinned local-only line can be created. + Setup, join, and the narrow home create-and-open / `dyro open` path + (`existing_line_workspace`) still skip missing-origin-only so a + just-created SHA-pinned local-only line can be created and opened. + Other FAILs still block open. - `is_missing_origin_finding` parses the doctor FAIL shape (`FAIL :/: missing origin/`) instead of a substring, so a path that embeds `: missing origin/...` is not classified diff --git a/src/dyro/home.py b/src/dyro/home.py index de15df7..0e4bf8f 100644 --- a/src/dyro/home.py +++ b/src/dyro/home.py @@ -46,6 +46,7 @@ create_line, doctor, get_line, + is_missing_origin_finding, line_root, list_lines, preflight_line, @@ -748,10 +749,14 @@ def existing_line_workspace( line = get_line(config, line_id, kind) relevant = {f"FAIL repository {repo_id}:" for repo_id in line.repositories} relevant.add(f"FAIL {line.kind}:{line.id}/") + # Home create-and-open and `dyro open` skip missing-origin-only so a + # just-created local-only line can be opened before origin/ + # exists. `dyro start` and `dyro next` do not skip: any FAIL refuses. failures = [ finding for finding in doctor(config) if any(finding.startswith(prefix) for prefix in relevant) + and not is_missing_origin_finding(finding) ] if failures: raise DyroError( diff --git a/src/dyro/workspace.py b/src/dyro/workspace.py index 5fb4bb7..1d70379 100644 --- a/src/dyro/workspace.py +++ b/src/dyro/workspace.py @@ -1323,10 +1323,11 @@ def is_missing_origin_finding(finding: str) -> bool: ``FAIL :/: missing origin/`` and nothing else. A path or message that merely embeds ``: missing origin/`` does not match. - Join completion and setup post-doctor skip these so SHA-pinned / - local-only lines can exist before the remote-tracking ref is published. - ``dyro next``, ``dyro start``, Isolated Console, and - ``existing_line_workspace`` do not: a FAIL is not ready. Wrong upstream, - wrong branch, missing worktree, common-dir, and symlink FAILs still fail. + Join completion, setup post-doctor, home create-and-open, and + ``existing_line_workspace`` / ``dyro open`` skip these so SHA-pinned / + local-only lines can exist (and be opened) before the remote-tracking + ref is published. ``dyro next``, ``dyro start``, and Isolated Console + do not: a FAIL is not ready. Wrong upstream, wrong branch, missing + worktree, common-dir, and symlink FAILs still fail. """ return _MISSING_ORIGIN_FINDING.fullmatch(finding) is not None diff --git a/tests/test_workspace.py b/tests/test_workspace.py index 42fc115..13d632a 100644 --- a/tests/test_workspace.py +++ b/tests/test_workspace.py @@ -140,8 +140,11 @@ def test_local_only_line_creates_but_doctor_and_next_are_not_ready(self) -> None ), payload, ) - with self.assertRaisesRegex(DyroError, "尚未就绪"): - existing_line_workspace(config, "local-only", "line") + # 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") + self.assertEqual(line.id, "local-only") + self.assertTrue(workspace.is_dir()) def test_doctor_fails_when_one_repo_missing_origin_feat(self) -> None: web = self.root / "repositories/web" From 77fc9b17eb98daad47e1ded630c658f7ee9de4ed Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Aug 2026 19:56:28 +0000 Subject: [PATCH 6/6] =?UTF-8?q?fix(=E6=8E=A7=E5=88=B6=E5=8F=B0):=20open=20?= =?UTF-8?q?refuses=20every=20doctor=20FAIL=20except=20missing-origin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit existing_line_workspace used a per-line prefix filter, so workspace-level FAILs such as external Profile never blocked open. Refuse the same FAIL set next maps to needs_repair, minus the constructed missing-origin shape. Co-authored-by: Dandre Yang --- CHANGELOG.md | 9 ++-- src/dyro/home.py | 16 +++---- src/dyro/workspace.py | 11 ++--- tests/test_hub.py | 12 ++++-- tests/test_workspace.py | 93 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 120 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86e1a80..5170d47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,10 +15,11 @@ - `dyro start` now refuses the same way `dyro next` does when doctor has any FAIL, including missing-origin-only. The 0.7.10 note that start treated missing-origin as non-blocking is no longer the live contract. - Setup, join, and the narrow home create-and-open / `dyro open` path - (`existing_line_workspace`) still skip missing-origin-only so a - just-created SHA-pinned local-only line can be created and opened. - Other FAILs still block open. + Setup, join, and `dyro open` / home create-and-open + (`existing_line_workspace`) skip only the constructed missing-origin + shape so a just-created SHA-pinned local-only line can be created and + opened. Every other doctor FAIL — including workspace-level + `FAIL external Profile requires …` — blocks open. - `is_missing_origin_finding` parses the doctor FAIL shape (`FAIL :/: missing origin/`) instead of a substring, so a path that embeds `: missing origin/...` is not classified diff --git a/src/dyro/home.py b/src/dyro/home.py index 0e4bf8f..39c6638 100644 --- a/src/dyro/home.py +++ b/src/dyro/home.py @@ -747,16 +747,15 @@ def existing_line_workspace( config: Config, line_id: str, kind: str | None ) -> tuple[Line, Path]: line = get_line(config, line_id, kind) - relevant = {f"FAIL repository {repo_id}:" for repo_id in line.repositories} - relevant.add(f"FAIL {line.kind}:{line.id}/") - # Home create-and-open and `dyro open` skip missing-origin-only so a - # just-created local-only line can be opened before origin/ - # exists. `dyro start` and `dyro next` do not skip: any FAIL refuses. + # Same FAIL set as `dyro next` → needs_repair, minus the constructed + # missing-origin shape. Workspace-level FAILs (external Profile, …) + # and other-line FAILs block open; only missing-origin-only may skip + # so a just-created local-only line can be opened. start/next refuse + # even that skip. failures = [ finding for finding in doctor(config) - if any(finding.startswith(prefix) for prefix in relevant) - and not is_missing_origin_finding(finding) + if finding.startswith("FAIL") and not is_missing_origin_finding(finding) ] if failures: raise DyroError( @@ -2181,7 +2180,8 @@ def _run_config_home( failures = [finding for finding in doctor(config) if finding.startswith("FAIL")] if failures: print( - f"\n检测到 {len(failures)} 个结构问题;只会阻止进入受影响的目标。" + f"\n检测到 {len(failures)} 个结构问题;除尚未发布的 " + "origin/ 外,doctor FAIL 会阻止打开开发线。" "运行 dyro doctor 查看详情。" ) briefing = _print_ready_briefing(config, record) diff --git a/src/dyro/workspace.py b/src/dyro/workspace.py index 1d70379..8418665 100644 --- a/src/dyro/workspace.py +++ b/src/dyro/workspace.py @@ -1324,10 +1324,11 @@ def is_missing_origin_finding(finding: str) -> bool: 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 these so SHA-pinned / - local-only lines can exist (and be opened) before the remote-tracking - ref is published. ``dyro next``, ``dyro start``, and Isolated Console - do not: a FAIL is not ready. Wrong upstream, wrong branch, missing - worktree, common-dir, and symlink FAILs still fail. + ``existing_line_workspace`` / ``dyro open`` skip only 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. """ return _MISSING_ORIGIN_FINDING.fullmatch(finding) is not None diff --git a/tests/test_hub.py b/tests/test_hub.py index 946a33a..e8456f7 100644 --- a/tests/test_hub.py +++ b/tests/test_hub.py @@ -1207,7 +1207,7 @@ def run(argv: tuple[str, ...], **_: object) -> subprocess.CompletedProcess[str]: self.assertNotIn("openclaw", load(self.root).adapters) self.assertEqual(self.root.joinpath("dyro.toml").read_bytes(), before) - def test_unhealthy_line_does_not_block_opening_a_healthy_line(self) -> None: + def test_unhealthy_sibling_fail_blocks_opening_a_healthy_line(self) -> None: self._create_line() create_line(load(self.root), line_id="beta", branch="feat/beta", base="main") shell( @@ -1220,18 +1220,22 @@ def test_unhealthy_line_does_not_block_opening_a_healthy_line(self) -> None: add_workspace(self.root, name="demo", make_default=True) output = StringIO() + stderr = StringIO() with ( patch("dyro.home.Path.cwd", return_value=self.root.parent), patch("dyro.home.interactive_terminal", return_value=True), patch("builtins.input", return_value=""), redirect_stdout(output), + redirect_stderr(stderr), + self.assertRaises(SystemExit) as raised, ): main(["--dry-run"]) - rendered = output.getvalue() + self.assertEqual(raised.exception.code, 2) + rendered = output.getvalue() + stderr.getvalue() self.assertIn("检测到 1 个结构问题", rendered) - self.assertIn(str(self.root / "versions/alpha"), rendered) - self.assertIn("/usr/bin/true", rendered) + self.assertIn("尚未就绪", rendered) + self.assertNotIn("/usr/bin/true", output.getvalue()) def test_task_open_uses_existing_worktree_without_changing_status(self) -> None: self._create_task_worktree() diff --git a/tests/test_workspace.py b/tests/test_workspace.py index 13d632a..2f5222d 100644 --- a/tests/test_workspace.py +++ b/tests/test_workspace.py @@ -146,6 +146,99 @@ def test_local_only_line_creates_but_doctor_and_next_are_not_ready(self) -> None self.assertEqual(line.id, "local-only") self.assertTrue(workspace.is_dir()) + def test_external_profile_fail_blocks_open_missing_origin_only_does_not( + self, + ) -> None: + from contextlib import redirect_stderr, redirect_stdout + from io import StringIO + import json + + from dyro.cli import main + + config = load(self.root) + create_line( + config, line_id="local-only", branch="feat/local-only", base="main" + ) + line, workspace = existing_line_workspace(config, "local-only", "line") + self.assertEqual(line.id, "local-only") + self.assertTrue(workspace.is_dir()) + + publish_origin_branch(self.anchor, "feat/alpha") + create_line(load(self.root), line_id="alpha", branch="feat/alpha", base="main") + config_path = self.root / "dyro.toml" + config_path.write_text( + config_path.read_text(encoding="utf-8").replace( + "require_clean_merge = true", + 'require_clean_merge = true\nexecution_mode = "external"', + ), + encoding="utf-8", + ) + config = load(self.root) + findings = doctor(config) + self.assertTrue( + any( + item.startswith("FAIL") + and "external Profile requires" in item + and "require_signed_execution" in item + for item in findings + ), + findings, + ) + self.assertFalse( + any(is_missing_origin_finding(item) and "alpha" in item for item in findings), + findings, + ) + with self.assertRaisesRegex(DyroError, "尚未就绪|external Profile"): + existing_line_workspace(config, "alpha", "line") + + stdout = StringIO() + stderr = StringIO() + with ( + redirect_stdout(stdout), + redirect_stderr(stderr), + self.assertRaises(SystemExit) as raised, + ): + main( + [ + "--root", + str(self.root), + "--dry-run", + "open", + "alpha", + "--agent", + "noop", + ] + ) + self.assertEqual(raised.exception.code, 2) + self.assertIn("尚未就绪", stdout.getvalue() + stderr.getvalue()) + + start_out = StringIO() + start_err = StringIO() + with ( + redirect_stdout(start_out), + redirect_stderr(start_err), + self.assertRaises(SystemExit) as start_raised, + ): + main( + [ + "--root", + str(self.root), + "--dry-run", + "start", + "--line", + "alpha", + "--agent", + "noop", + ] + ) + self.assertEqual(start_raised.exception.code, 2) + + next_out = StringIO() + with redirect_stdout(next_out): + main(["--root", str(self.root), "next", "--format", "json"]) + payload = json.loads(next_out.getvalue()) + self.assertEqual(payload["state"], "needs_repair") + def test_doctor_fails_when_one_repo_missing_origin_feat(self) -> None: web = self.root / "repositories/web" web.mkdir(parents=True)