Skip to content

Cleanup pass before feature work - #138

Merged
evansenter merged 10 commits into
mainfrom
claude/codebase-state-assessment-zeetx5
Aug 10, 2026
Merged

Cleanup pass before feature work#138
evansenter merged 10 commits into
mainfrom
claude/codebase-state-assessment-zeetx5

Conversation

@evansenter

@evansenter evansenter commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Audit-driven cleanup ahead of feature development. Four cleanup commits plus a review-fix commit, each independently reviewable. No feature work; the only user-visible addition is the webhook pause/resume tool that closes a half-built surface.

596 tests pass (569 on main before this branch), lint and format clean. Merged up to main — includes #137.

c7f5c70 — dead code, duplicated wire shape, schema-parity guard

  • _get_implicit_channels deleted. It took a session_id it never read and always returned None. Its three tests asserted that constant; TestBroadcastModel replaces them by asserting the actual design decision — a session sees events on channels it doesn't belong to.
  • _event_to_dict and _webhook_payload built identical dicts, differing only in id vs event_id, each with its own do-not-break-this comment. Both now delegate to _event_wire_dict, so a field added for one consumer reaches the other and the two can't drift on signal_level. Both names kept — tests pin the delivery contract against _webhook_payload specifically.
  • _preview() for truncation duplicated in two places; storage.get_events rebuilds its WHERE clause from a condition list instead of four copies of the same block.
  • TestSchemaParity added. _init_db defines the schema for fresh installs while the @migration registry upgrades existing databases, and nothing checked that they agree. A migrated database is now compared against a fresh one.

144357dcall_tool raises instead of exiting

call_tool lives in cli.py but is imported by bridge.py, and it terminated the process on failure. The bridge had three catch sites reverse-engineering intent from the exit code — isinstance(e, SystemExit) and e.code == 1 — each wrapped in a paragraph explaining why that inference held.

It now raises BusUnreachableError when nothing answers and lets every other failure propagate with its own type. It never prints and never exits; main() owns that policy in one place, and a handler's own sys.exit for a logical error still passes through. The debug parameter is gone from call_tool (it only ever controlled printing and exiting) and stays a main()-level flag. The bridge gains BridgeRegistrationError for its own named checks.

Net −60 lines of source, and the diagnostics improve. Registration retries used to log SystemExit(1) for a down bus, a 401, and a malformed body alike:

Registration on http://127.0.0.1:8098/mcp failed (BusUnreachableError('Cannot
connect to agent event bus at http://127.0.0.1:8098/mcp')); retrying in 1s

3ec41d8 — webhook pause/resume, and bridge docs out of the MCP guide

set_webhook_active. The active column, the storage method, and list_webhooks(active_only=) have existed since webhooks landed, but nothing in MCP or the CLI could set active to 0 — so in production it was always 1, making active_only and webhook list --all a distinction without a difference. Adds the MCP tool and webhook disable/enable <id>. Pausing keeps the registration, filters, and secret; unregistering stays permanent.

storage.list_webhooks now defaults to active_only=True, matching the MCP tool. The two disagreed, and the unsafe direction is asymmetric: a caller that forgets the argument while deciding whom to deliver to would otherwise wake every webhook its owner had deliberately paused. Every existing caller passes it explicitly, so nothing changes today.

docs/BRIDGE.md. guide.md was 658 lines, 300 of them the experimental bridge — longer than the entire rest of the API guide, in a file served as an MCP resource and therefore paid for in every session that reads it. Moved verbatim, with headings added (it had none) and a status banner. The guide keeps a pointer plus the one fact a woken session needs: drain wake/<session_id>.jsonl, one JSON event per line.

0f5fe74 — one mechanism for schema change

Four ways to change a schema existed, two invisible to tests. Now _init_db only CREATEs and migrations only ALTER:

  • @migration(5, "backfill_pre_registry_columns") takes over adding sessions.last_cursor and events.channel from the inline try/except ALTER TABLE blocks. Conditional, so it's a no-op on any database at v4.
  • Dropped the duplicate webhooks CREATE — migration v3 owns it and runs for fresh installs too.
  • TestSchemaParity's v1 fixture now omits those two columns, so it actually exercises migration 5.

Two destructive legacy paths removed, neither replaced by silence:

  • _migrate_db_location moved the user's only copy out of a pre-rename path using a plain file move that is WAL-unsafe by this repo's own rules. It now reports the stale file and prints the sqlite3 .backup command, leaving it untouched — reporting matters, or a stale old-path database presents as a brand-new empty bus with the real history unnoticed on disk.
  • _migrate_sessions_schema ran an unconditional DROP TABLE sessions on a pre-RFC-RFC: Session coherence across claude --continue calls #29 schema. It refuses with instructions instead, and a test asserts the rows survive the refusal.

398f64d — review round 1

