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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,29 @@

## Unreleased

## 0.7.15 - 2026-09-12

对使用者的影响:`dyro dispatch` 的异步运行不再因为并发读取运行状态而
偶发失败。

- `dyro dispatch` runs no longer fail intermittently with exit code 2 and
`run state path changed while opening: run-<id>`. `RunStore.load` opened
the run-state file, then re-checked that the path still named the same
inode. Updates are published with `os.replace`, and reads are not taken
under the write lock, so an async worker publishing a legitimate update
between the reader's `open` and its re-check failed that check exactly
the way a swapped path does. The read now re-opens a bounded number of
times; a path that is still changing after every attempt is refused as
before. No safety property was relaxed: `O_NOFOLLOW` applies on every
attempt, `fstat` still enforces regular-file and size limits, symlinks
are still refused, and reads still come only from the opened descriptor.
A file swapped for a symlink now reports the more precise `run state is
a symbolic link`.
- `dyro` no longer imports the console subsystem at startup. `cli.py` pulled
in `console.launcher` (and inspection/events/overview behind it) at module
scope although only `dyro console` uses it; it is now imported inside that
command, as `home.py` already did. Worth about 20ms of a ~250ms import.

## 0.7.14 - 2026-09-11

对使用者的影响:`dyro doctor`、首页菜单、`dyro next` / `dyro status` 以及
Expand Down
111 changes: 66 additions & 45 deletions experiments/local_agent_dispatch/run_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@
MAX_CANCEL_REASON_CHARS = 500
MAX_ORCHESTRATION_ID_CHARS = 256
MAX_RUN_STATE_BYTES = 2 * 1024 * 1024
# Bounded re-opens for a run-state file that an atomic writer replaced
# between our open and the inode re-check.
RUN_STATE_REOPEN_ATTEMPTS = 5
_POSIX_PROCESS_GROUPS = (
os.name == "posix"
and hasattr(os, "getpgid")
Expand Down Expand Up @@ -309,6 +312,61 @@ def from_mapping(cls, payload: Mapping[str, Any]) -> RunRecord:
)


def _read_run_state_bytes(path: Path) -> bytes | None:
"""Read one run-state file, or None when the path was replaced mid-read.

``O_NOFOLLOW`` plus the ``fstat`` checks below keep the symlink and
file-type guarantees on every attempt. Only the "still the same inode we
opened" check is retryable: ``atomic_write_json`` publishes updates with
``os.replace``, so a healthy concurrent writer fails that check exactly
the way a swapped path does, and refusing it would fail a live run.
"""
flags = (
os.O_RDONLY
| getattr(os, "O_CLOEXEC", 0)
| getattr(os, "O_NONBLOCK", 0)
)
flags |= getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(path, flags)
except FileNotFoundError as exc:
raise DispatchValidationError(
f"run not found: {path.stem}"
) from exc
except OSError as exc:
if exc.errno in {errno.ELOOP, errno.EMLINK} or path.is_symlink():
raise DispatchValidationError(
f"run state is a symbolic link: {path.stem}"
) from exc
raise DispatchValidationError(
f"run state cannot be opened safely: {path.stem}"
) from exc
try:
opened = os.fstat(descriptor)
if not stat.S_ISREG(opened.st_mode):
raise DispatchValidationError(
f"run state is not a regular file: {path.stem}"
)
if opened.st_size > MAX_RUN_STATE_BYTES:
raise DispatchValidationError(
f"run state exceeds {MAX_RUN_STATE_BYTES} bytes"
)
linked = os.stat(path, follow_symlinks=False)
if stat.S_ISLNK(linked.st_mode) or not os.path.samestat(opened, linked):
return None
with os.fdopen(descriptor, "rb", closefd=False) as handle:
raw = handle.read(MAX_RUN_STATE_BYTES + 1)
if len(raw) > MAX_RUN_STATE_BYTES:
raise DispatchValidationError(
f"run state exceeds {MAX_RUN_STATE_BYTES} bytes"
)
except FileNotFoundError:
return None
finally:
os.close(descriptor)
return raw


