Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <current>`). 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/<skill>`,
`~/.hermes/skills/<skill>`). Detection stays fail-closed: absent default
Expand Down
32 changes: 26 additions & 6 deletions src/dyro/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
render_briefing_text,
render_human_attention,
render_human_wave,
unread_briefing,
)
from .continuation.next_step import (
bootstrap_repair_applicable,
Expand Down Expand Up @@ -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 ""
Expand Down Expand Up @@ -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",
Expand All @@ -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 打开本机已安装的编码工具。")
Expand Down
15 changes: 12 additions & 3 deletions src/dyro/continuation/ready_briefing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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 (
Expand Down
2 changes: 1 addition & 1 deletion src/dyro/integrations/assets/dyro-control-plane/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ When the request already supplies a workspace alias, skip global discovery and u
- Objective next-wave preview: `dyro --workspace <alias> objective tick <id> --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 <alias> objective plan <id> --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.

Expand Down
36 changes: 35 additions & 1 deletion tests/test_control_plane_read_budget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
71 changes: 71 additions & 0 deletions tests/test_hub.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <B> 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:
Expand Down