One real defect this PR had introduced, plus five suggestions — all valid, all fixed:

  • bridge.py (blocking). register_with_bus swept stale rows at its own hook URL with active_only=True. Correct only while nothing could set active = 0 — which set_webhook_active changes. A paused row at the bridge's URL survived the sweep, a restart added a second row there, re-enabling the first made the bus deliver every DM twice, and shutdown unregistered only the newer id. Swept with active_only=False. The regression test drives the real bus tool implementations, so reverting the argument reproduces two rows at one URL.
  • TestSchemaParity was weaker than its own description. It compared declared type only, so NOT NULL/DEFAULT divergences passed silently — and one already did: sessions.display_id is NOT NULL fresh, nullable migrated, because migration v2 can only ADD COLUMN. Now compares (type, notnull, pk, default) and index CREATE statements. That one divergence is recorded in KNOWN_CONSTRAINT_DIVERGENCES with its reason (closing it needs a table rebuild — its own change); anything unlisted fails, type is still compared for listed columns, and a second test pins the list.
  • The legacy-path warning was once-only, gated on "file doesn't exist" — true on exactly the boot that creates it. Now keyed on "this database is still empty": repeats while actionable, stops once real history accumulates.
  • set_webhook_active was missing from the hand-listed async-tool guard. Derived from the registry instead, matched by tool type (duck-typing on .fn/.name catches conftest's MagicMock), with a companion test pinning the roster.
  • --debug didn't apply to BusUnreachableError — the one failure a user reaching for the flag is most likely debugging. Now checked first, uniform everywhere.
  • docs/BRIDGE.md: Delivery outcomes promoted out of Security and operation; blank lines before headings.

d5bc7a2 — merge main

Brings in #137 (CLAUDE_CODE_SESSION_ID fallback), which touched the same two CLI handlers this PR refactored. Auto-merged cleanly, and verified as more than textual: _session_id_from_env() and the debug-less call_tool signature coexist, precedence still holds (--session-id > AGENT_EVENT_BUS_SESSION_ID > CLAUDE_CODE_SESSION_ID), and the new webhook verbs still work against a live bus.

Database verification

Given CLAUDE.md's protection rules, this was verified on a real upgrade rather than fixtures. A v4 database built by the previous revision of storage.py, populated with sessions, events (meta, correlation_id, channel), and a secret-carrying webhook, opens at v5 with every field intact, a single version row, and a schema matching a fresh v5 install.

Also exercised against a live server and bridge: CLI error paths and exit codes; a bridge started before the bus retrying, registering when it came up, and spooling a DM to its wake file; and the full webhook register → disable → list --all → enable cycle.

SCHEMA_VERSION moves 4 → 5, so back up before deploying:

sqlite3 ~/.claude/contrib/agent-event-bus/data.db ".backup $HOME/.claude/contrib/agent-event-bus/data.db.backup-$(date +%Y%m%d-%H%M%S)"

Not done

Left from the audit, unaddressed: duplicate SSE parsing in middleware.py and cli.py; ~4.5s of the suite spent on real webhook backoff sleeps; make fmt linting . while CI lints src tests; no coverage measurement; the stale docs/superpowers/specs/ design doc. Also noted: closing the sessions.display_id constraint divergence needs a table-rebuild migration.


Generated by Claude Code

claude added 4 commits August 9, 2026 23:48
… schema parity

Cleanup pass ahead of feature work. No behavior change.

- Delete _get_implicit_channels: it took a session_id it never read and
  always returned None. Its three tests asserted that constant; they are
  replaced by TestBroadcastModel, which pins the actual design decision
  (channels are metadata, not subscriptions) by observing that a session
  sees events on channels it does not belong to.

- Single-source the event wire shape. _event_to_dict and _webhook_payload
  built identical dicts differing only in "id" vs "event_id", each with its
  own do-not-break-this comment. Both now delegate to _event_wire_dict, so a
  field added for one consumer reaches the other and the two cannot drift on
  signal_level. Both names are kept: tests pin the delivery contract against
  _webhook_payload specifically.

- Extract _preview() for the payload truncation duplicated in the DM
  notification and the publish dev-notify.

- Rebuild storage.get_events' WHERE clause from a condition list instead of
  four copies of the same append-or-initialize block.

- Add TestSchemaParity. _init_db defines the schema for fresh installs while
  the @migration registry upgrades existing databases; until now nothing
  checked that the two agree, and _init_db's docstring could only assert it.
  A migrated v1 database is now compared column-for-column and index-for-
  index against a fresh one, so a change landing in one path and not the
  other fails here instead of as "no such column" on a live bus.

- server.py's module docstring was missing list_channels.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N11TJkcFv2Bn4YQvbYsQ37
…l_tool

call_tool lived in cli.py but is imported by bridge.py, and it terminated
the process on failure: sys.exit(1) for connection errors, and for
everything else either sys.exit(1) or a re-raise depending on a `debug`
flag. A daemon whose HTTP helper can kill it is the wrong shape, and the
bridge had grown three catch sites that reverse-engineered intent from the
exit code - `isinstance(e, SystemExit) and e.code == 1` - each wrapped in a
paragraph of comments explaining why that inference held.

call_tool now raises BusUnreachableError when nothing answers and lets every
other failure propagate with its own type. It never prints and never exits;
main() owns that policy in one place, and a handler's own sys.exit for a
logical error still passes through (SystemExit is not an Exception). The
`debug` parameter is gone from call_tool - it only ever controlled printing
and exiting - and stays a main()-level flag.

The bridge gains BridgeRegistrationError for its own named checks (a
non-list listing, a missing webhook_id, a refused removal), replacing the
SystemExits it raised to signal "retryable" to register_with_retry. That
loop now catches Exception plainly and logs the exception itself; the
exit-code archaeology and the debug=True call sites are gone.

The failure messages get better as a side effect. Registration retries used
to log "SystemExit(1)" for a down bus, a 401, and a malformed body alike;
they now log the real cause and the URL:

  Registration on http://... failed (BusUnreachableError('Cannot connect to
  agent event bus at http://...')); retrying in 1s

Verified end to end: the CLI still prints its start hint and exits 1 against
a dead bus, and a bridge started before the bus retries, registers when it
comes up, and spools a DM to its wake file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N11TJkcFv2Bn4YQvbYsQ37
Two cleanups that were blocking the same thing - the webhook surface being
half-built, and the guide being mostly about a component most sessions never
run.

set_webhook_active
------------------
The `active` column, the storage method, and `list_webhooks(active_only=)`
have existed since webhooks landed, but nothing in MCP or the CLI could set
`active` to 0. In production it was therefore always 1, which made
`active_only` - and `webhook list --all` - a distinction without a
difference. Adds the MCP tool and `webhook disable/enable <id>` on the CLI.

Pausing keeps the registration, its filters, and its secret; unregistering
stays permanent. That is the useful state for an endpoint that is failing or
noisy but that you intend to bring back - including a bridge whose hook you
want to silence without losing its row.

storage.list_webhooks now defaults to active_only=True, matching the MCP
tool. The two disagreed, and the unsafe direction is asymmetric: a caller
that forgets the argument while deciding whom to deliver to would otherwise
wake every webhook its owner had deliberately paused. Every existing caller
passes it explicitly, so nothing changes today.

Tests cover the round trip, the unknown-id path, both CLI verbs (one handler
behind two subcommands - the mapping is what can silently invert), and the
part that actually matters: a paused webhook drops out of the real dispatch
matcher, not just the listing.

docs/BRIDGE.md
--------------
guide.md was 658 lines, 300 of them the experimental bridge - a section
longer than the entire rest of the API guide, in a file served as an MCP
resource and therefore paid for in every session that reads it. The bridge
material is operator documentation for a daemon, not usage guidance for a
session.

Moved verbatim to docs/BRIDGE.md, which gains headings (it had none, being
300 lines of unbroken prose) and a status banner. guide.md keeps a short
pointer plus the one fact a woken session actually needs: drain
wake/<session_id>.jsonl, one JSON event per line. The guide is now 373
lines. README points at the new file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N11TJkcFv2Bn4YQvbYsQ37
…ropping user data

_init_db defined the current schema while the @migration registry upgraded
existing databases, and two more mechanisms had grown alongside them: bare
try/except ALTER TABLE statements inside _init_db, and a duplicate CREATE
for the webhooks table that migration v3 already owns. Four ways to change a
schema, two of which no test could see.

Now: _init_db only ever CREATEs, migrations only ever ALTER.

- @migration(5, "backfill_pre_registry_columns") takes over adding
  sessions.last_cursor and events.channel from the inline try/excepts. Both
  checks are conditional, so it is a no-op on any database that has them -
  which is every database at v4.
- Dropped the duplicate webhooks CREATE. Migrations run for fresh installs
  too (version 0 -> SCHEMA_VERSION), so v3 covers that case, exactly as v4
  already covers idx_events_correlation.
- TestSchemaParity's v1 fixture now omits last_cursor and channel, so it
  actually exercises migration 5. Built WITH them, the migration could rot
  while the test stayed green.

The two destructive legacy paths are gone, and neither is replaced by
silence:

- _migrate_db_location moved the user's only copy out of a pre-rename path,
  with a plain file move that is WAL-unsafe by the repo's own rules. The
  rename was January; the move has had months to run everywhere it was going
  to. It now reports the stale file and prints the sqlite3 .backup command,
  leaving the file untouched. Reporting is not optional: a stale old-path
  database would otherwise present as a brand-new empty bus with the real
  history sitting unnoticed on disk.
- _migrate_sessions_schema ran an unconditional DROP TABLE sessions on a
  pre-RFC-#29 pid-based schema. That was a defensible clean break when the
  schema was weeks old; it is now just a destructive statement aimed at user
  data, in a file whose own guidance forbids adding one. It refuses with
  instructions instead, and a test asserts the rows survive the refusal.

Verified on a real upgrade, not just fixtures: a v4 database built by the
PREVIOUS revision of this file, populated with sessions, events (meta,
correlation_id, channel), and a secret-carrying webhook, opens at v5 with
every field intact, a single version row, and a schema identical column-for-
column and index-for-index to a fresh v5 install.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N11TJkcFv2Bn4YQvbYsQ37
Comment thread src/agent_event_bus/bridge.py Outdated
# failure still funneled into a bare exit is a connection error, which
# the retry loop renders as exactly that.
existing = call_tool("list_webhooks", {"active_only": True}, url=config.bus_url, debug=True)
existing = call_tool("list_webhooks", {"active_only": True}, url=config.bus_url)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Important] The startup sweep only sees active rows, but this PR is what makes a row inactive in the first place.

register_with_bus's own docstring states the invariant it is protecting: "the bus neither dedupes by URL nor deactivates failing hooks, so each stale row would duplicate every wake. Remove matching URLs before registering." _register_webhook_impl confirms it — it calls storage.add_webhook unconditionally, with no URL uniqueness check.

Until this PR, active was always 1 in production (as the PR body says), so passing active_only=True here was harmless. Now set_webhook_active / webhook disable can leave a paused row at exactly this hook_url — and pausing a noisy endpoint you intend to bring back is the documented use case for the feature, which the bridge's own endpoint fits precisely.

Fails when: an operator runs agent-event-bus-cli webhook disable <bridge's id> because the bridge is spamming wakes, then restarts the bridge → the sweep skips the paused row and register_webhook adds a second row at the same URL; webhook enable <old id> to resume → two active webhooks at one URL → every session: DM is delivered twice, spooling duplicate lines into the wake file (only deduped if the consuming hook honors the event_id contract). unregister_from_bus on shutdown removes only the newer id, so the paused row then leaks indefinitely.

Fix is one argument: sweep with active_only=False. A stale row at this bridge's URL is stale whether or not someone paused it, and the removal is a delete either way.

Comment thread tests/test_storage.py Outdated
snapshot = {}
for table in tables:
columns = {
row[1]: row[2].upper() for row in conn.execute(f"PRAGMA table_info({table})")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] _schema_snapshot compares the declared type only, so the parity guard is weaker than the class docstring advertises ("column-for-column").

PRAGMA table_info returns (cid, name, type, notnull, dflt_value, pk)row[3] and row[4] are right there and unused. As written, a NOT NULL or a DEFAULT that lands in one path but not the other passes silently. That is not hypothetical: sessions.display_id is TEXT NOT NULL from _init_db's CREATE but plain nullable TEXT on a migrated database (migration v2 adds it with a bare ALTER TABLE ... ADD COLUMN display_id TEXT), and this test is green on that divergence today.

Same for indexes: only names are compared, so an index on the right table over the wrong columns still matches. Selecting name, sql from sqlite_master for type='index' would close both gaps.

(The display_id divergence is pre-existing, not introduced here — but this PR is what adds the guard that claims to catch exactly this class of drift.)

Comment thread src/agent_event_bus/storage.py Outdated
self.db_path.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(OLD_DB_PATH), str(self.db_path))
logger.info("Database migration complete")
return # Already at the current location, nothing to say

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The legacy-path warning is once-only by construction, which undercuts the "silence is not an option" reasoning in the docstring above.

This early return fires as soon as data.db exists — and the very first startup that emits the warning is also the one that creates data.db. So the operator gets exactly one log line, on the boot where they are least likely to be reading logs, and every restart afterwards is silent while the bus runs empty and the real history sits at the old path.

