diff --git a/tests/tools/test_bot_relay.py b/tests/tools/test_bot_relay.py index 4758defd63..0c584d0803 100644 --- a/tests/tools/test_bot_relay.py +++ b/tests/tools/test_bot_relay.py @@ -528,6 +528,33 @@ def test_drain_expires_old_envelope_with_queued_expired_reply(root): assert not reply["reply"] +def test_the_outbox_is_claimed_oldest_first(root): + """Two DMs from one sender to one agent must arrive in the order they were sent. The Desktop + delivers each target's claimed envelopes in the order this list gives them, one turn at a + time, so the claim IS the delivery order — and sorting by filename ordered them by + ``uuid4().hex``. The names here are forced into the reverse of the send order to pin that + deterministically, which random ids reproduce half the time.""" + first = bot_relay.enqueue_envelope( + root, target=_target(), message="do this first", + sender_profile="default", sender_handle="hermes", + ) + second = bot_relay.enqueue_envelope( + root, target=_target(), message="then this", + sender_profile="default", sender_handle="hermes", + ) + outbox = bot_relay.relay_root(root) / bot_relay.OUTBOX_DIR + now = _time2.time() + for env, name, sent_at in ((first, "f" * 32, now - 2), (second, "0" * 32, now - 1)): + path = outbox / f"{name}.json" + (outbox / f"{env['id']}.json").rename(path) + _os2.utime(path, (sent_at, sent_at)) + + claimed = bot_relay.claim_pending_envelopes(root) + + assert [e["id"] for e in claimed] == [first["id"], second["id"]] + assert [e["message"] for e in claimed] == ["do this first", "then this"] + + def test_drain_delivers_fresh_envelope_under_ttl(root): env = bot_relay.enqueue_envelope( root, target=_target(), message="on time", diff --git a/tools/bot_relay.py b/tools/bot_relay.py index e65cdcdb5b..f0a2f343c1 100644 --- a/tools/bot_relay.py +++ b/tools/bot_relay.py @@ -242,6 +242,15 @@ def _expire_if_stale(root: Path | str, path: Path, ttl: float, now: float) -> bo return True +def _queued_at(path: Path) -> tuple[float, str]: + """Claim order for one outbox entry: oldest first. ``mtime`` is what ``_sweep_stale`` already + treats as an envelope's age, and unlike the whole-second ``created_at`` field it separates two + DMs sent in the same second. The name only breaks ties.""" + with contextlib.suppress(OSError): + return (path.stat().st_mtime, path.name) + return (0.0, path.name) + + def claim_pending_envelopes(root: Path | str) -> list[dict]: """Drain the outbox (rename → claimed/ so a second drain can't double-deliver). TTL-expired envelopes get a 'queued_expired' reply and are removed instead. @@ -255,7 +264,10 @@ def claim_pending_envelopes(root: Path | str) -> list[dict]: ttl = _envelope_ttl_seconds() now = time.time() out: list[dict] = [] - for path in sorted((base / OUTBOX_DIR).glob("*.json")): + # Oldest first: the Desktop delivers each target's claimed envelopes in the order this list + # gives them, so a sender's two DMs to one agent arrive in the order they were sent. Sorting + # by filename ordered them by ``uuid4().hex`` — at random. + for path in sorted((base / OUTBOX_DIR).glob("*.json"), key=_queued_at): if ttl > 0 and _expire_if_stale(root, path, ttl, now): with contextlib.suppress(OSError): path.unlink()