From f19caff183f0bf5d5d75196dc851d0ca7f47d7d6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 09:20:30 +0000 Subject: [PATCH] =?UTF-8?q?fix(=E6=8E=A7=E5=88=B6=E5=8F=B0):=20next=20must?= =?UTF-8?q?=20not=20claim=20ready=20on=20doctor=20FAIL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Treat any doctor FAIL, including missing-origin-only, as needs_repair with a scoped doctor read command. Isolated Console now loads the same next.commands source instead of an empty loader. Co-authored-by: Dandre Yang --- CHANGELOG.md | 8 +++ src/dyro/cli.py | 51 ++--------------- src/dyro/console/_inspect_worker.py | 7 ++- src/dyro/console/overview.py | 10 +++- src/dyro/continuation/next_step.py | 89 +++++++++++++++++++++++++++++ src/dyro/workspace.py | 9 +-- tests/test_cli.py | 27 +++++++-- tests/test_console_inspection.py | 36 ++++++++++++ tests/test_console_overview.py | 33 +++++++++++ tests/test_workspace.py | 15 +++-- 10 files changed, 225 insertions(+), 60 deletions(-) create mode 100644 src/dyro/continuation/next_step.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1312dd1..ef6f1b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +- `dyro next` no longer reports `ready` when doctor has FAIL findings, + including missing-origin-only. JSON `state` is `needs_repair`, + `commands` includes scoped `doctor` (a read; `mutation_available` + stays false), and the human path prints the FAILs instead of + 「工作区已就绪」。`dyro start` still treats missing-origin as + non-blocking. Isolated Console uses the same `next.commands` source + instead of an empty loader, so a FAIL workspace is not stuck with + empty commands while production is not. - Console operator surface: treat doctor FAIL findings as something the page must show even when `next` reports ready with empty commands. Overview heading and 现在需要你 surface those FAILs; the primary copy diff --git a/src/dyro/cli.py b/src/dyro/cli.py index 8dab197..db23e6d 100644 --- a/src/dyro/cli.py +++ b/src/dyro/cli.py @@ -44,6 +44,7 @@ render_human_attention, render_human_wave, ) +from .continuation.next_step import bootstrap_repair_applicable, repair_commands from .continuation.ready_briefing import briefing_command, build_ready_briefing from .continuation.engine import ( build_scheduler_tick, @@ -164,7 +165,6 @@ render_setup_plan, repository_input_from_path, sibling_workspace_for, - validate_bootstrap_destination, ) from .read_limits import ObservationLimits, ReadBudget, ReadLimitCode, ReadLimitError from .profile import ( @@ -2525,14 +2525,6 @@ def cmd_start(args: argparse.Namespace) -> None: ) -def _bootstrap_destination_safe(config: Config, relative: str) -> bool: - try: - validate_bootstrap_destination(config, relative) - except (DyroError, OSError): - return False - return True - - def _next_push_fields(config: Config) -> dict[str, object]: return push_policy_fields(config.policy) @@ -2581,34 +2573,10 @@ def cmd_next(args: argparse.Namespace) -> None: budget = _control_plane_budget(args) if args.format == "json" else None findings = doctor(config, read_budget=budget) failures = [finding for finding in findings if finding.startswith("FAIL")] - missing_origin_failures = [ - finding for finding in failures if is_missing_origin_finding(finding) - ] - blocking_failures = [ - finding for finding in failures if not is_missing_origin_finding(finding) - ] - if blocking_failures: - absent_bootstrap_ids = { - repo_id - for repo_id, repository in config.repositories.items() - if repository.remote - and not (config.root / repository.path).exists() - and not (config.root / repository.path).is_symlink() - and _bootstrap_destination_safe(config, repository.path) - } - expected_bootstrap_failures = { - f"FAIL repository {repo_id}: missing or not Git: " - f"{config.root / config.repositories[repo_id].path}" - for repo_id in absent_bootstrap_ids - } - bootstrap_applicable = ( - bool(absent_bootstrap_ids) and set(failures) == expected_bootstrap_failures - ) - repair_commands = ( - [_briefing_command(args, config, "bootstrap", "--yes")] - if bootstrap_applicable - else [] - ) + if failures: + alias = getattr(args, "workspace_alias", None) or config.name + commands = repair_commands(config, str(alias), failures) + bootstrap_applicable = bootstrap_repair_applicable(config, failures) findings = [ _doctor_finding_payload(item, include_paths=False) for item in failures ] @@ -2617,7 +2585,7 @@ def cmd_next(args: argparse.Namespace) -> None: "next_step", state="needs_repair", summary="工作区还不能开始任务。", - commands=repair_commands, + commands=commands, diagnostic_commands=[_briefing_command(args, config, "doctor")], mutation_available=bootstrap_applicable, findings=findings, @@ -2689,17 +2657,10 @@ def cmd_next(args: argparse.Namespace) -> None: if briefing is not None: payload["briefing"] = briefing payload["diagnostic_commands"] = diagnostic_commands - if missing_origin_failures: - payload["findings"] = [ - _doctor_finding_payload(item, include_paths=False) - for item in missing_origin_failures - ] payload.update(_family_unacked_fields(config)) payload.update(_next_push_fields(config)) _print_control_plane_json("next_step", **payload) return - for finding in missing_origin_failures: - _print_doctor_finding(finding) if briefing is None: print("工作区已就绪。可用 dyro start 打开本机已安装的编码工具。") _print_family_unacked_attention(config) diff --git a/src/dyro/console/_inspect_worker.py b/src/dyro/console/_inspect_worker.py index 4700787..3f48854 100644 --- a/src/dyro/console/_inspect_worker.py +++ b/src/dyro/console/_inspect_worker.py @@ -19,6 +19,7 @@ from typing import Any from ..config import load +from ..continuation.next_step import next_commands from ..hub import WorkspaceRecord, WorkspaceRegistry from .overview import ( ConsoleOverviewError, @@ -61,7 +62,10 @@ def _capture_workspace_summary( default=record.name if is_default else "", workspaces=(record,), ) - service = ConsoleOverviewService(registry_loader=lambda: registry) + service = ConsoleOverviewService( + registry_loader=lambda: registry, + commands_loader=next_commands, + ) payload = service.workspace(record.name) summary = payload["data"]["workspace"] warnings = [item["code"] for item in payload["freshness"]["warnings"]] @@ -282,6 +286,7 @@ def main(argv: list[str] | None = None) -> int: service_arguments: dict[str, object] = { "cursor_secret": _secret_from_environment(), "summary_loader": _isolated_summaries, + "commands_loader": next_commands, } if target_registry is not None: service_arguments["registry_loader"] = lambda: target_registry diff --git a/src/dyro/console/overview.py b/src/dyro/console/overview.py index 6f9f759..b4c1ad3 100644 --- a/src/dyro/console/overview.py +++ b/src/dyro/console/overview.py @@ -779,7 +779,7 @@ def _capture( warning_codes.add("DOCTOR_UNAVAILABLE") commands: list[object] = [] try: - loaded = self._commands_loader(config) + loaded = self._invoke_commands_loader(config, alias) if isinstance(loaded, list): commands = loaded except (DyroError, ValidationError, OSError, UnicodeError, TypeError, AttributeError): @@ -848,6 +848,14 @@ def _workspace_attention( ) return {"counts": counts, "items": items} + def _invoke_commands_loader(self, config: Config, alias: str) -> object: + """Prefer ``(config, alias)`` so Isolated next.commands match the card.""" + loader = self._commands_loader + try: + return loader(config, alias) + except TypeError: + return loader(config) + def _recommendation( self, alias: str, diff --git a/src/dyro/continuation/next_step.py b/src/dyro/continuation/next_step.py new file mode 100644 index 0000000..10421fe --- /dev/null +++ b/src/dyro/continuation/next_step.py @@ -0,0 +1,89 @@ +"""Read-only ``next.commands`` projection. Isolated Console may import this.""" + +from __future__ import annotations + +from ..config import Config +from ..errors import DyroError, ValidationError +from ..onboarding import validate_bootstrap_destination +from ..read_limits import ReadBudget +from ..workspace import doctor, list_lines +from .ready_briefing import briefing_command + + +def next_commands( + config: Config, + alias: str | None = None, + *, + read_budget: ReadBudget | None = None, +) -> list[str]: + """Same list ``dyro next`` puts in JSON ``commands``. + + Isolated workers may call this. It does not import launcher, provider, + or session credentials. Commands never embed ``--root`` paths. + """ + token = alias if isinstance(alias, str) and alias else getattr(config, "name", "") + if not isinstance(token, str) or not token: + return [] + try: + findings = doctor(config, read_budget=read_budget) + except (DyroError, ValidationError, OSError, TypeError, AttributeError): + return [] + failures = [ + item + for item in findings + if isinstance(item, str) and item.startswith("FAIL") + ] + if failures: + return repair_commands(config, token, failures) + try: + lines = list_lines(config, read_budget=read_budget) + except (DyroError, ValidationError, OSError, TypeError, AttributeError): + return [] + if not lines: + return [briefing_command(token, "line", "create", "dev", "--yes")] + return [] + + +def repair_commands(config: Config, alias: str, failures: list[str]) -> list[str]: + """Scoped repair command for doctor FAILs. Doctor is a read, not ``--yes``.""" + if not failures: + return [] + if bootstrap_repair_applicable(config, failures): + return [briefing_command(alias, "bootstrap", "--yes")] + return [briefing_command(alias, "doctor")] + + +def bootstrap_repair_applicable(config: Config, failures: list[str]) -> bool: + """True only when every FAIL is a missing repo that bootstrap can clone.""" + repositories = getattr(config, "repositories", {}) + if not isinstance(repositories, dict) or not failures: + return False + root = getattr(config, "root", None) + if root is None: + return False + absent: set[str] = set() + for repo_id, repository in repositories.items(): + remote = getattr(repository, "remote", "") + path = getattr(repository, "path", "") + if not remote or not path: + continue + destination = root / path + try: + present = destination.exists() or destination.is_symlink() + except OSError: + continue + if present: + continue + try: + validate_bootstrap_destination(config, path) + except (DyroError, OSError): + continue + absent.add(str(repo_id)) + if not absent: + return False + expected = { + f"FAIL repository {repo_id}: missing or not Git: " + f"{root / repositories[repo_id].path}" + for repo_id in absent + } + return set(failures) == expected diff --git a/src/dyro/workspace.py b/src/dyro/workspace.py index 65777cd..1a81dc5 100644 --- a/src/dyro/workspace.py +++ b/src/dyro/workspace.py @@ -1315,9 +1315,10 @@ def doctor(config: Config, *, read_budget: ReadBudget | None = None) -> list[str def is_missing_origin_finding(finding: str) -> bool: """True only for doctor FAILs that mean origin/ is absent. - Join completion, setup post-doctor, start, next, and home-open skip these - so SHA-pinned / local-only lines can exist before the remote-tracking ref - is published. Wrong upstream, wrong branch, missing worktree, common-dir, - and symlink FAILs still fail. + 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. """ return finding.startswith("FAIL ") and _MISSING_ORIGIN_TOKEN in finding diff --git a/tests/test_cli.py b/tests/test_cli.py index 89f723e..1307732 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1126,9 +1126,20 @@ def test_control_plane_next_preserves_an_explicit_workspace_selector(self) -> No ) payload = json.loads(output.getvalue()) - self.assertEqual(payload["state"], "ready") - self.assertEqual(payload["commands"], []) + 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, + ) def test_control_plane_json_runtime_errors_use_one_stable_envelope(self) -> None: with tempfile.TemporaryDirectory(prefix="dyro-registry-") as registry_home: @@ -1259,7 +1270,10 @@ def test_control_plane_next_only_offers_applicable_bootstrap(self) -> None: self.anchor.rename(self.root / "api-missing") unavailable = self._read_json("next") self.assertFalse(unavailable["mutation_available"]) - self.assertEqual(unavailable["commands"], []) + self.assertEqual( + unavailable["commands"], + ["dyro --workspace test-workspace doctor"], + ) self.assertEqual( unavailable["diagnostic_commands"], ["dyro --workspace test-workspace doctor"], @@ -1305,7 +1319,10 @@ def test_control_plane_next_never_offers_bootstrap_through_symlink_parent( payload = self._read_json("next") self.assertFalse(payload["mutation_available"]) - self.assertEqual(payload["commands"], []) + self.assertEqual( + payload["commands"], + ["dyro --workspace test-workspace doctor"], + ) self.assertFalse((outside / "api").exists()) def test_control_plane_rejects_symlinked_line_and_changeset_manifests(self) -> None: @@ -1699,6 +1716,7 @@ def test_objective_explain_json_includes_path_free_briefing(self) -> None: self.assertIn("下一步:", "\n".join(briefing["lines"])) def test_next_with_one_live_objective_points_to_follow_up(self) -> None: + publish_origin_branch(self.anchor, "feat/alpha") self._start_release_objective() explain = self._read_json("objective", "explain", "release") payload = self._read_json("next") @@ -1744,6 +1762,7 @@ def test_bare_dyro_without_objectives_does_not_invent_a_briefing(self) -> None: self.assertNotIn("objective attention", text) def test_next_with_two_live_objectives_does_not_pick_one(self) -> None: + publish_origin_branch(self.anchor, "feat/alpha") self._start_release_objective() directory = self.config.task_specs_dir / "TASK-B" directory.mkdir(parents=True) diff --git a/tests/test_console_inspection.py b/tests/test_console_inspection.py index 69922e1..c5c5129 100644 --- a/tests/test_console_inspection.py +++ b/tests/test_console_inspection.py @@ -794,6 +794,42 @@ def test_missing_origin_fail_is_not_ready_or_a_bare_workspace_command(self) -> N self.assertEqual(card["health"], "degraded") self.assertNotEqual(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", + ) + + def test_isolated_summary_worker_passes_next_commands_loader(self) -> None: + from dyro.config import load + from dyro.continuation.next_step import next_commands + from dyro.workspace import create_line + + create_line(load(self.root), line_id="core", branch="feat/core", base="main") + seen: dict[str, object] = {} + real = _inspect_worker.ConsoleOverviewService + + def spy(*args: object, **kwargs: object): + seen.update(kwargs) + return real(*args, **kwargs) + + class _Queue: + def put(self, value: object) -> None: + self.value = value + + with patch.object(_inspect_worker, "ConsoleOverviewService", side_effect=spy): + _inspect_worker._capture_workspace_summary( + _Queue(), WorkspaceRecord("demo", self.root), True + ) + + self.assertIs(seen.get("commands_loader"), next_commands) + commands = next_commands(load(self.root), alias="demo") + self.assertIn("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: from dyro.config import load diff --git a/tests/test_console_overview.py b/tests/test_console_overview.py index d16c74b..8ec95f6 100644 --- a/tests/test_console_overview.py +++ b/tests/test_console_overview.py @@ -262,6 +262,39 @@ def test_fail_findings_and_empty_commands_recommend_doctor_not_bare_home(self) - self.assertEqual(recommendation["reason"], "MISSING_ORIGIN") self.assertNotEqual(recommendation["reason"], "HOME_GUIDANCE") + def test_commands_loader_receives_registry_alias_not_empty_default(self) -> None: + seen: list[str | None] = [] + + def loader(config: object, alias: str | None = None) -> list[str]: + seen.append(alias) + return [f"dyro --workspace {alias} doctor"] + + self.registry = WorkspaceRegistry( + default="core", + workspaces=(WorkspaceRecord("core", self.alpha_root),), + ) + self.snapshots["Alpha Project"] = _snapshot( + name="Alpha Project", + attention=(), + ) + service = ConsoleOverviewService( + registry_loader=lambda: self.registry, + config_loader=self.service._config_loader, + snapshot_loader=lambda config: self.snapshots[config.name], + clock=self.service._clock, + cursor_secret=b"k" * 32, + doctor_loader=lambda config: [ + "FAIL line:core/api: missing origin/feat/core", + ], + commands_loader=loader, + ) + + card = service.page()["data"]["workspaces"][0] + + self.assertEqual(seen, ["core"]) + self.assertEqual(card["recommendation"]["command"], "dyro --workspace core doctor") + self.assertNotEqual(card["recommendation"]["command"], "dyro --workspace core") + def test_fail_findings_project_path_free_and_degrade_health(self) -> None: self.registry = WorkspaceRegistry( default="core", diff --git a/tests/test_workspace.py b/tests/test_workspace.py index d92cce7..c47ad4b 100644 --- a/tests/test_workspace.py +++ b/tests/test_workspace.py @@ -117,16 +117,21 @@ def test_local_only_line_creates_but_doctor_and_next_are_not_ready(self) -> None with redirect_stdout(output): main(["--root", str(self.root), "next"]) rendered = output.getvalue() - # doctor still FAILs missing origin; next stays ready when that is - # the only FAIL and discloses it. + # doctor FAILs missing origin; next must not sell that as ready. self.assertIn("missing origin/feat/local-only", rendered) - self.assertNotIn("还不能开始任务", rendered) - self.assertIn("工作区已就绪", rendered) + self.assertIn("还不能开始任务", rendered) + self.assertNotIn("工作区已就绪", rendered) + self.assertIn("dyro --workspace test-workspace doctor", 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"], "ready") + 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", "")