class RunStore:
def __init__(self, home: Path | None = None, *, create: bool = True) -> None:
self.home = home
Expand Down Expand Up @@ -543,53 +601,16 @@ def _save_unlocked(self, record: RunRecord) -> None:
atomic_write_json(self._path(record.run_id), payload)

def _read_payload(self, path: Path) -> dict[str, Any]:
flags = (
os.O_RDONLY
| getattr(os, "O_CLOEXEC", 0)
| getattr(os, "O_NONBLOCK", 0)
)
flags |= getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(path, flags)
except FileNotFoundError as exc:
raise DispatchValidationError(
f"run not found: {path.stem}"
) from exc
except OSError as exc:
if exc.errno in {errno.ELOOP, errno.EMLINK} or path.is_symlink():
raise DispatchValidationError(
f"run state is a symbolic link: {path.stem}"
) from exc
raise DispatchValidationError(
f"run state cannot be opened safely: {path.stem}"
) from exc
try:
opened = os.fstat(descriptor)
if not stat.S_ISREG(opened.st_mode):
raise DispatchValidationError(
f"run state is not a regular file: {path.stem}"
)
if opened.st_size > MAX_RUN_STATE_BYTES:
raise DispatchValidationError(
f"run state exceeds {MAX_RUN_STATE_BYTES} bytes"
)
linked = os.stat(path, follow_symlinks=False)
if stat.S_ISLNK(linked.st_mode) or not os.path.samestat(opened, linked):
raise DispatchValidationError(
f"run state path changed while opening: {path.stem}"
)
with os.fdopen(descriptor, "rb", closefd=False) as handle:
raw = handle.read(MAX_RUN_STATE_BYTES + 1)
if len(raw) > MAX_RUN_STATE_BYTES:
raise DispatchValidationError(
f"run state exceeds {MAX_RUN_STATE_BYTES} bytes"
)
except FileNotFoundError as exc:
for _ in range(RUN_STATE_REOPEN_ATTEMPTS):
raw = _read_run_state_bytes(path)
if raw is not None:
break
else:
# A path that is still changing after every attempt is not a
# writer publishing an update; refuse it as this guard always has.
raise DispatchValidationError(
f"run state path changed while opening: {path.stem}"
) from exc
finally:
os.close(descriptor)
)
try:
payload = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "dyro"
version = "0.7.14"
version = "0.7.15"
description = "DyroEngineeringFlow: local-first automation and delivery control for multi-repository teams"
readme = "README.md"
requires-python = ">=3.11"
Expand Down
2 changes: 1 addition & 1 deletion src/dyro/bridge/skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.14"},
"client": {"name": "dyro-agent-bridge-skill", "version": "0.7.15"},
"operation": "bridge.capabilities.compact",
"input": {}
}
Expand Down
6 changes: 5 additions & 1 deletion src/dyro/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@
verify_changeset,
)
from .config import CONFIG_NAME, Config, load, load_profile_exact, push_disclosure, push_policy_fields, validate_id
from .console.launcher import launch_console, render_console_plan
from .continuation.attention import (
build_attention_projection,
render_attention_json,
Expand Down Expand Up @@ -1966,6 +1965,11 @@ def cmd_home(args: argparse.Namespace) -> None:


def cmd_console(args: argparse.Namespace) -> None:
# Imported here, not at module scope: the console subsystem pulls in
# inspection/events/overview and costs ~80ms of every `dyro` start that
# never opens a console. `home.py` already imports it this way.
from .console.launcher import launch_console, render_console_plan

initial_workspace = getattr(args, "workspace_alias", None)
root_arg = getattr(args, "root", None)
target_root: Path | None = None
Expand Down
69 changes: 69 additions & 0 deletions tests/test_adversarial_remediation_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
from experiments.local_agent_dispatch.result_envelope import build_result
from experiments.local_agent_dispatch.run_store import (
ASYNC_RESERVATION_GRACE_SECONDS,
RUN_STATE_REOPEN_ATTEMPTS,
RunRecord,
)
import experiments.local_agent_dispatch.lease as lease_module
Expand Down Expand Up @@ -2614,6 +2615,74 @@ def test_output_limit_terminates_backend(self) -> None:
self.assertTrue(completed.output_limited)
self.assertLessEqual(len(completed.stdout.encode("utf-8")), 4096)

def _accepted_run(self, root: Path):
project = root / "project"
project.mkdir()
(project / "app.py").write_text("safe = True\n", encoding="utf-8")
home = root / "home"
payload = _payload()
payload["backend"] = "echo"
payload["allow_offline_simulation"] = True
record = DispatchSupervisor(home=home).accept(payload, project_root=project)
store = supervisor_module.RunStore(home)
return store, record.run_id, store._path(record.run_id)

def _replace_state_during_read(self, path: Path, *, every_time: bool):
"""Publish an atomic update inside the reader's open/re-check window."""
real_fstat = os.fstat
replacements: list[int] = []

def fstat_then_replace(descriptor):
result = real_fstat(descriptor)
if every_time or not replacements:
replacements.append(1)
current = json.loads(path.read_text(encoding="utf-8"))
current["status"] = "running"
atomic_write_json(path, current)
return result

return fstat_then_replace, replacements

def test_run_state_read_survives_a_concurrent_atomic_replace(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
store, run_id, path = self._accepted_run(Path(tmp))
replace, replacements = self._replace_state_during_read(
path, every_time=False
)

with patch(
"experiments.local_agent_dispatch.run_store.os.fstat",
side_effect=replace,
):
reloaded = store.load(run_id)

self.assertTrue(replacements, "the atomic replace never ran")
self.assertEqual(reloaded.status, "running")

def test_run_state_read_still_refuses_a_path_that_keeps_changing(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
store, run_id, path = self._accepted_run(Path(tmp))
replace, replacements = self._replace_state_during_read(
path, every_time=True
)

with (
patch(
"experiments.local_agent_dispatch.run_store.os.fstat",
side_effect=replace,
),
self.assertRaisesRegex(
DispatchValidationError,
"run state path changed while opening",
),
):
store.load(run_id)

self.assertEqual(
len(replacements),
RUN_STATE_REOPEN_ATTEMPTS,
)

def test_default_cli_run_starts_async_worker(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
Expand Down
4 changes: 2 additions & 2 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2270,6 +2270,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.14")
self.assertEqual(__version__, "0.7.14")
self.assertEqual(metadata["project"]["version"], "0.7.15")
self.assertEqual(__version__, "0.7.15")
self.assertEqual(__version__, metadata["project"]["version"])
4 changes: 2 additions & 2 deletions tests/test_console_artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_14(self) -> None:
def test_package_version_is_0_7_15(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.14")
self.assertEqual(metadata["project"]["version"], "0.7.15")


class ConsoleArtifactServiceTests(WorkspaceCase):
Expand Down
2 changes: 1 addition & 1 deletion tests/test_hub.py
Original file line number Diff line number Diff line change
Expand Up @@ -2001,7 +2001,7 @@ def test_console_unique_fold_plan_and_apply_share_canonical_alias(self) -> None:
main(["--dry-run", "--workspace", "demo", "console"])
self.assertIn("初始焦点:Demo", output.getvalue())
self.assertNotIn("初始焦点:demo", output.getvalue())
with patch("dyro.cli.launch_console") as launch:
with patch("dyro.console.launcher.launch_console") as launch:
main(["--workspace", "demo", "console"])
launch.assert_called_once()
self.assertEqual(launch.call_args.kwargs["initial_workspace"], "Demo")
Expand Down
2 changes: 1 addition & 1 deletion tests/test_release_gates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.14"])
code = main(["--root", str(ROOT), "--release-tag", "v0.7.15"])
self.assertEqual(code, 0)
self.assertIn("0.7 gates present", stdout.getvalue())
self.assertNotIn("1.0 gates present", stdout.getvalue())
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading