diff --git a/src/agent_comms/child_process.py b/src/agent_comms/child_process.py index c327025401..f12253f903 100644 --- a/src/agent_comms/child_process.py +++ b/src/agent_comms/child_process.py @@ -1024,15 +1024,34 @@ def decoded(stdout: bytes) -> CheckPrinted: def unproven(self, command: str, expected: int) -> tuple[str, ...]: ... @abstractmethod - def distance(self, expected: int) -> int: ... + def distance(self, expected: int) -> CheckPrinted: ... + + @abstractmethod + def plus(self, amount: int) -> CheckPrinted: ... + + @abstractmethod + def require(self, expected: object) -> None: ... + + @abstractmethod + def project[T](self, counted: Callable[[int], T], uncounted: Callable[[], T]) -> T: ... @dataclass(frozen=True) class CountPrinted(CheckPrinted): count: int - def distance(self, expected: int) -> int: - return abs(self.count - expected) + def distance(self, expected: int) -> CheckPrinted: + return CountPrinted(abs(self.count - expected)) + + def plus(self, amount: int) -> CheckPrinted: + return CountPrinted(self.count + amount) + + def require(self, expected: object) -> None: + if self.count: + raise ValueError(f"The order check is {self.count} away from {expected}.") + + def project[T](self, counted: Callable[[int], T], uncounted: Callable[[], T]) -> T: + return counted(self.count) def unproven(self, command: str, expected: int) -> tuple[str, ...]: return ((f"Check already prints {self.count} before any work: `{command}` proves nothing; " @@ -1041,8 +1060,17 @@ def unproven(self, command: str, expected: int) -> tuple[str, ...]: @dataclass(frozen=True) class NoIntegerPrinted(CheckPrinted): - def distance(self, expected: int) -> int: - raise ValueError(f"The order check printed no integer, so no count of {expected}.") + def distance(self, expected: int) -> CheckPrinted: + return self + + def plus(self, amount: int) -> CheckPrinted: + return self + + def require(self, expected: object) -> None: + raise ValueError(f"The order check cannot prove {expected}: it printed no integer.") + + def project[T](self, counted: Callable[[int], T], uncounted: Callable[[], T]) -> T: + return uncounted() def unproven(self, command: str, expected: int) -> tuple[str, ...]: return ("the check printed no integer; use grep -c PATTERN FILE or ... | wc -l",) diff --git a/src/agent_comms/goals.py b/src/agent_comms/goals.py index 05055b3114..85653a9dcc 100644 --- a/src/agent_comms/goals.py +++ b/src/agent_comms/goals.py @@ -15,8 +15,8 @@ from pathlib import Path from typing import TYPE_CHECKING, ClassVar, Literal -from .child_process import (BoundedRun, ChildOutcome, ChildResult, ExitedOutcome, SignaledOutcome, - TimedOutOutcome) +from .child_process import (BoundedRun, CheckPrinted, ChildOutcome, ChildResult, CountPrinted, + ExitedOutcome, SignaledOutcome, TimedOutOutcome) from .declared_family import DeclaredFamily from .errors import RelationViolationError from .field_codec import ( @@ -230,19 +230,17 @@ def require_identity(self, goal_id: str) -> None: class CheckResult(DeclaredFamily, affix="CheckResult"): def require(self, result: ChildResult) -> None: - distance = self.distance(result) - if distance: - raise ValueError(f"The order check is {distance} away from {self}.") + self.distance(result).require(self) @abstractmethod - def distance(self, result: ChildResult) -> int: - """How far this run is from the result the order requires; zero when it is met.""" + def distance(self, result: ChildResult) -> CheckPrinted: + """The measured distance, or the run's declared missing-count state.""" @dataclass(frozen=True) class SilentCheckResult(CheckResult): - def distance(self, result: ChildResult) -> int: - return int(result.outcome != ExitedOutcome(0) or bool(result.stdout)) + def distance(self, result: ChildResult) -> CheckPrinted: + return CountPrinted(int(result.outcome != ExitedOutcome(0) or bool(result.stdout))) @dataclass(frozen=True) @@ -253,7 +251,7 @@ def __post_init__(self): if self.expected < 0: raise ValueError("An expected count must be nonnegative.") - def distance(self, result: ChildResult) -> int: + def distance(self, result: ChildResult) -> CheckPrinted: return result.outcome.printed_count(result.stdout).distance(self.expected) diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index a5edb83dec..b31cf455b1 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -25,7 +25,7 @@ from . import skills from .change_report import FileLine -from .child_process import BoundedRun, ChildResult, ExitedOutcome +from .child_process import BoundedRun, CheckPrinted, ChildResult, ExitedOutcome from .command import Command from .declared_family import DeclaredFamily from .errors import ToolRefusedError @@ -261,7 +261,8 @@ def _clock(at: float) -> str: @dataclass(frozen=True) class DistanceReading: at: float - distance: int + distance: CheckPrinted + command: str = "" @dataclass(frozen=True) @@ -272,15 +273,25 @@ class DistanceReadings: @property def trend(self) -> DistanceTrend: - """Velocity across the two readings: falling projects the finish, else it is flat.""" + """A missing count diagnoses the check; only measured counts project a finish.""" + if not self.readings: + return UnreadTrend() + return self.readings[-1].distance.project( + self.counted_trend, lambda: UncountedTrend(self.readings[-1].command)) + + def counted_trend(self, final: int) -> DistanceTrend: last = self.readings[-2:] - if len(last) < 2 or not last[1].distance: + if len(last) < 2 or not final: return UnreadTrend() - first, final = last - if final.distance >= first.distance: - return FlatTrend(final.distance, final.at) - rate = (first.distance - final.distance) / max(final.at - first.at, 1.0) - return MovingTrend(final.distance, final.at + final.distance / rate) + first, latest = last + + def compared(previous: int) -> DistanceTrend: + if final >= previous: + return FlatTrend(final, latest.at) + rate = (previous - final) / max(latest.at - first.at, 1.0) + return MovingTrend(final, latest.at + final / rate) + + return first.distance.project(compared, UnreadTrend) class DistanceTrend(DeclaredFamily, affix="Trend"): @@ -313,6 +324,15 @@ def projection(self) -> str: return f"; distance {self.distance}, not falling" +@dataclass(frozen=True) +class UncountedTrend(DistanceTrend): + command: str + + @property + def projection(self) -> str: + return f"; check prints no integer: {self.command}" + + @dataclass(frozen=True) class UnreadTrend(DistanceTrend): """Fewer than two readings, or the last one at done: nothing to project from.""" @@ -369,7 +389,7 @@ def readings(self, source: ProgressSource) -> DistanceReadings: "--not", base).splitlines()))] points = [(base, source.since), *commits][-2:] return DistanceReadings( - tuple(DistanceReading(at, source.distance(self, base, sha)) for sha, at in points)) + tuple(source.reading(self, base, sha, at) for sha, at in points)) @dataclass(frozen=True) @@ -400,12 +420,13 @@ def commits(self, *excluded: str) -> Iterator[tuple[str, float]]: def readings(self) -> DistanceReadings: return self.checkout.readings(self) - def distance(self, checkout: PresentCheckout, base: str, sha: str) -> int: - """Run the order's check and count its untouched edits at a present revision.""" + def reading(self, checkout: PresentCheckout, base: str, sha: str, at: float) -> DistanceReading: + """Keep the check command with its measured or missing distance.""" check = self.todo.order.check touched = checkout.git("diff", "--name-only", base, sha).split() run = check.at(sha, str(checkout.path)) - return check.result.distance(run) + self.todo.order.untouched(touched) + return DistanceReading(at, check.result.distance(run).plus( + self.todo.order.untouched(touched)), check.command) @dataclass(frozen=True) diff --git a/tests/test_commander_problem_orders.py b/tests/test_commander_problem_orders.py index 8fd8cd8a24..f98a2d39f4 100644 --- a/tests/test_commander_problem_orders.py +++ b/tests/test_commander_problem_orders.py @@ -659,7 +659,7 @@ def test_commander_refuses_a_check_that_prints_no_count(commander_script): @pytest.mark.parametrize('printed, refused', [ - ("printf 'no count here'", 'The order check printed no integer, so no count of 1.'), + ("printf 'no count here'", 'The order check cannot prove CountCheckResult(expected=1): it printed no integer.'), ('echo 2', 'The order check is 1 away from CountCheckResult(expected=1).'), ('echo found 1', None), ]) diff --git a/tests/test_order_progress.py b/tests/test_order_progress.py index 7d8041a799..b676797076 100644 --- a/tests/test_order_progress.py +++ b/tests/test_order_progress.py @@ -66,6 +66,34 @@ def test_no_commit_since_the_order_is_explicit(checkout): assert progress.line == 'no commit since the order' +def test_uncounted_check_keeps_other_overdue_orders_visible(commander, commander_script, checkout, monkeypatch): + from agent_comms.child_process import CountPrinted, NoIntegerPrinted + from agent_comms.todos import DistanceReading, UncountedTrend + + script, _ = commander_script + todo, unit = checkout + comms, _ = commander + monkeypatch.setattr(script.COMMS.registry, 'storage_rule', StorageRule(checkout_base=unit.parent)) + monkeypatch.setattr(PullRequests, 'read', lambda *args: ()) + broken = replace(todo.order, check=replace(todo.order.check, command="printf 'no count here'")) + with comms.goals.todos.writing() as db: + replace(todo, order=broken).upsert(db) + create(commander, 'P2', deadline=1) + commit(unit) + + progress = read((comms.goals.todos.get('P1'), unit)) + assert progress.line.endswith("; check prints no integer: printf 'no count here'") + lines = deadline_lines(script) + alarm = next(line for line in lines if line.startswith('OVERDUE:')) + assert "order order-P1" in alarm and "order order-P2" in alarm + assert "check prints no integer: printf 'no count here'" in alarm + assert 'distance 0' not in alarm and 'projected done' not in alarm + missing = NoIntegerPrinted() + assert missing.distance(1) is missing and missing.plus(3) is missing + assert DistanceReadings((DistanceReading(1, missing, 'printf missing'),)).trend == UncountedTrend('printf missing') + assert DistanceReadings((DistanceReading(1, missing), DistanceReading(2, CountPrinted(2)))).trend.projection == '' + + def test_an_unpushed_commit_names_its_sha_and_time(checkout): sha = commit(checkout[1]) progress = read(checkout)