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
38 changes: 33 additions & 5 deletions src/agent_comms/child_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -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; "
Expand All @@ -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",)
Expand Down
18 changes: 8 additions & 10 deletions src/agent_comms/goals.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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)
Expand All @@ -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)


Expand Down
47 changes: 34 additions & 13 deletions src/agent_comms/todos.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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"):
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion tests/test_commander_problem_orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
])
Expand Down
28 changes: 28 additions & 0 deletions tests/test_order_progress.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading