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
12 changes: 7 additions & 5 deletions src/agent_comms/coordination_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,13 @@ class DrainDeferred(Exception): # noqa: N818 - capability, not an outcome


class CommandFailure(Exception): # noqa: N818 - capability, not an outcome
"""A failure a command reports to its caller as a failed result instead of crashing."""
"""A failure a command reports to its caller as a failed result instead of crashing.

It crosses a worker's pipe as its class, args and state, never through a member's own
``__init__``, whose parameters are not its args, and keeps its original cause."""

def __reduce__(self):
return __newobj__, (type(self), *self.args), self.__dict__ | {"__cause__": self.__cause__}


class DrainUnavailable(DrainDeferred):
Expand All @@ -22,10 +28,6 @@ class DrainUnavailable(DrainDeferred):
class CoordinationError(RuntimeError, DrainUnavailable, CommandFailure):
"""Base class for fail-loud coordinator errors."""

def __reduce__(self):
"""Keep the original cause when an observation sends this error over its pipe."""
return __newobj__, (type(self), *self.args), self.__dict__ | {"__cause__": self.__cause__}


class DeferredRead(Exception): # noqa: N818 - capability, not an outcome
"""A read refused only for now, for currentness or contention; retrying may answer it."""
Expand Down
43 changes: 43 additions & 0 deletions tests/test_command_failure_pickles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""Every command failure crosses a worker's pipe whole, whatever its constructor takes."""

import importlib
import pickle
import pkgutil
import subprocess

import pytest

import agent_comms
from agent_comms.coordination_errors import CommandFailure
from agent_comms.repo import GitCommandError


def members(family: type) -> set[type]:
return {member for child in family.__subclasses__() for member in {child, *members(child)}}


def declared() -> list[type[CommandFailure]]:
"""The family once every module declaring a member is imported."""
for module in pkgutil.walk_packages(agent_comms.__path__, f"{agent_comms.__name__}."):
if ".skills." not in module.name:
importlib.import_module(module.name)
return sorted(members(CommandFailure), key=lambda member: member.__qualname__)


@pytest.mark.parametrize("member", declared(), ids=lambda member: member.__qualname__)
def test_each_member_unpickles_with_its_message_state_and_cause(member) -> None:
failure = member.__new__(member, "git exited 128")
failure.detail = "kept"
failure.__cause__ = ValueError("root")
copy = pickle.loads(pickle.dumps(failure))
assert type(copy) is member
assert copy.args == ("git exited 128",)
assert copy.detail == "kept"
assert copy.__cause__.args == ("root",)


def test_a_raised_git_failure_keeps_its_git_message() -> None:
run = subprocess.CompletedProcess(("git",), 128, b"", b"fatal: bad object\n")
copy = pickle.loads(pickle.dumps(GitCommandError("/repo", run)))
assert str(copy) == "Git in /repo: fatal: bad object"
assert (copy.returncode, copy.stderr) == (128, "fatal: bad object\n")
Loading