A cheap strengthening: keep checking the legacy paths for as long as the current database is still empty (no sessions and no events), rather than gating on db_path.exists(). That repeats the warning while it is still actionable and goes quiet once the operator has either restored the old file or started accumulating real history — without nagging someone who deliberately left the old copy in place.



@mcp.tool()
async def set_webhook_active(webhook_id: int, active: bool) -> dict:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] tests/test_hardening.py::TestAsyncToolWrappers::test_all_tools_are_async enumerates the tool functions by hand, and set_webhook_active was not added to that list — so the new tool is not covered by the guard for the concurrency invariant CLAUDE.md calls out ("Never put blocking work directly in a tool function - it freezes the whole server (#112)").

The implementation here is correct (async wrapper delegating to _run_sync), so this is coverage, not a defect. Deriving that list from the MCP registry instead of a literal would make the guard cover every future tool automatically — the same one-mechanism instinct the @migration consolidation in 0f5fe74 applies to schemas.

Comment thread src/agent_event_bus/cli.py Outdated
# (SystemExit is not an Exception) passes through untouched.
try:
args.func(args)
except BusUnreachableError as e:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This arm ignores --debug, unlike the generic arm three lines below.

agent-event-bus-cli --debug sessions against a down bus prints the friendly two-line message and exits 1, with no traceback — the one case where the user explicitly asked for the stack. Hoisting if args.debug: raise above both arms (or repeating it here) makes --debug mean the same thing for every failure.

Comment thread docs/BRIDGE.md Outdated
loopback literal, or any value listed with `--allowed-hosts` - the same
allowlist covers both endpoints, so a monitoring probe arriving through
the same reverse proxy as the deliveries passes on the proxy's Host.
### Delivery outcomes

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] ### Delivery outcomes is nested under ## Security and operation, but the action field (spool, tmux, spool-cooldown, spool-unmapped, spool-tmux-failed) is delivery semantics, not security — it is the section an operator debugging "why did my session not wake" goes looking for, and under a security heading it is easy to miss in a rendered TOC. Promoting it to ## (or moving it next to ## Backends) would match how it is actually used.

Also minor: the new ## headings at lines 168 and 200 sit directly against the preceding list item with no blank line. CommonMark lets an ATX heading interrupt a paragraph, so both render correctly on GitHub, but a blank line before each matches the rest of the file.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prompt: evansenter/dotfiles/.../claude-review.md

Code Review — Round 1

Summary

