diff --git a/CHANGELOG.md b/CHANGELOG.md index befa929..8cf1f63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,16 @@ non-empty `doctor` repair command. JSON `status` deadline partial exits 2, same as `doctor`. It is not a bare `kind=error` with `command`. `next` stays `needs_repair` (never ready on FAIL). + A post-doctor briefing-only ceiling still keeps `kind: next_step` + with `partial: true` and `code: DEADLINE_EXCEEDED` plus unread + `briefing` (not leftover doctor repair) and exits 2; it is not a + complete ready observation. +- `dyro start` no longer prints unscoped `dyro bootstrap --yes` when + doctor has FAIL. If bootstrap can clone the missing remotes, the ad + uses the same root-safe selector as `next` / briefing (`--workspace` + only when it stays on this root; otherwise `--root `). Bare + `dyro bootstrap --yes` is never advertised when that command would + resolve to a different registry default. - 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 default diff --git a/src/dyro/cli.py b/src/dyro/cli.py index f265ced..922841c 100644 --- a/src/dyro/cli.py +++ b/src/dyro/cli.py @@ -43,6 +43,7 @@ render_briefing_text, render_human_attention, render_human_wave, + unread_briefing, ) from .continuation.next_step import ( bootstrap_repair_applicable, @@ -2747,9 +2748,12 @@ def cmd_start(args: argparse.Namespace) -> None: failures = [finding for finding in findings if finding.startswith("FAIL")] if failures: print("\n".join(failures)) - raise DyroError( - "工作区尚未就绪;先修复 doctor 失败项,或运行 dyro bootstrap --yes" - ) + extra = "" + if bootstrap_repair_applicable(config, failures): + extra = ",或运行 " + _briefing_command( + args, config, "bootstrap", "--yes" + ) + raise DyroError("工作区尚未就绪;先修复 doctor 失败项" + extra) alias = getattr(args, "workspace_alias", None) or config.name briefing, _ = build_ready_briefing(config, alias=str(alias)) text = render_briefing_text(briefing) if briefing else "" @@ -2906,9 +2910,20 @@ def cmd_next(args: argparse.Namespace) -> None: _print_family_unacked_attention(config) _print_push_disclosure(config) return - briefing, diagnostic_commands = _workspace_ready_briefing( - args, config, budget - ) + try: + briefing, diagnostic_commands = _workspace_ready_briefing( + args, config, budget + ) + briefing_deadline = False + except ReadLimitError as exc: + if exc.code != ReadLimitCode.DEADLINE_EXCEEDED: + raise + briefing_deadline = True + alias = getattr(args, "workspace_alias", None) or config.name + command = scoped_briefing_command( + config, str(alias), "objective", "list" + ) + briefing, diagnostic_commands = unread_briefing(command), [command] if args.format == "json": payload: dict[str, object] = { "state": "ready", @@ -2919,9 +2934,14 @@ def cmd_next(args: argparse.Namespace) -> None: if briefing is not None: payload["briefing"] = briefing payload["diagnostic_commands"] = diagnostic_commands + if briefing_deadline: + payload["partial"] = True + payload.update(_observation_timeout_fields(partial=True)) payload.update(_family_unacked_fields(config)) payload.update(_next_push_fields(config)) _print_control_plane_json("next_step", **payload) + if briefing_deadline: + raise SystemExit(2) return if briefing is None: print("工作区已就绪。可用 dyro start 打开本机已安装的编码工具。") diff --git a/src/dyro/continuation/ready_briefing.py b/src/dyro/continuation/ready_briefing.py index 6a26fa3..78cf795 100644 --- a/src/dyro/continuation/ready_briefing.py +++ b/src/dyro/continuation/ready_briefing.py @@ -12,7 +12,7 @@ unique_registered_alias, workspace_alias_retargets_root, ) -from ..read_limits import ReadBudget, ReadLimitError +from ..read_limits import ReadBudget, ReadLimitCode, ReadLimitError from .briefing import ( briefing_payload, follow_up_argv, @@ -102,7 +102,12 @@ def build_ready_briefing( ) if record.operator_state != "stopped" ] - except (DyroError, ValidationError, OSError, ReadLimitError): + except ReadLimitError as exc: + if exc.code == ReadLimitCode.DEADLINE_EXCEEDED: + raise + command = scoped_briefing_command(config, alias, "objective", "list") + return unread_briefing(command), [command] + except (DyroError, ValidationError, OSError): command = scoped_briefing_command(config, alias, "objective", "list") return unread_briefing(command), [command] if not records: @@ -116,7 +121,11 @@ def build_ready_briefing( ) try: stored, plan = _read_plan(config, record.objective.id, read_budget) - except (DyroError, ValidationError, OSError, ReadLimitError): + except ReadLimitError as exc: + if exc.code == ReadLimitCode.DEADLINE_EXCEEDED: + raise + return unread_briefing(explain), [explain] + except (DyroError, ValidationError, OSError): return unread_briefing(explain), [explain] command = scoped_briefing_command(config, alias, *follow_up_argv(plan)) return ( diff --git a/src/dyro/integrations/assets/dyro-control-plane/SKILL.md b/src/dyro/integrations/assets/dyro-control-plane/SKILL.md index 6df25d0..0ea3684 100644 --- a/src/dyro/integrations/assets/dyro-control-plane/SKILL.md +++ b/src/dyro/integrations/assets/dyro-control-plane/SKILL.md @@ -30,7 +30,7 @@ When the request already supplies a workspace alias, skip global discovery and u - Objective next-wave preview: `dyro --workspace objective tick --format json`. Treat `peer_wave.executor_bindings` as the intended peer executors for that wave, and `peer_wave.warnings` as missing `conflict_group` or harness-capacity notes. A wave member is an executor, not a live supervisor. - Objective plan: `dyro --workspace objective plan --format json` -Use only an existing Objective or Change Set ID returned by Dyro or supplied by the user. A non-zero exit, unavailable workspace, pending transaction, failed finding, missing field, or partial observation is unknown or blocked—not ready. JSON `doctor` / `status` / `next` use a 45s read ceiling (not the 5s Bridge default). A multi-worktree Mac workspace that crosses 5s on every JSON `status` sample (locked five-run, ~5.35–5.41s) must succeed or return this structured partial — never a bare `kind=error` `DEADLINE_EXCEEDED`. If the 45s ceiling is hit, `kind` stays `doctor` / `workspace_status` / `next_step` with `partial: true`, `code: DEADLINE_EXCEEDED`, completed FAIL findings or rows, and a non-empty `doctor` repair command on `next`. JSON `status` with `partial: true` is incomplete and exits 2, same as `doctor`. That is blocked evidence, not a bare `kind=error`, and not ready. Isolated Console overview does not attach this 45s budget. +Use only an existing Objective or Change Set ID returned by Dyro or supplied by the user. A non-zero exit, unavailable workspace, pending transaction, failed finding, missing field, or partial observation is unknown or blocked—not ready. JSON `doctor` / `status` / `next` use a 45s read ceiling (not the 5s Bridge default). A multi-worktree Mac workspace that crosses 5s on every JSON `status` sample (locked five-run, ~5.35–5.41s) must succeed or return this structured partial — never a bare `kind=error` `DEADLINE_EXCEEDED`. If the 45s ceiling is hit, `kind` stays `doctor` / `workspace_status` / `next_step` with `partial: true` and `code: DEADLINE_EXCEEDED`. Leftover doctor / status / next keep completed FAIL findings or rows and a non-empty `doctor` repair command on `next`. A post-doctor briefing-only ceiling stays `next_step` with unread `briefing` (not leftover doctor repair) and exits 2 — that is blocked evidence, not a complete ready observation. JSON `status` with `partial: true` is incomplete and exits 2, same as `doctor`. That is blocked evidence, not a bare `kind=error`, and not ready. Isolated Console overview does not attach this 45s budget. Treat local paths and workspace inventory as sensitive metadata. Never add `--include-paths` to any command, request paths only to enrich a summary, or repeat a local path in the response unless the user supplied that exact path and it is necessary to identify the requested workspace. Keep Task IDs, branch names, and commit identifiers to the minimum needed for the requested observation. diff --git a/tests/test_control_plane_read_budget.py b/tests/test_control_plane_read_budget.py index 3f2813a..fb90555 100644 --- a/tests/test_control_plane_read_budget.py +++ b/tests/test_control_plane_read_budget.py @@ -42,7 +42,7 @@ status_rows, ) -from .support import WorkspaceCase +from .support import WorkspaceCase, publish_origin_branch # Locked pre-merge Mac baseline: five consecutive JSON status walls, all # DEADLINE_EXCEEDED on the 5s protocol budget, zero successes. The faster @@ -698,6 +698,40 @@ def test_print_error_does_not_emit_mac_bare_status_envelope(self) -> None: self.assertTrue(payload["commands"], payload) self.assertIn("doctor", payload["commands"][0]) + def test_json_next_briefing_deadline_keeps_partial_and_code(self) -> None: + """Post-doctor briefing ceiling must not claim complete ready.""" + + publish_origin_branch(self.anchor, "feat/alpha") + create_line(load(self.root), line_id="alpha", branch="feat/alpha", base="main") + deadline = ReadLimitError( + ReadLimitCode.DEADLINE_EXCEEDED, + "Core observation deadline exceeded", + ) + stdout = StringIO() + stderr = StringIO() + with ( + patch( + "dyro.continuation.ready_briefing.list_objectives", + side_effect=deadline, + ), + redirect_stdout(stdout), + redirect_stderr(stderr), + self.assertRaises(SystemExit) as raised, + ): + main(["--root", str(self.root), "next", "--format", "json"]) + self.assertEqual(raised.exception.code, 2) + self.assertEqual(stderr.getvalue(), "") + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["kind"], "next_step") + self.assertNotEqual(payload.get("kind"), "error") + self.assertTrue(payload["partial"], payload) + self.assertEqual(payload["code"], "DEADLINE_EXCEEDED") + briefing = payload.get("briefing") or {} + self.assertIsInstance(briefing, dict) + self.assertFalse(briefing.get("available")) + self.assertNotEqual(payload.get("commands"), None) + self.assertFalse(payload.get("mutation_available")) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_hub.py b/tests/test_hub.py index b7292c1..38ce118 100644 --- a/tests/test_hub.py +++ b/tests/test_hub.py @@ -1773,6 +1773,77 @@ def _advertised_next(self, argv: list[str]) -> tuple[dict[str, object], list[str ] return payload, advertised + def _default_a_bootstrap_fail_b(self) -> Path: + """A is registry default; B (self.root) is Demo with a bootstrap-able FAIL. + + Unique fold of Demo is registered at A, so next/briefing must advertise + ``--root bootstrap --yes``. Unscoped ``dyro bootstrap --yes`` from a + non-workspace cwd resolves to A, not B. + """ + self._profile_named(self.root, "Demo", remote=True) + default_a = self.root.parent / f"{self.root.name}-default-a" + default_a.mkdir() + default_a.joinpath("dyro.toml").write_text( + (self.root / "dyro.toml") + .read_text(encoding="utf-8") + .replace('name = "Demo"', 'name = "alpha"'), + encoding="utf-8", + ) + (default_a / "repositories/api").mkdir(parents=True) + add_workspace(default_a, name="demo", make_default=True) + self.anchor.rename(self.root / "api-missing") + return default_a + + def test_root_start_does_not_advertise_unscoped_bootstrap_that_resolves_to_a( + self, + ) -> None: + default_a = self._default_a_bootstrap_fail_b() + current_root = str(self.root.resolve()) + default_root = str(default_a.resolve()) + unrelated = self.root.parent / f"{self.root.name}-unrelated" + unrelated.mkdir() + previous = Path.cwd() + try: + os.chdir(unrelated) + next_payload, next_ads = self._advertised_next( + ["--root", str(self.root), "next", "--format", "json"] + ) + start_out = StringIO() + start_err = StringIO() + with ( + redirect_stdout(start_out), + redirect_stderr(start_err), + self.assertRaises(SystemExit) as raised, + ): + main(["--root", str(self.root), "--dry-run", "start"]) + finally: + os.chdir(previous) + self.assertEqual(next_payload["kind"], "next_step") + self.assertEqual(next_payload["state"], "needs_repair") + self.assertTrue( + any( + "bootstrap --yes" in item and "--root" in item and current_root in item + for item in next_ads + ), + next_ads, + ) + self.assertEqual(raised.exception.code, 2) + advertised = start_out.getvalue() + start_err.getvalue() + self.assertIn("尚未就绪", advertised) + self.assertNotIn("dyro bootstrap --yes", advertised) + self.assertNotIn(default_root, advertised) + bootstrap_lines = [ + line + for line in advertised.splitlines() + if "bootstrap" in line + ] + self.assertTrue(bootstrap_lines, advertised) + for line in bootstrap_lines: + self.assertIn("--root", line) + self.assertIn(current_root, line) + self.assertNotIn("--workspace demo", line) + self.assertNotIn("--workspace Demo", line) + def test_implicit_and_root_next_do_not_advertise_other_root_fold_match( self, ) -> None: