SIGN IN SIGN UP

feat(relay): derive the two timestamps a client needs to triage a herd

herdr reports neither, so the relay owns both, per pane and on every `agents`
and `panes` entry: last_active_at (the last status transition observed) and
last_seen_at (the last time a client opened or drove the pane through here), in
epoch ms because every client that will compare them is JavaScript.

They answer the one thing a status cannot -- did this finish while I wasn't
looking -- and they answer it as a comparison rather than a stored flag, so
opening the pane clears it with no bookkeeping on either side.

Ported from Collie's ActivityLedger (bridge/activity.ts), including the rules
that are not obvious until they bite:

- A first sighting seeds active_at == seen_at, so a fresh client never opens on a
  wall of alerts for work already handled at the desk. Only transitions observed
  after the relay first saw a pane may mark it unread -- the same rule the
  blocked-push path already follows.
- The ledger keeps its own status memory rather than reading last_statuses, which
  the blocked-push logic owns and updates on its own schedule.
- SEEN_ON is one chokepoint ahead of every handler, so a new handler cannot
  forget. `focus` is deliberately not in it: it moves herdr's cursor at the desk
  without the client reading anything.
- Keyed by (host, pane_id), unlike the other pane maps -- every herdr numbers its
  own panes and this is the one that reaches disk, where a collision would stick.
- Forgetting rides the existing stale sweep instead of a second reconcile policy
  beside it. That sweep already decides when a caller's picture is complete, and
  it covers shell panes, which a removal event derived from an agent status map
  never would.
- Debounced 10s: an open pane's 3s mirror tick marks it seen every tick, free in
  memory and one write per tick forever on disk. Temp file plus rename, since a
  half file parses as nothing and silently costs everyone's unread column. Every
  field re-validated on load, including that True is not a timestamp -- it is an
  int in python and would sort a pane unread for good.

Verified live: 30 panes stamped, nothing falsely unread on first connect,
read_pane advanced seen_at within one poll, and activity.json holds 30 entries
with the one pane I opened showing seen_at > active_at.

Also fixes the utcnow() DeprecationWarning in audit(), which the new per-module
tests turned from two journal lines per restart into a wall of test output. Same
wire format -- `Z`, not `+00:00` -- and datetime.timezone.utc rather than
datetime.UTC, which needs 3.11 against this file's requires-python of 3.10.
N
Nick007 committed
eba2a2a3dbb3c0c53d3f056a1b11e556faecd632
Parent: b0fa261