Four well-scoped cleanup commits: dead code removal, a shared event wire-shape builder, call_tool becoming a raise-not-exit library function, the webhook pause/resume surface, and consolidating schema change onto the @migration registry. The reasoning in the commit bodies and docstrings is unusually good, the two destructive legacy paths (shutil.move of the user's only copy, unconditional DROP TABLE sessions) are exactly the right things to remove, and TestSchemaParity is a genuinely valuable guard.

I checked the load-bearing pieces by hand rather than taking the descriptions on trust:

  • _init_db creating only schema_version/sessions/events/indexes and letting migration v3 own webhooks is correct — _run_migrations runs current+1 .. SCHEMA_VERSION and fresh installs enter at version 0, so v3 does create the table.
  • Migration v5 is conditional on both columns and is a genuine no-op at v4.
  • The get_events WHERE rebuild is behavior-identical, including the since_id == 0if since_id swap (a negative cursor produced id > -N before and still does) and dropping the redundant len(event_types) > 0.
  • _preview's conditional-expression precedence is right (+ binds tighter than the ternary).
  • register_with_retry narrowing from except (SystemExit, Exception) to except Exception is safe: every SystemExit remaining in bridge.py is in the CLI/lock-acquisition paths, none reachable from register_with_bus.
  • The storage.list_webhooks default flip has no production caller relying on the old default — get_matching_webhooks and _list_webhooks_impl both pass it explicitly.
  • The guide.mddocs/BRIDGE.md move is content-complete: same sections, same order.

Verdict

REQUEST_CHANGES — one Important finding on bridge.py:1360. The bridge's stale-webhook sweep lists active webhooks only, an assumption that held solely because nothing could ever set active = 0. This PR is what removes that guarantee: a paused row at the bridge's own hook URL now survives the sweep, a restart registers a second row at that URL, and re-enabling the paused one makes the bus dispatch every DM to the bridge twice. One-argument fix (active_only=False).

Everything else is a Suggestion and does not block: the TestSchemaParity snapshot ignoring NOT NULL/DEFAULT and index columns, the once-only legacy-DB warning, set_webhook_active missing from the hand-maintained async-tool guard list, --debug not applying to the BusUnreachableError arm, and the ### Delivery outcomes heading nesting.

Process note: the gh api ... --input - heredoc form specified by the review prompt is rejected by a shell-safety guard in this environment, so the six findings were posted as individual inline review comments via the pulls/comments endpoint rather than in a single comments array. They are all attached to the correct file and line. I also could not run the test suite here (uv is outside the tool allowlist), so the 584-passing claim rests on the author's verification and CI.


Automated review by Claude Code

One real defect, introduced by this PR, plus five suggestions - all valid.

bridge sweep (Important)
------------------------
register_with_bus swept stale rows at its own hook URL with
active_only=True. That was correct only because nothing could set active=0;
set_webhook_active removes the guarantee, and pausing a hook that is
spamming you is exactly what that feature is for - so the bridge's own
endpoint is a likely target.

Missing the paused row means a restart adds a SECOND row at the same URL,
re-enabling the first makes the bus deliver every DM twice, and shutdown
unregisters only the newer id so the paused one leaks. Swept with
active_only=False: a row at this URL is stale whether or not someone paused
it, and the removal is a delete either way.

The regression test drives the real bus tool implementations rather than a
canned listing, so the paused row is genuinely absent from an active-only
listing. Reverting the argument reproduces two rows at one URL.

TestSchemaParity was weaker than advertised
-------------------------------------------
_schema_snapshot compared declared type only, so a NOT NULL or DEFAULT
landing in one path stayed invisible - and one already had:
sessions.display_id is TEXT NOT NULL from _init_db but nullable on a
migrated database, because migration v2 can only ALTER TABLE ADD COLUMN
(SQLite has no ALTER COLUMN, as v2's own comment records). The guard was
green on that.

Now compares (type, notnull, pk, default) and index CREATE statements rather
than index names. The display_id divergence is recorded in
KNOWN_CONSTRAINT_DIVERGENCES with the reason and the follow-up (a table
rebuild, which is its own change); everything not listed there fails. Even
for a listed column the declared type is still compared, and a second test
pins the list so an allowance nobody rechecks cannot become permanent.

legacy-path warning was once-only
---------------------------------
It gated on "db_path does not exist", true on exactly one boot - the one
that creates it. So the operator got a single line at the moment they are
least likely to be reading logs, then silence while the bus ran empty. Now
keyed on "this database is still empty": repeats while it is actionable,
stops on its own once real history accumulates.

three smaller ones
------------------
- test_all_tools_are_async hand-listed the tools and set_webhook_active was
  not in it. Derived from the registry instead, matched by tool TYPE rather
  than duck-typing on .fn/.name - conftest patches a MagicMock into the
  server module and a MagicMock answers hasattr for everything. A companion
  test pins the roster so a shape change fails loudly instead of scanning
  nothing and passing vacuously.
- --debug now applies to BusUnreachableError too. It was checked only in the
  generic arm, so the one failure a user reaching for the flag is most
  likely to be debugging was the one that swallowed the stack.
- docs/BRIDGE.md: `Delivery outcomes` promoted out of `Security and
  operation` (it is delivery semantics, and the section someone debugging a
  missed wake looks for), and blank lines restored before the headings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N11TJkcFv2Bn4YQvbYsQ37

Copy link
Copy Markdown
Owner Author

All six findings confirmed and fixed in 398f64d. Thanks — the bridge one was a genuine defect this PR introduced, and I'd missed it.

bridge.py:1360 (Important) — correct, and the failure mode is exactly as described. Swept with active_only=False. The regression test drives the real bus tool implementations rather than a canned listing, so the paused row is genuinely absent from an active-only listing; reverting the argument reproduces two rows at one URL:

AssertionError: exactly one row must remain at this hook URL; the paused one was
left behind and will double-deliver once re-enabled:
[{'webhook_id': 2, 'url': 'http://127.0.0.1:9999/hook', 'active': True},
 {'webhook_id': 1, 'url': 'http://127.0.0.1:9999/hook', 'active': False}]

test_storage.py:719 — confirmed empirically before fixing: fresh display_id is ('TEXT', notnull=1), migrated is ('TEXT', notnull=0), and the guard was green on it. Now compares (type, notnull, pk, default) and index CREATE statements instead of index names.

I did not close the display_id divergence itself — migration v2 can only ADD COLUMN and closing it needs a table rebuild on live user data, which is its own change rather than a line in a cleanup pass. Instead it's recorded in KNOWN_CONSTRAINT_DIVERGENCES with the reason; anything not listed there fails, the declared type is still compared even for listed columns, and a second test pins the list so the allowance can't quietly become permanent.

One correction to the index half: index divergence isn't reachable today. Both _init_db's CREATE INDEX IF NOT EXISTS statements and the migrations run on every open, for fresh and migrated databases alike, so an index change lands in both paths together. Comparing the SQL is still worth having for a future migration-only index — but I don't want to claim it caught something it can't yet.

storage.py:300 — right, and it contradicted the docstring directly above it. Now keyed on "this database is still empty" rather than "does not exist": repeats while actionable, stops once real history accumulates. Tested across three restarts.

server.py:1061 — fixed by deriving from the registry as suggested. One wrinkle worth recording: duck-typing on .fn/.name doesn't work here, because conftest's autouse fixture patches a MagicMock into the server module and a MagicMock answers hasattr for every name. Matching on tool type, anchored on one known tool, plus a companion test pinning the roster so a shape change fails loudly instead of scanning nothing and passing vacuously.

cli.py:632 — agreed. I had preserved the old behavior deliberately, but that behavior was an artifact of where the code lived (the ConnectionError arm preceded the debug-honoring arm inside call_tool), not a design decision. Now that the policy is centralized, --debug is checked first and means one thing everywhere.

docs/BRIDGE.md:256 — both applied.

589 tests pass, lint and format clean.


Generated by Claude Code

Comment thread tests/test_storage.py
the entry comes out of the list - rather than silently exempting a
column that no longer needs exempting.
"""
assert KNOWN_CONSTRAINT_DIVERGENCES == {("sessions", "display_id")}, (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This assertion is tautological - it compares the constant against its own literal, so it can only fail when someone edits KNOWN_CONSTRAINT_DIVERGENCES without editing this line. That makes it a change-detector, not the check the name and docstring describe.

The docstring claims: "If a rebuild migration ever fixes sessions.display_id, this fails and the entry comes out of the list." It would not. A rebuild migration that made display_id NOT NULL on migrated databases too would leave this test green (the constant is untouched) and test_migrated_v1_matches_fresh_install green (the exemption only ever relaxes a comparison) - so the stale allowance would survive exactly the event it is supposed to detect, and the blind spot it opens is permanent from then on.

Making it self-invalidating costs about the same as the current line: build the two databases, snapshot both, and assert the divergence is observed - migrated["sessions"]["columns"]["display_id"][1] != fresh["sessions"]["columns"]["display_id"][1] - with a message saying the exemption is now unnecessary and should be deleted. Then the entry comes out of the list because a test told you to, which is what the docstring promises.

(The exemption itself is right, and the reasoning in the comment above KNOWN_CONSTRAINT_DIVERGENCES is the correct call - a table rebuild on live user data is its own change.)

Comment thread tests/test_storage.py Outdated


def _schema_snapshot(db_path) -> dict:
"""Per table: columns as name → (type, notnull, default), and indexes as

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The docstring says columns are name -> (type, notnull, default), but the tuple built below is 4-wide and ordered (type, notnull, pk, default) - pk is missing from the description and default is in the wrong position. _compare_columnss failure message gets it right, so the two disagree about the same tuple.

Worth fixing because the snapshot shape is what a future reader indexes into: the [0] in _compare_columnss known-divergence branch is "the declared type" only by reference to this line.

Comment thread tests/test_bridge.py Outdated
assert any("bus unreachable" in r.message for r in caplog.records)

def test_unregister_logs_real_cause_for_non_connection_failures(self, tmp_path, caplog):
"""The except-Exception arm: with debug=True a 401/timeout/bad body

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Stale reference to the parameter this PR removes: "with debug=True a 401/timeout/bad body re-raises out of call_tool". There is no debug parameter on call_tool anymore - those failures re-raise unconditionally now, which is what makes this tests assertion hold.

The test itself is correct and still valuable (it pins that the shutdown log names the real cause instead of the connection-error misdiagnosis); only the docstrings explanation of why is out of date. test_call_tool_failure_contract further down was updated for the same change, so this one just got missed.

claude[bot]
claude Bot previously approved these changes Aug 10, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prompt: evansenter/dotfiles/.../claude-review.md

Code Review — Round 2

Summary

Round 1s single blocking finding is fixed, and fixed at the right depth. register_with_bus now sweeps with active_only=False, and the regression test drives the real server._register_webhook_impl / _set_webhook_active_impl / _list_webhooks_impl rather than a canned listing — so the paused row is genuinely absent from an active-only view and reverting the one argument reproduces two rows at one URL. The five suggestions were all taken too, and two came back stronger than asked: TestSchemaParity now compares (type, notnull, pk, default) plus index CREATE SQL, with the pre-existing sessions.display_id divergence recorded explicitly; and the legacy-path warning is re-keyed from "the file does not exist" to "this database is still empty", which is the condition that actually tracks whether the advice is still actionable.

Re-checked by hand rather than on trust:

  • The sweeps URL match still reads wh.get("url"), and _list_webhooks_impl emits url for inactive rows too, so widening the listing does not change the match key.
  • register_with_retry narrowing to except Exception remains safe after the round-1 edits — every SystemExit in bridge.py is still in the CLI / lock-acquisition paths, and bridge_hook_url cannot raise one.
  • The cli.main() reorder is behavior-preserving off the --debug path: BusUnreachableError still prints the same two lines and exits 1, now via an isinstance check inside the single arm.
  • _is_empty() is safe at its new call site — _warn_about_legacy_db_location moved after _init_db, so sessions/events always exist, and the legacy is None short-circuit means the two COUNT queries only run when a pre-rename file is actually present.
  • test_hardening.pys registry scan anchors on type(server.register_session), which conftests _notify_dm_recipient MagicMock does not satisfy; test_tool_coverage_includes_every_known_tool pins the roster so a vacuous scan fails loudly rather than passing.
  • storage.list_webhookss default flip still has no caller relying on the old default: get_matching_webhooks and _list_webhooks_impl pass it explicitly, and the only bare calls are in tests over active-only fixtures.
  • Fresh-install ordering after dropping the duplicate webhooks CREATE is still correct: _init_db creates, then migrations run 0 to 5, so v3 creates webhooks, v4 adds its index, v5 is a no-op on a fresh CREATE.
  • Docs stayed in step: set_webhook_active is in guide.md, README.md, and CLAUDE.md, and the guides bridge pointer is an absolute GitHub URL, not a repo-relative path an MCP reader could not follow.

Previously Addressed (Filtered)

All six round-1 items — bridge sweep, schema-parity strength, once-only legacy warning, async-tool guard list, --debug on the unreachable arm, BRIDGE.md heading nesting — are implemented and are not re-raised.

Verdict

APPROVE - No blocking findings. Three non-blocking suggestions posted inline, all against test/docstring surface added by the round-1 fix.

Note: uv/pytest are outside the tool allowlist here, so the 584-passing claim rests on CI and the authors verification; this review is by inspection. Findings were posted as individual inline comments via the pulls/comments endpoint, since this environments command guard rejects the single-call heredoc form.


Automated review by Claude Code

Three non-blocking suggestions, all against surface the round-1 fix added.

test_the_recorded_divergences_are_still_real asserted the constant against
its own literal - a change-detector, not the check its name promised. Its
docstring claimed a rebuild migration fixing sessions.display_id would fail
it; it would not. The constant would be untouched (green here) and the
parity test green too, since an exemption only ever relaxes a comparison -
so the stale allowance would have survived exactly the event meant to
retire it, and its blind spot would have been permanent from then on.

It now OBSERVES the divergence: build both databases, snapshot both, assert
the exempt column still differs on (notnull, pk, default) - the fields the
exemption relaxes, type being compared for exempt columns anyway. An entry
that has stopped exempting anything now fails with an instruction to delete
it. Verified non-vacuous: adding an entry for a column that does not diverge
fails with "'events.channel' ... is now exempting nothing".

Also two stale docstrings:
- _schema_snapshot described its column tuple as (type, notnull, default);
  it is (type, notnull, pk, default), and _compare_columns indexes [0] and
  [1:] against that order.
- test_unregister_logs_real_cause_for_non_connection_failures still
  explained itself in terms of debug=True, a parameter this PR removes from
  call_tool. Those failures propagate unconditionally now, which is what
  makes the assertion hold.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N11TJkcFv2Bn4YQvbYsQ37

Copy link
Copy Markdown
Owner Author

Round-2 suggestions fixed in 0089f54. All three were against surface the round-1 fix added.

The tautological exemption check — you're right, and the docstring was the giveaway: it promised a behavior the assertion couldn't deliver. Asserting the constant against its own literal can only fail when someone edits both lines, so a rebuild migration fixing display_id would have left it green and left test_migrated_v1_matches_fresh_install green, and the stale allowance would have outlived exactly the event meant to retire it.

It now observes the divergence. I compare [1:] rather than just notnull, since the exemption relaxes (notnull, pk, default) as a group and type is compared for exempt columns anyway — so an entry whose non-type fields all match is exempting nothing regardless of which one stopped diverging. Verified non-vacuous by adding an entry for a column that doesn't diverge:

AssertionError: 'events.channel' no longer diverges between a fresh and a migrated
database (both (1, 0, "'all'") for notnull/pk/default). The entry in
KNOWN_CONSTRAINT_DIVERGENCES is now exempting nothing - delete it, so the column
goes back to being compared in full.

_schema_snapshot's tuple description — fixed, and I added the reason it matters, since your point was that _compare_columns's [0] and [1:] are only meaningful by reference to that line.

The debug=True docstring in test_unregister_logs_real_cause_for_non_connection_failures — fixed. Missed it because test_call_tool_failure_contract was the one I rewrote wholesale; this one only needed its explanation updated, so it read fine at a glance.

596 passing, lint and format clean, branch current with main.


Generated by Claude Code

Comment thread docs/BRIDGE.md Outdated
Comment on lines +177 to +182
- Startup is idempotent: stale active webhooks at this bridge's URL (from
unclean exits) are removed before registering, so restarts never stack
duplicate deliveries. The sweep matches the URL being registered NOW -
after changing `--port` or `--hook-url`, drop the row at the old URL
yourself (`agent-event-bus-cli webhook list` / `webhook unregister`) or
the bus keeps dispatching to the dead address forever. Because that sweep

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Two bits of this bullet were outrun by the round-1 fix.

  1. "stale active webhooks at this bridge URL ... are removed before registering" — the sweep now lists active_only=False precisely so it also removes paused rows (bridge.py:1367). The word "active" here now describes the bug that was fixed rather than the behavior.
  2. The recovery instruction for a changed --port/--hook-url points at agent-event-bus-cli webhook list, which defaults to active-only. A row left at the old URL that someone had paused will not appear, so the operator follows the instruction and concludes there is nothing to clean up. webhook list --all shows both — and, now that webhook disable exists, paused rows are a state operators can actually reach.

Suggested: drop "active" from the first sentence, and make the pointer webhook list --all.

Comment thread README.md
| `notify` | System notification |
| `register_webhook` | Register HTTP endpoint for push notifications |
| `list_webhooks` | List registered webhooks |
| `set_webhook_active` | Pause/resume a webhook without unregistering |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The MCP tool table gained set_webhook_active, but the CLI examples block further down (the agent-event-bus-cli webhook register / list / unregister group, ~L140-147) was not updated with the two new subcommands: agent-event-bus-cli webhook disable 1 and ... webhook enable 1, plus a note that paused webhooks only show up under webhook list --all.

CLAUDE.md asks for CLI help, MCP docstrings and guide.md to move together; guide.md and the MCP docstring did, so this is the one surface left behind. Same note for CLAUDE.md itself: its architecture tree and See Also section do not mention the new docs/BRIDGE.md.

Comment thread src/agent_event_bus/bridge.py Outdated
# state. Shutdown stays best-effort, so a surprise is a warning here
# rather than the sweep's retryable SystemExit - but the log must not
# rather than the sweep's retryable BridgeRegistrationError - but the
# log must not

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The comment wrap got mangled when the longer BridgeRegistrationError replaced SystemExit: "...rather than the sweep retryable BridgeRegistrationError - but the" / "log must not" / "assert a removal it never checked" now breaks mid-clause across three lines with an orphaned two-word line. Reflowing the paragraph would restore it to one sentence.

Comment thread src/agent_event_bus/storage.py Outdated

# Check if we need to migrate from pid to client_id schema
self._migrate_sessions_schema(conn)
self._reject_prehistoric_schema(conn)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] _reject_prehistoric_schema runs after the CREATE TABLE IF NOT EXISTS schema_version above it, and after _connect has issued PRAGMA journal_mode=WAL. Python sqlite3 opens implicit transactions only before DML, so that CREATE is autocommitted and survives the RuntimeError - a refused prehistoric database comes away with a new empty schema_version table and converted to WAL journalling.

User rows are untouched, which is what actually matters and what the test asserts, so this is cosmetic. But the docstring frames it as "refusing is right: the operator keeps their rows and decides", and the file is in fact modified. Moving the check ahead of the schema_version CREATE - it only needs PRAGMA table_info(sessions) - would make the refusal a genuine no-write, and let the docstring say so.

Comment thread tests/test_storage.py
for table, column in KNOWN_CONSTRAINT_DIVERGENCES:
migrated_spec = migrated[table]["columns"][column]
fresh_spec = fresh[table]["columns"][column]
assert migrated_spec[1:] != fresh_spec[1:], (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The rewrite closes the retirement blind spot round 2 named, and closes it correctly — an exemption that no longer exempts anything now fails with a message that says to delete it. But the literal it replaced was also doing a second job that nothing covers now: pinning the roster.

The old == {("sessions", "display_id")} failed whenever the set grew. The new loop only asks "does each listed entry still diverge?" — so a future contributor who hits a real parity failure on, say, events.channel can make test_migrated_v1_matches_fresh_install green by adding ("events", "channel") to the set, and both tests stay green: the parity test relaxes for it, and this test confirms it genuinely diverges (which it does — that is why they added it). The comment above the constant calls the list "the honest boundary of the guard", and its growth is exactly the event worth a deliberate stop.

The two checks are complementary rather than alternatives — keeping the literal alongside the new observation costs one line and restores the addition guard, with the docstring for each saying which direction it watches (growth vs. staleness).

Comment thread tests/test_storage.py
migrated = _schema_snapshot(migrated_path)

for table, column in KNOWN_CONSTRAINT_DIVERGENCES:
migrated_spec = migrated[table]["columns"][column]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] These two lookups raise KeyError rather than the intended failure when an entry names a table or column that no longer exists.

That is precisely the scenario the docstring is built around: a rebuild migration that closes the sessions.display_id divergence will very likely rename the table (sessions_newsessions is the standard SQLite rebuild dance) or, in another case, drop the column outright. The test does fail, which is the important part — but it fails with a bare KeyError: "display_id" instead of the carefully written "the entry is now exempting nothing - delete it" message, and a reader has to reconstruct why.

An assert column in migrated[table]["columns"] (and the same for fresh) ahead of the comparison, with a message saying a stale entry names a column that no longer exists, makes the two retirement paths — the constraint converged, or the column went away — report the same conclusion.

Comment thread src/agent_event_bus/bridge.py Outdated
# state. Shutdown stays best-effort, so a surprise is a warning here
# rather than the sweep's retryable SystemExit - but the log must not
# rather than the sweep's retryable BridgeRegistrationError - but the
# log must not

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Reflow artifact from the SystemExitBridgeRegistrationError rename: the longer name pushed the line over and # log must not is now a two-word orphan mid-sentence, so the comment reads "…but the / log must not / assert a removal it never checked". Rewrapping the paragraph restores it — cosmetic only, and the surrounding comments in this file are otherwise unusually well kept.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prompt: evansenter/dotfiles/.../claude-review.md

Code Review — Round 2

Summary

All six round-1 findings are genuinely fixed, and two of the fixes are better than what was asked for. I re-derived the load-bearing ones rather than trusting the reply:

  • bridge.py:1367 — the sweep now lists active_only=False, and test_startup_sweep_removes_a_paused_row_at_our_url drives the real _register_webhook_impl / _list_webhooks_impl / _unregister_webhook_impl, so the paused row is genuinely invisible to an active-only listing rather than mocked around. The pre-existing sweep test also now pins the listing argument, so it cannot drift back silently.
  • storage.py:305 — keyed on _is_empty() rather than db_path.exists(), with _init_db() correctly moved ahead of the warning so sessions/events exist to count. Ordering inside the helper is right too: the legacy is None early return fires before _is_empty(), so the common case costs nothing. The three-restart test and the stops-once-an-event-lands test cover both directions.
  • cli.py:658--debug checked first, BusUnreachableError demoted to an isinstance inside the single handler. SystemExit is not an Exception, so a handler own logical-error exit still passes through with its code (pinned by test_handler_sys_exit_passes_through_untouched).
  • test_hardening.py — matching on type(server.register_session) rather than duck-typing is correct given conftest patches a MagicMock over _notify_dm_recipient; an isinstance against FunctionTool will not match it. The assert tools anchor plus the pinned 11-name roster closes the vacuous-pass hole.
  • test_storage.py:846test_the_recorded_divergences_are_still_real now observes the divergence instead of asserting the constant against its own literal. The reasoning in that docstring is right: the literal form would have survived precisely the rebuild migration meant to retire the exemption. Comparing spec[1:] while _compare_columns still compares spec[0] for exempt columns is the correct split.

I also re-checked the parts round 1 reasoned about that this round could have disturbed: _init_db still only CREATEs, migration v5 is still conditional and a true no-op at v4, the get_events WHERE rebuild is still behavior-identical (including negative cursors and the truthiness swap on since_id), no SystemExit remains reachable from register_with_bus now that register_with_retry narrowed to except Exception, and the flipped storage.list_webhooks default has no caller relying on the old one (get_matching_webhooks and _list_webhooks_impl pass it explicitly; conftest.py:68 passes False; test_list_webhooks registers only active rows).

Nothing in this round names a way the code produces a wrong result. Four Suggestions posted inline — three are documentation the round-1 fix outran, one is a mangled comment wrap.

Previously Addressed (Filtered)

All six round-1 findings verified fixed above and not re-raised. The sessions.display_id NOT NULL divergence is explicitly Deferred (recorded in KNOWN_CONSTRAINT_DIVERGENCES, now with a self-invalidating guard) and not re-raised.

Verdict

APPROVE - No blocking findings.

Process note: the single-call gh api heredoc form is rejected by this environment shell-safety guard, so the four findings were posted as individual inline review comments via the pulls/comments endpoint. All are attached to the correct file and line. I could not run the suite here (pytest/ruff are outside the tool allowlist), so the 589-passing claim rests on the author verification and CI.


Automated review by Claude Code

claude[bot]
claude Bot previously approved these changes Aug 10, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prompt: evansenter/dotfiles/.../claude-review.md

Code Review — Round 3

Summary

The only change since round 2 is 0089f54 — the round-2 suggestion on test_the_recorded_divergences_are_still_real, taken, plus two docstring corrections. It is a correct fix: the test now builds both databases and asserts the exemption is observed (migrated_spec[1:] != fresh_spec[1:]), so a rebuild migration that closes the sessions.display_id divergence fails here with a message telling the author to delete the entry — which is exactly what the old literal assertion could not do.

Checked rather than taken on trust:

  • The new test compares [1:] (notnull, pk, default) and deliberately excludes [0], which is consistent with _compare_columns: the type is already compared for exempt columns, so an entry matching on all three constraint fields is exempting nothing. The two functions agree on the tuple layout, and the corrected _schema_snapshot docstring now names it in the right order with pk included.
  • _build_v1_db is reused for the migrated fixture, so this test exercises the same v1 to v5 path as test_migrated_v1_matches_fresh_install — including migration 5, whose whole point is that the fixture omits sessions.last_cursor and events.channel.
  • The test_bridge.py docstring fix is accurate: call_tool has no debug parameter anymore, and a 401, timeout, or bad body does now propagate with its own type, which is what makes that assertion hold.

Nothing in the earlier commits changed this round, and the round-1 and round-2 verifications of those still stand: the active_only=False sweep, the fresh-install migration ordering after dropping the duplicate webhooks CREATE, the list_webhooks default flip, the get_events WHERE rebuild, and the _event_wire_dict consolidation.

Previously Addressed (Filtered)

All six round-1 items and all three round-2 suggestions are implemented; none are re-raised.

Verdict

APPROVE - No blocking findings. Three non-blocking suggestions posted inline: the roster-growth guard that the rewritten divergence test no longer provides, a KeyError instead of the intended message when an exemption names a column that has gone away, and a comment reflow artifact in bridge.py left by the SystemExit to BridgeRegistrationError rename.

Note: pytest and uv are outside the tool allowlist here, so the 596-passing claim rests on CI and the author verification; this review is by inspection. Findings were posted as individual inline comments via the pulls/comments endpoint, since the command guard in this environment rejects the single-call heredoc form.


Automated review by Claude Code

Docs the round-1 fix outran, two test-guard gaps, and a refusal that wrote
before it refused.

Docs the sweep fix outran
-------------------------
- docs/BRIDGE.md described the startup sweep as removing "stale ACTIVE
  webhooks". Since the sweep now lists active_only=False, that word named the
  bug rather than the behavior. Its recovery instruction after a --port or
  --hook-url change also pointed at `webhook list`, which hides paused rows -
  so an operator with a disabled row at the old URL would follow the
  instruction and conclude there was nothing to clean up. Now `webhook list
  --all`, with the reason stated. Only reachable because this PR added
  `webhook disable`.
- README's CLI examples never gained disable/enable (its MCP table did), and
  CLAUDE.md's architecture tree and See Also never gained docs/BRIDGE.md.
  CLAUDE.md asks for CLI help, MCP docstrings and guide.md to move together;
  these were the surfaces left behind.

Guarding the guard, both directions
-----------------------------------
Round 2 had me replace a literal roster assertion with an observation, which
closed the staleness hole but silently opened an addition hole: a real parity
failure could be silenced by appending its column to
KNOWN_CONSTRAINT_DIVERGENCES, and both tests would stay green - the parity
test relaxes for whatever is listed, and the staleness test would confirm the
new entry genuinely diverges, which is exactly why someone added it.

The two watch opposite directions and are both one line, so both are kept:
test_no_new_divergences_have_been_recorded (growth, deliberately a
change-detector) and test_the_recorded_divergences_are_still_real (staleness).

The staleness test also raised a bare KeyError when an entry named a column
that had gone away - the likeliest retirement path, since a SQLite rebuild
renames the table. Both retirement shapes now report the same conclusion:
delete the entry.

A refusal that wrote first
--------------------------
_reject_prehistoric_schema ran after _init_db's CREATE TABLE IF NOT EXISTS
schema_version, and DDL autocommits, so a refused database came away with an
empty schema_version table - while the docstring said the operator keeps
their file and decides. Moved ahead of every CREATE. Verified: a refused
pid-schema database now contains only its own `sessions` table, rows intact.
The docstring also now states the one thing that does still happen - opening
the file asserts journal_mode=WAL, which is format, not content.

Also reflowed a comment left mangled by the SystemExit ->
BridgeRegistrationError rename.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N11TJkcFv2Bn4YQvbYsQ37

Copy link
Copy Markdown
Owner Author

Round-3 suggestions fixed in d68c9bc. All six across this round were valid; three concerned docs my own round-1 fix had outrun.

docs/BRIDGE.md — the more consequential of the two points. "stale active webhooks" now named the bug rather than the behavior, and the recovery instruction after a --port/--hook-url change pointed at webhook list, which hides paused rows. An operator with a disabled row at the old URL would have followed the instruction and concluded there was nothing to clean up — a failure mode reachable only because this PR added webhook disable. Now webhook list --all, with the reason stated inline so it doesn't decay back.

README / CLAUDE.md — right, and by the repo's own rule: CLAUDE.md asks for CLI help, MCP docstrings and guide.md to move together. I'd updated the README's MCP table but not its CLI examples, and hadn't added docs/BRIDGE.md to the architecture tree or See Also. Both done.

The roster-growth guard — this is the one I'd call a genuine miss on my part, not just staleness. Taking round 2's suggestion, I replaced the literal with an observation and treated them as alternatives; they're complementary. The hole you describe is real: appending a column to KNOWN_CONSTRAINT_DIVERGENCES silences a parity failure while both tests stay green, because the staleness test confirms the new entry diverges — which is precisely why someone added it. Both are kept now, each docstring naming which direction it watches (growth vs. staleness).

The KeyError path — fixed. A SQLite rebuild renaming the table is the likeliest way display_id ever gets retired, so that's the shape most likely to hit it. Both retirement shapes now reach the same conclusion instead of one arriving as a bare KeyError.

The refusal writing before it refused — correct, and worth more than cosmetic given the docstring's claim. DDL autocommits, so a refused database came away with an empty schema_version table while the text said the operator keeps their file and decides. Moved ahead of every CREATE, and verified rather than reasoned:

refused: ...back the file up, then drop the sessions table by hand to continue.
tables after refusal: ['sessions']
user rows intact: [('old-1',)]

Only its own table remains. I also documented the one thing that does still happen — opening the file asserts journal_mode=WAL from _connect, before any statement the refusal controls. That's format, not content, but the docstring shouldn't promise more than it delivers.

The bridge.py comment reflow — fixed.

597 passing, lint and format clean, branch current with main.


Generated by Claude Code

Comment thread tests/test_storage.py
rows = conn.execute("SELECT id FROM sessions").fetchall()
conn.close()
assert row is None, "New DB should not have old_marker table (wasn't overwritten)"
assert rows == [("old-1",)], "the refusal must not have touched user rows"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The reorder this commit makes is correct, but nothing pins it.

_reject_prehistoric_schema now runs before the CREATE TABLE IF NOT EXISTS schema_version, and its new docstring makes that a promise: "Runs before _init_db creates anything, so the refusal adds no tables of its own." This test asserts only that the user rows survive — which was already true under the old ordering. Move the call back below the CREATE and every test still passes, leaving the docstring quietly false.

A one-line addition here would close it, on the same connection you already open:

tables = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type=(?)", ("table",))}
assert tables == {"sessions"}, "the refusal must not have created scaffolding of its own"

That also documents the boundary the docstring is careful about: schema_version absent (content), journal_mode=WAL asserted (format).

Comment thread docs/BRIDGE.md Outdated
yourself (`agent-event-bus-cli webhook list --all` / `webhook unregister`)
or the bus keeps dispatching to the dead address forever. Use `--all`:
plain `webhook list` hides paused rows, so a disabled one at the old URL
would look like nothing to clean up. Because that sweep

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The --all fix landed twice in one bullet, and the reflow left a short line mid-sentence.

The parenthetical two lines up already says agent-event-bus-cli webhook list --all; the following sentence then re-explains why. One of the two is enough — keeping the parenthetical and dropping "Use --all: plain webhook list hides paused rows, so a disabled one at the old URL would look like nothing to clean up" reads cleanly, or keep the prose and leave the parenthetical bare.

Either way the paragraph wants a rewrap: "would look like nothing to clean up. Because that sweep" ends short at ~40 columns while its neighbours run to ~75.

Separately, this bullet is now doing a lot — startup idempotence, the changed-URL recovery procedure, --all, the two flock singletons, the run-two-bridges requirements, and the out-of-scope multi-user case. Splitting at "Because that sweep can't tell a stale row from a live peer's" would give the locking story its own bullet without touching a word of it.

claude[bot]
claude Bot previously approved these changes Aug 10, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prompt: evansenter/dotfiles/.../claude-review.md

Code Review — Round 5

Summary

The only change since round 3 is d68c9bc — all three round-3 suggestions taken, plus one behavioral reorder in storage.py. Nothing here names a way the code produces a wrong result.

Verified by hand rather than on trust:

  • storage.py:420_reject_prehistoric_schema moved above the CREATE TABLE IF NOT EXISTS schema_version. Safe: the function only runs PRAGMA table_info(sessions), so it has no dependency on schema_version existing, and _get_schema_version already tolerates the table being absent (sqlite3.OperationalError → 0). On a fresh database the PRAGMA returns nothing and the guard does not fire; on a refusal the RuntimeError propagates out of _connect before conn.commit(), and the finally still closes. The docstring is now exact about what remains — journal_mode=WAL is asserted in _connect before any statement here — which is format, not content.
  • test_storage.py:891-907 — the KeyError hole is closed in both directions and at both snapshots. A rebuild migration that renames sessions or drops display_id now fails with the intended "the entry is stale, delete it" message instead of a bare KeyError, and the loop runs before the [1:] comparison so the guard cannot be reached through a missing key.
  • test_storage.py:846test_no_new_divergences_have_been_recorded restores the roster-growth guard round 3 flagged, and the two tests now cover opposite directions without overlapping: this one fails when the set grows, test_the_recorded_divergences_are_still_real fails when an entry stops exempting anything. Neither can be satisfied by editing only the other.
  • bridge.py:1436 — comment reflowed, one sentence again; no code change.
  • DocsREADME.md's CLI block now carries webhook disable/enable and the --all caveat, BRIDGE.md no longer says the sweep is active-only (it is not, since round 1), and CLAUDE.md gained the docs/BRIDGE.md pointer. guide.md, README.md, and CLAUDE.md all list set_webhook_active, so the three surfaces CLAUDE.md asks to move together did.

I also re-derived the load-bearing pieces from earlier rounds against the current tree, since this commit touched storage.py and bridge.py: the sweep still lists active_only=False and still matches on wh.get("url"); _list_webhooks_impl still emits url and webhook_id for inactive rows; migration v5 is still conditional on both columns; _init_db still only CREATEs, with webhooks owned by v3 which runs for fresh installs at version 0; the get_events WHERE rebuild is still behavior-identical; and storage.list_webhooks's flipped default still has no caller relying on the old one. The webhook disable/enable path is wired correctly end to end — cmd_webhook_set_active derives active from args.webhook_command, whose dest matches the add_subparsers(dest="webhook_command") at cli.py:605, and _set_webhook_active_impl returns success: False for an unknown id, which the CLI turns into exit 1.

Previously Addressed (Filtered)

All six round-1 findings, all three round-2 suggestions, and all three round-3 suggestions are implemented; none re-raised. The sessions.display_id NOT NULL divergence stays Deferred (recorded in KNOWN_CONSTRAINT_DIVERGENCES, now guarded from both staleness and growth).

Verdict

APPROVE - No blocking findings. Two non-blocking suggestions posted inline, both against surface this round's own fix added: the new _init_db ordering is not pinned by any test (the refusal test asserts only that user rows survive, which held under the old ordering too), and the --all guidance now appears twice in one BRIDGE.md bullet with a ragged rewrap.

Note: pytest/uv are outside the tool allowlist in this environment, so the 596-passing claim rests on CI and the author's verification; this review is by inspection. Findings were posted as individual inline comments via the pulls/comments endpoint, since this environment's command guard rejects the single-call heredoc form.


Automated review by Claude Code

Both round-4 suggestions, against surface round 3's own fix added.

The reorder in d68c9bc moved _reject_prehistoric_schema above _init_db's
CREATE TABLE IF NOT EXISTS schema_version, and its docstring turned that
into a promise: "runs before _init_db creates anything, so the refusal adds
no tables of its own". Nothing held it there. The existing test asserts only
that user rows survive - true under the old ordering too - so moving the
call back would have left every test green and the docstring quietly false.
I had verified the behavior by hand and never pinned it, which is the same
gap in a different place.

test_refuses_and_preserves_the_rows now also asserts the refused database
contains only its own `sessions` table, on the connection it already opens.
Reverting the reorder fails it with "the refusal created scaffolding of its
own: ['schema_version', 'sessions']". The message names the boundary the
docstring is careful about: schema_version absent (content), journal_mode=WAL
asserted (format).

docs/BRIDGE.md: the --all fix landed twice in one bullet - once in the
parenthetical, then again as a sentence re-explaining it. Kept the
parenthetical with a short inline reason and dropped the sentence. Split the
bullet at "Because that sweep can't tell a stale row from a live peer's", so
the locking story stands on its own instead of trailing six other topics,
and rewrapped the paragraph the edit left ragged. Verified word-for-word
identical to the previous revision apart from the intended de-duplication.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N11TJkcFv2Bn4YQvbYsQ37

Copy link
Copy Markdown
Owner Author

Both round-4 suggestions fixed in 63782cf.

The unpinned ordering — you're right, and it's the same gap twice: I verified the no-scaffolding behavior by hand in the previous round and never pinned it, which is exactly the mistake that made the round-2 tautological-test finding possible. The existing assertion only covered user rows, which held under the old ordering too, so moving the call back would have left the suite green and the docstring false.

test_refuses_and_preserves_the_rows now also asserts the table set, on the connection it already opens. Reverting the reorder fails it:

AssertionError: the refusal created scaffolding of its own:
['schema_version', 'sessions']. Only journal_mode=WAL may change on a
refused database - format, not content.

Message states the boundary deliberately, since that's the distinction the docstring is careful about.

The duplicated --all — correct, I added the parenthetical and a sentence re-explaining it. Kept the parenthetical with a short inline reason, dropped the sentence, and took the structural suggestion too: the bullet now splits at "Because that sweep can't tell a stale row from a live peer's", so the locking story isn't trailing six other topics.

I rewrapped the paragraph my edit left ragged, and since that's the kind of change that quietly eats words, I diffed the whitespace-normalized text against the previous revision — identical apart from the intended de-duplication.

597 passing, lint and format clean.


Generated by Claude Code

A content conflict, not a textual one. #139 added a supervision section to
guide.md's bridge docs - the section this branch moved to docs/BRIDGE.md -
and its whole point is that the claims this branch relocated are now false:
the bridge HAS an install target.

Resolved by keeping guide.md's pointer (the section lives in docs/BRIDGE.md
now) and porting every one of #139's additions there:

- "Running it supervised (macOS)" - make install-bridge / uninstall-bridge,
  the separate-unit rationale, boot ordering being a non-issue, and the
  launchd log-truncation caveat.
- "Verifying supervision" - the four manual checks, two of which test claims
  the unit's own comments make.
- The rewritten closing Supervision section (macOS only, no systemd yet).
- The PATH parenthetical, which no longer ends "yet. That lands with the
  supervision story."

Then fixed what #139 could not know was stale, because it lives in files
this branch created or had already edited:

- docs/BRIDGE.md's status banner still read "No install target, no
  supervision story".
- README's bridge paragraph still said nothing symlinks it onto PATH "until
  the supervision story lands".

One deliberate deviation from #139's text: its crash-restart check says
`webhook list` should show one row; ported as `webhook list --all`, since
this branch is what makes a row invisible to a plain listing, and "is there
exactly one row at this URL" is precisely the question `--all` exists to
answer correctly.

Verified rather than assumed: every content word of main's post-#139 bridge
section is present in docs/BRIDGE.md (the only absences are punctuation
artifacts and the word "active", removed on purpose in round 3 when the
sweep stopped being active-only). CLAUDE.md auto-merged keeping both sides.
The bridge still registers after retrying a down bus, spools a DM, and
leaves exactly one webhook row. 597 passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N11TJkcFv2Bn4YQvbYsQ37
@evansenter
evansenter merged commit 0bb3293 into main Aug 10, 2026
5 checks passed
result = call_tool("list_webhooks", arguments, url=args.url)

if not result:
print("No webhooks registered")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] webhook list without --all now has a reachable state it did not have before this PR: registrations exist, but every one of them is paused. In that state this prints No webhooks registered, which is the one thing that is not true.

It matters because it is the exact case docs/BRIDGE.md sends an operator into - "after changing --port/--hook-url, drop the row at the old URL yourself (webhook list --all ... --all because plain list hides paused rows)". Someone who reaches for the plain form first gets told there is nothing there rather than being nudged toward --all.

Since args.all is right here, the message can distinguish the two:

if not result:
    print("No webhooks registered" if args.all else "No active webhooks (try --all)")
    return

Comment thread docs/BRIDGE.md

A **separate unit** from the bus (`com.evansenter.agent-event-bus-bridge`)
because a bus host does not have to run a bridge, and the bridge is
experimental while the bus is not. The unit pins `--backend spool`; tmux

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] "The unit pins --backend spool" names a flag the unit does not use. scripts/com.evansenter.agent-event-bus-bridge.plist has no --backend in ProgramArguments; it sets AGENT_EVENT_BUS_BRIDGE_BACKEND=spool in EnvironmentVariables (plist:33-34).

Worth the word change for two reasons: an operator verifying the claim greps the plist for --backend and finds nothing, and the two are not equivalent under "pins" - an env default yields to an explicit --backend tmux, a flag in ProgramArguments would not. The plist own comment is precise about this ("Pinned rather than inherited. launchd does not run a shell..."); the prose ported from #139 rounds it off.

self.db_path.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(OLD_DB_PATH), str(self.db_path))
logger.info("Database migration complete")
legacy = next((p for p in (OLD_CONTRIB_DB_PATH, OLD_DB_PATH) if p.exists()), None)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] next(...) reports only the first legacy path that exists, and the warning stops once this database is non-empty - so if both OLD_CONTRIB_DB_PATH and OLD_DB_PATH are present, restoring the contrib one silences the function permanently and ~/.claude/event-bus.db is never mentioned.

Not a regression: _migrate_db_location had the same first-match precedence. But it moved the file, so the unmentioned one at least sat next to visible evidence something had happened; now reporting is the entire value of the function, and the docstring case against silence ("the real history sitting unnoticed on disk") applies just as well to the second file.

One line, and the message already handles a list naturally:

legacy = [p for p in (OLD_CONTRIB_DB_PATH, OLD_DB_PATH) if p.exists()]
if not legacy:
    return

...then name the newest as the restore source and mention the rest. Genuinely marginal - two pre-rename paths on one machine is unlikely - so equally fine to leave it and note the precedence in the docstring instead.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prompt: evansenter/dotfiles/.../claude-review.md

Code Review — Round 6

Summary

Two changes since round 5: 63782cf (both round-4/5 suggestions taken) and 33fd9ad (merge of main carrying issue 139 bridge supervision, ported into docs/BRIDGE.md). Nothing in either names a way the code produces a wrong result, and nothing that worked in an earlier round is broken now.

Verified by hand rather than on trust:

  • tests/test_storage.py:1128test_refuses_and_preserves_the_rows now snapshots sqlite_master on the connection it already had open and asserts the table set is exactly sessions. That is the assertion the previous revision was missing: the row check alone held under the old ordering too. The reorder it pins is safe — _reject_prehistoric_schema only runs PRAGMA table_info(sessions), _get_schema_version already tolerates a missing schema_version table, and on a fresh file the PRAGMA returns nothing so the guard does not fire.
  • docs/BRIDGE.md:179-196 — the duplicated --all guidance is gone (parenthetical kept, the re-explaining sentence dropped) and the locking story is now its own bullet rather than trailing six other topics.
  • The merge is a content resolution, not a textual one. PR 139 supervision section landed in guide.md bridge docs — the section this branch moved — and its whole point contradicts what this branch relocated. All of it is present in docs/BRIDGE.md: "Running it supervised (macOS)", "Verifying supervision", the rewritten closing Supervision section, and the PATH parenthetical with its now-false "that lands with the supervision story" tail removed. The status banner and the README bridge paragraph — both stale in a way 139 could not see, since they live in files this branch created or had already edited — were corrected too. The deliberate deviation (webhook list --all in the crash-restart check, where 139 wrote webhook list) is right: this branch is what makes a row invisible to a plain listing, and "is there exactly one row at this URL" is exactly the question --all answers correctly.
  • The conftest change main brought composes. The CLAUDE_CODE_SESSION_ID scrub moved from test_cli.py to conftest.py as an autouse fixture; test_cli.py still imports pytest for pytest.raises/parametrize in the round-1 TestMainErrorHandling class, so the removal leaves no unused import.

Re-derived the load-bearing pieces from earlier rounds against the merged tree, since the merge touched cli.py:

  • --debug is still a top-level parser flag (cli.py:481), so args.debug exists on every path reaching the except Exception arm in main(); SystemExit is not an Exception, so a handler own logical-error exit still passes through.
  • Migration ordering is still sound after v5 took over the two inline ALTERs: nothing in migrations 2-4 reads or writes sessions.last_cursor or events.channel, so moving them from before _run_migrations to position 5 changes no outcome. _init_db CREATEs declare both (storage.py:439, :450), so v5 is a true no-op on a fresh install.
  • The bridge sweep still lists active_only=False and still matches on wh.get("url"); _list_webhooks_impl still emits url, webhook_id, and active for inactive rows.
  • storage.list_webhooks flipped default still has no caller relying on the old one (get_matching_webhooks and _list_webhooks_impl pass it explicitly; conftest.py:86 passes False).
  • set_webhook_active is covered end to end — TestSetWebhookActive drives the impls including get_matching_webhooks (the part that matters), the CLI pins both verb-to-state directions, and test_tool_coverage_includes_every_known_tool has it in the pinned 11-name roster.

Previously Addressed (Filtered)

All six round-1 findings, three round-2, three round-3, and both round-4/5 suggestions are implemented; none re-raised. The sessions.display_id NOT NULL divergence stays Deferred in KNOWN_CONSTRAINT_DIVERGENCES, guarded from both growth and staleness.

Verdict

APPROVE - No blocking findings. Converged at round 6 — remaining feedback is non-blocking. Three Suggestions posted inline: the plain webhook list empty-message now that all-paused is reachable, docs/BRIDGE.md naming a --backend flag the LaunchAgent does not use, and the legacy-path warning reporting only the first of two possible stale databases.

Note: pytest/uv are outside the tool allowlist here, so the 597-passing claim rests on CI and the author verification; this review is by inspection. Findings were posted as individual inline comments via the pulls/comments endpoint, since this environment command guard rejects the single-call heredoc form.


Automated review by Claude Code

evansenter pushed a commit that referenced this pull request Aug 10, 2026
#138 moved the bridge operator docs out of guide.md into docs/BRIDGE.md while
this branch was open, so the rebase resolved both of my guide.md corrections
away - the surrounding section no longer existed there. The plist and CLAUDE.md
halves survived; these two did not. Same corrections, same file the content
moved to.

launchd APPENDS to StandardOutPath/StandardErrorPath rather than truncating, so
the crash-loop-eats-its-own-evidence caveat and the append-mode file handler it
motivated are both moot. The .err-vs-.log split stands and is stated on its
actual cause: basicConfig is stderr-only.

The boot-order check was not reproducible as written. /health's `registered` is
the startup result, cached and never re-verified, so unloading the bus under a
bridge that install-bridge just left registered leaves /health reporting `true`.
Added the missing restart step and the corollary - /health answers "did I
register at startup", not "am I registered now" - which the /health bullet
further down this same file already implied.

make check green: 613 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFp3uCezpvbeyAMYnNrQ4R
evansenter pushed a commit that referenced this pull request Aug 10, 2026
#138 moved the bridge operator docs out of guide.md into docs/BRIDGE.md while
this branch was open, so the rebase resolved both of my guide.md corrections
away - the surrounding section no longer existed there. The plist and CLAUDE.md
halves survived; these two did not. Same corrections, same file the content
moved to.

launchd APPENDS to StandardOutPath/StandardErrorPath rather than truncating, so
the crash-loop-eats-its-own-evidence caveat and the append-mode file handler it
motivated are both moot. The .err-vs-.log split stands and is stated on its
actual cause: basicConfig is stderr-only.

The boot-order check was not reproducible as written. /health's `registered` is
the startup result, cached and never re-verified, so unloading the bus under a
bridge that install-bridge just left registered leaves /health reporting `true`.
Added the missing restart step and the corollary - /health answers "did I
register at startup", not "am I registered now" - which the /health bullet
further down this same file already implied.

make check green: 613 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFp3uCezpvbeyAMYnNrQ4R
evansenter added a commit that referenced this pull request Aug 10, 2026
#141)

#139's round-2 review follow-ups, plus corrections to two claims that running
#139 on the real bus host proved false.

launchd APPENDS to StandardOutPath/StandardErrorPath rather than truncating, so
the crash-loop-eats-its-own-evidence caveat and the append-mode file handler it
motivated are both moot. The same false claim lived in the bus plist and is
corrected there too. The .err-vs-.log split stands, restated on its actual
cause: basicConfig is stderr-only.

/health's `registered` is the result of the last registration attempt, never
re-checked against the bus afterwards, so the boot-order check was not
reproducible as written - unloading the bus under an already-registered bridge
leaves /health reporting true. Added the missing restart step and the corollary:
/health is not a bus-liveness probe.

Installer hardening: mktemp+trap for the preflight stderr capture (the old
predictable path let `2>` follow a symlink), uvicorn named explicitly because
bridge.py imports it lazily inside main(), mkdir -p for log parents, and a
health poll that waits out ThrottleInterval only when a live instance was
actually displaced.

Docs corrections land in docs/BRIDGE.md rather than guide.md: #138 moved the
bridge operator docs there while this branch was open.
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.

2 participants