Skip to content

fix: keep what Kelpie knows across a subscription rebuild - #8

Merged
snaka merged 2 commits into
mainfrom
fix/notification-across-rebuild
Sep 12, 2026
Merged

snaka merged 2 commits into
mainfrom
fix/notification-across-rebuild

Conversation

@snaka

@snaka snaka commented Sep 12, 2026

Copy link
Copy Markdown
Owner

Closes the last behavioural item in ROADMAP.md, and it turned out to be
worse than that entry described.

The bug

A changed pane set costs a whole new event connection — herdr delivers agent
status changes only through per-pane subscriptions, and a subscription list is
fixed for a connection's lifetime. That rebuild went through the ordinary
reconnect path, which clears SessionState and applies the next snapshot as
.bootstrap, the phase whose entire job is keeping a launch silent.

ROADMAP described the loss as "a pane that turned blocked inside that ~1 s
window never notifies". The larger loss is the pending reminders: because a
bootstrap snapshot arms nothing, a pane that was already blocked stopped
being reminded about permanently
. Starting or ending any agent session was
enough to trigger it, and the symptom is silence, so it is not the kind of bug
you notice.

The fix

ConnectionRestart names the distinction the connection loop was missing.
.fresh is a first sighting of herdr: bootstrap, start from nothing.
.continuation replaces a connection Kelpie closed itself, so it applies
.live and carries SessionState, the reminders and the blocked emphasis
forward. Timers are torn down either way — the next snapshot reschedules all
of them.

If the reconnect after a rebuild fails, rebuildRequested is already cleared,
so it falls into the normal path and everything resets as before.

Verification

Beyond swift test (136 tests), the fix was measured against a live herdr by
running the fixed build and the released 0.1.11 side by side on the same
events
, with a probe agent driven through pane.report_agent:

fixed 0.1.11
pane enters blocked notifying 1 pane(s) entering blocked (no such log line in 0.1.11)
rebuild forced 12 s later pane set changed; rebuilding pane set changed; rebuilding
60 s after blocking reminding 1 blocked pane(s) nothing

The notify breadcrumb added here is what made that comparison readable;
fireDueReminders already had its counterpart.

The AppCoordinator wiring itself has no automated coverage — that file has
none — so the rules live in ConnectionRestart where swift test can hold
them.

Doc corrections

The second commit fixes what live testing falsified. agents[] does not carry
plain shells, so isAgentPane is currently filtering nothing (the guard stays;
herdr's schema declares agent optional). A snapshot request in flight fails
on essentially every rebuild rather than being the replay-burst one-off
SnapshotRetry was written for. And two new protocol facts go to CLAUDE.md:
an agent appearing in a pane fires no event Kelpie subscribes to, and a second
events.subscribe on an open connection makes herdr close it.

🤖 Generated with Claude Code

snaka and others added 2 commits September 12, 2026 16:53
herdr only delivers agent status changes through per-pane subscriptions,
and a subscription list is fixed for a connection's lifetime, so a
changed pane set costs a whole new event connection. That rebuild went
through the ordinary reconnect path, which clears `SessionState` and
applies the next snapshot as `.bootstrap` — the phase that exists to keep
a launch silent.

Two things were lost with it. A pane that turned `blocked` inside the
~400 ms window never notified, which is what ROADMAP described. Less
obviously, the pending reminders went too: a pane that was already
blocked stopped being reminded about for good, because a bootstrap
snapshot arms nothing. Starting or ending any agent session was enough
to trigger it.

`ConnectionRestart` names the distinction the loop was missing. A
`.fresh` connection is a first sighting of herdr and bootstraps; a
`.continuation` replaces a connection Kelpie closed itself, so it applies
`.live` and carries `SessionState`, the reminders and the blocked
emphasis forward. Timers are still torn down either way — the next
snapshot reschedules all of them.

Verified against a live herdr by running the fixed build and 0.1.11 side
by side on the same events: with a pane blocked and a rebuild forced
between the banner and the one-minute mark, the fixed build logged
`reminding 1 blocked pane(s)` and 0.1.11 logged nothing.

Also adds the notify breadcrumb that made that comparison possible;
`fireDueReminders` already had its counterpart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Verifying the rebuild fix against herdr 0.8.2 contradicted three things
the repository asserted or assumed.

`session.snapshot`'s `agents[]` does not carry plain shells: a pane split
off with no agent in it never appears, so `isAgentPane` is currently
filtering nothing. The guard stays — herdr's own schema declares `agent`
optional, and that is the only promise Kelpie has — but the comments now
say which is fact and which is defence.

A snapshot request in flight fails on essentially every subscription
rebuild, about ten milliseconds after the event connection closes, and
succeeds on the first retry. `SnapshotRetry` was written believing the
failure was a one-off from a replay burst.

Two further facts are new rather than wrong, and go to CLAUDE.md: an
agent appearing in a pane fires no event Kelpie subscribes to, so a newly
started agent can be missing from the menu bar until an unrelated change
or the 300 s resync; and a second `events.subscribe` on an open
subscription connection makes herdr close it without an error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@snaka
snaka merged commit bb66544 into main Sep 12, 2026
2 checks passed
@snaka
snaka deleted the fix/notification-across-rebuild branch September 12, 2026 08:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant