Skip to content

Harden the bridge installer; correct two claims the bus host disproved - #141

Merged
evansenter merged 5 commits into
mainfrom
claude/codebase-review-next-steps-vphsgo
Aug 10, 2026
Merged

Harden the bridge installer; correct two claims the bus host disproved#141
evansenter merged 5 commits into
mainfrom
claude/codebase-review-next-steps-vphsgo

Conversation

@evansenter

@evansenter evansenter commented Aug 10, 2026

Copy link
Copy Markdown
Owner

#139's round-2 review follow-ups (they merged on its approval before they were pushed), plus corrections to two claims that running #139 on the real bus host proved false — both of them mine.

Rebased onto ffdfcec (#143). Note the docs corrections now land in docs/BRIDGE.md, not guide.md: #138 moved the bridge operator docs there while this branch was open, and the rebase resolved my original guide.md hunks away with the section they lived in. Same corrections, re-landed in the file the content moved to.

Corrections from the host

launchd appends; it does not truncate. After a kill -9 respawn, the bridge's .err still held the previous PID's startup lines and both webhook entries. So:

  • The "crash loop eats its own evidence" caveat I documented doesn't exist.
  • The append-mode file handler it motivated is moot.
  • ThrottleInterval's stated justification loses that half.

The same false claim lived in the bus plist — which is where I copied it from — so it's corrected there too. The .err-vs-.log split is unaffected and now stated on its actual cause: basicConfig is stderr-only.

/health's registered is cached, so my boot-order check wasn't reproducible. It reports the startup result and is never re-verified. Against a bridge that make install-bridge just left registered, unloading the bus leaves /health still saying registered: true; the false state only appears when the bridge itself starts bus-less. Added the missing "restart the bridge" step, and stated the corollary plainly:

/health is not a bus-liveness probe. It answers "did I register at startup", not "am I registered now".

Notably the /health bullet further down that same file already said the row isn't re-verified — the checklist I added in #139 contradicted a caveat sitting a few hundred lines above it.

Installer hardening

Round-2 suggestions, all against code #139 added:

  • /tmp symlink hazard — the import preflight wrote stderr to a predictable path. 2> follows symlinks, so a pre-planted file would be truncated as the installing user, and it leaked on interrupt. Now mktemp under $TMPDIR with a trap.
  • uvicorn invisible to the preflight — it's imported lazily inside main(), so the one dependency whose absence causes the crash-loop the preflight exists to prevent was the one it couldn't see.
  • mkdir -p for log parents — an AGENT_EVENT_BUS_BRIDGE_LOG outside the data dir left launchd unable to open StandardOutPath, so the job just failed to start.
  • Health poll waited on the wrong instance — it broke on the first answer, which on a re-install comes from the outgoing process still holding port 8082 through its shielded stop-join-unregister. Now waits out ThrottleInterval, but only when a live instance was actually displaced.
  • _BRIDGE_LOG / _BRIDGE_ERR documented as install-time only — unlike every other _BRIDGE_* name they're read by the installer and baked into the plist; setting them for a running bridge does nothing.
  • bridge.py's module docstring no longer defers PATH/supervision to "the supervision story" that Supervise the RFC #122 bridge as a LaunchAgent #139 is.

Declined: reflowing the supervision sections below the backend bullet list. The suggestion is right that the bullets now attach to the wrong heading, but that reflow is larger than the fix and worth doing deliberately rather than as a rider — and #138 has since moved the whole file, so it wants doing against the new layout.

Verified on the host, not just here

make check green on the rebase: 649 passed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AFp3uCezpvbeyAMYnNrQ4R

Comment thread scripts/install-bridge-launchagent.sh Outdated
# the module alone would not surface its absence - and a missing uvicorn is
# precisely the crash-loop this check exists to prevent. A stale venv missing
# a bridge dependency would otherwise become an import crash-loop under
# KeepAlive - the one case where the log-truncation caveat bites hardest, since

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 retracted truncation claim survives here. This PR corrects "launchd TRUNCATES" → "launchd APPENDS" in both plists, guide.md, and CLAUDE.md — but this comment still reads "the one case where the log-truncation caveat bites hardest, since every respawn wipes the previous traceback", and it is now the last place in the repo asserting it (grep -rn truncat over the non-test sources finds only this line and the unrelated mktemp rationale two lines down).

The preflight is still worth having — an import crash-loop under KeepAlive is a bad state regardless of whether the log survives it — so the justification just needs restating without the truncation half, the same way ThrottleInterval's comment in the plist was rewritten.

Comment thread scripts/install-bridge-launchagent.sh Outdated
# /health carries nothing instance-specific to tell them apart, so wait the
# throttle out first. Skipped entirely on a fresh install, where the first
# answer can only come from the process we just started.
if [[ "$REPLACED_LIVE_BRIDGE" == true ]]; then

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 gate is a real improvement over probing immediately. Two things worth noting as follow-ups rather than fixes here:

  1. REPLACED_LIVE_BRIDGE is set from launchctl list | grep -q "$LABEL$", which reports that the job is loaded — not that a process is alive. A job that is loaded but currently dead (crashed and sitting out its throttle, or a prior install left it loaded with nothing running) takes the full 12s wait for no handoff. Cosmetic, but the name promises more than the check delivers.
  2. 12s bounds ThrottleInterval, not the outgoing instance shutdown. The comment is right that the port is held through the shielded stop-join-unregister — if that unregister retries against a slow or unreachable bus and outlives the sleep, the poll first answer still comes from the dying instance and the script prints its registered: value. The same shape exists on the fresh-install path when an unsupervised uv run agent-event-bus-bridge already holds 8082: the LaunchAgent instance refuses on the hook lock, launchctl list still shows the job loaded, and /health answers from the manual process.

The durable fix is the one this comment already identifies as missing: /health carries nothing instance-specific. Adding pid (or a start timestamp) to the payload would let the poll assert it is talking to the new process and drop the fixed sleep entirely.

Also non-blocking: none of the installer changes here are covered by a test — the repo has no harness for scripts/*.sh, so that is a gap in the project shape rather than in this PR.

(from the repo checkout - the console script lives in the project venv;
nothing puts it on PATH yet, that lands with the supervision story)
(from the repo checkout - the console script lives in the project venv and
nothing puts it on PATH; on macOS `make install-bridge` supervises it as a

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] Good call removing the forward-reference to "the supervision story" from the PR that is it. The same stale deferral is still in README.md:111-113:

Run it as uv run agent-event-bus-bridge from the checkout - unlike the CLI, nothing symlinks it onto PATH until the supervision story lands.

That is the only bridge mention in the README, and it never names make install-bridge — so a reader starting there gets the pre-#139 picture. Worth the same one-line update this docstring just got.

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 1

Summary

Two empirically-grounded doc corrections (launchd appends rather than truncates; /health registered is the startup result, not a bus-liveness probe) plus four installer hardening fixes. I checked the code-side claims against the tree:

  • uvicorn in the preflight — confirmed: bridge.py:2130 imports it lazily inside main(), and it is a top-level project dependency, so a stale venv really could pass the old check and then crash-loop. The preflight also matches the runtime environment the plist sets (PYTHONPATH=<project>/src, venv python, -m agent_event_bus.bridge), so it exercises the same import path.
  • /health correction — confirmed: registered is derived from registration_state["webhook_id"] at bridge.py:1186, and that state is only written by startup registration and shutdown unregistration. "Startup result, never re-verified" is accurate, and the new step-2 wording in guide.md now agrees with the /health bullet at guide.md:597 instead of contradicting it.
  • mktemp + trap — a nice side effect beyond the symlink fix: the old rm -f sat only on the success path, so the early exit 1 on import failure leaked the file it had just written. The EXIT trap covers both paths.
  • launchd append semantics — matches launchd opening StandardOutPath/StandardErrorPath in append mode, and the host observation is the right way to have settled it.

Findings

Three Suggestions, all posted inline. The highest-value one: the retracted truncation claim survives in the one place this PR did not sweep — scripts/install-bridge-launchagent.sh:30 still justifies the preflight with "every respawn wipes the previous traceback", which is exactly the claim corrected in both plists, guide.md, and CLAUDE.md.

Verdict

APPROVE - No blocking findings. Nothing here names a way the code produces a wrong result; the remaining feedback is documentation consistency and a follow-up on the health-poll heuristic.


Automated review by Claude Code

evansenter pushed a commit that referenced this pull request Aug 10, 2026
Round-1 review of #141 found the retraction was incomplete in exactly the file
this PR edits.

install-bridge-launchagent.sh still justified the import preflight with "the
one case where the log-truncation caveat bites hardest, since every respawn
wipes the previous traceback" - the claim this PR corrects in both plists,
guide.md and CLAUDE.md. It was the last assertion of it in the non-test
sources. The preflight is still worth having, so the justification is restated
without that half: an import crash-loop under KeepAlive respawns every
ThrottleInterval forever, never serves a delivery, and never answers /health to
say so.

README.md carried the same stale deferral the bridge.py docstring just lost -
"nothing symlinks it onto PATH until the supervision story lands" - and never
named make install-bridge. It is the only bridge mention in the README, so a
reader starting there got the pre-#139 picture.

Also renames REPLACED_LIVE_BRIDGE -> REPLACED_LOADED_JOB and states the gate's
two honest limits: `launchctl list` reports the JOB is loaded, not that a
process is alive, so a loaded-but-dead job pays the wait for no handoff; and
12s bounds ThrottleInterval, not the outgoing shutdown, which an unregister
retrying against a slow bus can outlive. Both dissolve once /health carries
something instance-specific (a pid or start timestamp) - noted in the comment
as the durable fix rather than left implicit.

make check green: 576 passed. Installer passes bash -n.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFp3uCezpvbeyAMYnNrQ4R
@evansenter
evansenter force-pushed the claude/codebase-review-next-steps-vphsgo branch from 2e9800a to b483ace Compare August 10, 2026 17:46
evansenter pushed a commit that referenced this pull request Aug 10, 2026
Round-1 review of #141 found the retraction was incomplete in exactly the file
this PR edits.

install-bridge-launchagent.sh still justified the import preflight with "the
one case where the log-truncation caveat bites hardest, since every respawn
wipes the previous traceback" - the claim this PR corrects in both plists,
guide.md and CLAUDE.md. It was the last assertion of it in the non-test
sources. The preflight is still worth having, so the justification is restated
without that half: an import crash-loop under KeepAlive respawns every
ThrottleInterval forever, never serves a delivery, and never answers /health to
say so.

README.md carried the same stale deferral the bridge.py docstring just lost -
"nothing symlinks it onto PATH until the supervision story lands" - and never
named make install-bridge. It is the only bridge mention in the README, so a
reader starting there got the pre-#139 picture.

Also renames REPLACED_LIVE_BRIDGE -> REPLACED_LOADED_JOB and states the gate's
two honest limits: `launchctl list` reports the JOB is loaded, not that a
process is alive, so a loaded-but-dead job pays the wait for no handoff; and
12s bounds ThrottleInterval, not the outgoing shutdown, which an unregister
retrying against a slow bus can outlive. Both dissolve once /health carries
something instance-specific (a pid or start timestamp) - noted in the comment
as the durable fix rather than left implicit.

make check green: 576 passed. Installer passes bash -n.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFp3uCezpvbeyAMYnNrQ4R
claude added 4 commits August 10, 2026 17:46
…env docs

Round-2 review follow-ups from #139, which merged on its approval before these
were pushed. All are against code that PR added.

- The import preflight wrote stderr to a predictable /tmp path. The 2>
  redirect follows symlinks, so a pre-planted file of a guessable name would
  be truncated as the installing user, and the file leaked if the script was
  interrupted before the rm. Now mktemp under $TMPDIR with a trap on EXIT,
  matching the $TMPDIR-aware convention CLAUDE.md documents for the hook-lock
  dir.
- The preflight imported agent_event_bus.bridge only, but uvicorn is imported
  lazily INSIDE main() (bridge.py:2129), so the one dependency whose absence
  produces exactly the KeepAlive import-crash-loop the preflight exists to
  prevent was the one it could not see. Now imports both.
- The installer created only DATA_DIR, so an AGENT_EVENT_BUS_BRIDGE_LOG
  pointing outside it left launchd unable to open StandardOutPath and the job
  simply failed to start. Creates each log file's parent.
- The health poll broke on the FIRST answer, which on a re-install comes from
  the OUTGOING instance: launchctl unload returns on SIGTERM delivery, and the
  old bridge holds port 8082 through its shielded stop-join-unregister, so the
  script reported "installed and running" plus the outgoing registered: value
  while the replacement had just exited on the flock. /health carries nothing
  instance-specific to tell them apart, so wait out ThrottleInterval first -
  and only when a live instance was actually displaced, so a fresh install
  pays nothing.
- CLAUDE.md listed _BRIDGE_LOG / _BRIDGE_ERR beside the runtime _BRIDGE_*
  names, but unlike every other one they are read only by the installer and
  baked into the plist: setting them for a running bridge does nothing. Noted
  as install-time only, the same warning the bus's _LOG / _ERR pair carries.
- bridge.py's module docstring still deferred PATH/supervision to "the
  supervision story"; #139 is that story. Points at make install-bridge.

Not taken: moving the guide's two new supervision sections below the backend
bullet list. The suggestion is right that the bullets now read as a
continuation of the wrong heading, but the reflow is larger than the fix and
the section is legible as-is; worth doing deliberately rather than as a rider.

make check green: 576 passed. Installer passes bash -n.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFp3uCezpvbeyAMYnNrQ4R
Both were verified wrong on the real machine after #139 merged, and both were
mine.

launchd APPENDS to StandardOutPath/StandardErrorPath; it does not truncate.
After a kill -9 respawn the bridge's .err still held the prior PID's startup
lines and both webhook entries. So the crash-loop-eats-its-own-evidence caveat
does not exist, the append-mode file handler it motivated is moot, and
ThrottleInterval's justification drops that half. The same false claim lived in
the BUS plist, which is where I copied it from - corrected there too.

/health's `registered` is the STARTUP result, cached and never re-verified, so
guide.md's boot-order check was not reproducible as written: against a bridge
that install-bridge just left registered, unloading the bus leaves /health
still reporting registered:true. The false state only appears when the bridge
itself starts bus-less. Added the missing "restart the bridge" step and stated
the corollary plainly - /health is not a bus-liveness probe; it answers "did I
register at startup", not "am I registered now". (The /health bullet further up
already said the row is not re-verified; the checklist I added contradicted it.)

The .err-vs-.log split is unaffected and still documented: bridge records go to
.err because basicConfig is stderr-only, .log carries uvicorn access lines.

Verified on the bus host alongside these: #137's fallback attributes a
non-interactive publish to the real session id (event 4638), and #139's crash
restart reclaims exactly one webhook row (stale #2 removed, #3 registered).

make check green: 576 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFp3uCezpvbeyAMYnNrQ4R
Round-1 review of #141 found the retraction was incomplete in exactly the file
this PR edits.

install-bridge-launchagent.sh still justified the import preflight with "the
one case where the log-truncation caveat bites hardest, since every respawn
wipes the previous traceback" - the claim this PR corrects in both plists,
guide.md and CLAUDE.md. It was the last assertion of it in the non-test
sources. The preflight is still worth having, so the justification is restated
without that half: an import crash-loop under KeepAlive respawns every
ThrottleInterval forever, never serves a delivery, and never answers /health to
say so.

README.md carried the same stale deferral the bridge.py docstring just lost -
"nothing symlinks it onto PATH until the supervision story lands" - and never
named make install-bridge. It is the only bridge mention in the README, so a
reader starting there got the pre-#139 picture.

Also renames REPLACED_LIVE_BRIDGE -> REPLACED_LOADED_JOB and states the gate's
two honest limits: `launchctl list` reports the JOB is loaded, not that a
process is alive, so a loaded-but-dead job pays the wait for no handoff; and
12s bounds ThrottleInterval, not the outgoing shutdown, which an unregister
retrying against a slow bus can outlive. Both dissolve once /health carries
something instance-specific (a pid or start timestamp) - noted in the comment
as the durable fix rather than left implicit.

make check green: 576 passed. Installer passes bash -n.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFp3uCezpvbeyAMYnNrQ4R
#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
evansenter force-pushed the claude/codebase-review-next-steps-vphsgo branch from b483ace to 9c267ca Compare August 10, 2026 17:46
# poll assert it is talking to the new process and drop this sleep entirely.
if [[ "$REPLACED_LOADED_JOB" == true ]]; then
echo "Waiting out the restart throttle so /health reports the NEW instance..."
sleep 12

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 12s pre-wait makes the failure message downstream misleading. Line 143 still reads:

/health did not answer within 20s; check $BRIDGE_ERR_FILE.

On the re-install path the script has already been waiting 12s before the poll starts, so an operator who sees that line has actually been sitting for ~32s. The 20s was loose in the other direction too: each iteration is curl --max-time 2 plus sleep 0.5, so 40 iterations bound at 20s only when every probe fails instantly, and at ~100s when they all hang.

Cheapest fix is to drop the number entirely: /health did not answer; check $BRIDGE_ERR_FILE. The elapsed time is not what the operator needs — the log path is.

Comment thread docs/BRIDGE.md Outdated
bus-less: `/health` shows `registered: false`. Load the bus; within ~30s
it flips to `true` with no intervention. This is `register_with_retry`'s
backoff doing its job. The restart is the step that makes this
reproducible - `registered` is the *startup* result and is never

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] "registered is the startup result and is never re-verified" sits two lines below "within ~30s it flips to true with no intervention". As written the two read as contradicting each other — the same shape as the contradiction this PR is fixing.

Both halves are right, they just need different words. registration_state["webhook_id"] (bridge.py:1186) is written after startup, by the register_with_retry background thread — which is exactly what makes the flip happen. What never happens is re-validation against the bus once that thread succeeds and stops.

Wording that keeps the corollary without appearing to deny the flip the step above depends on: "the result of the last registration attempt, never re-checked against the bus afterwards".

# user. The trap also covers an interrupt between creation and cleanup.
IMPORT_ERR="$(mktemp "${TMPDIR:-/tmp}/agent-event-bus-bridge-import.XXXXXX")"
trap 'rm -f "$IMPORT_ERR"' EXIT
if ! PYTHONPATH="$PROJECT_DIR/src" "$VENV_PYTHON" -c "import agent_event_bus.bridge, uvicorn" 2>"$IMPORT_ERR"; then

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 preflight now hand-mirrors the runtime lazy-import set, and nothing keeps the two in sync.

Today that set is exactly one name — a grep for indented import lines in bridge.py returns only import uvicorn at line 2125 — so the check is complete as written, and the comment above correctly explains why uvicorn had to be named. But a second deferred import added inside main() later silently reopens precisely the hole this commit closed, and the symptom is the same crash-loop with no failing install to point at it.

A one-line pointer in the comment (keep in sync with the deferred imports in bridge.py:main()) is probably the whole fix. Having the preflight actually drive main() far enough to trigger them costs more than it buys.

@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 1 raised three Suggestions and all three are resolved: the retracted truncation claim is gone from the installer preflight comment (a grep for truncat over the non-test sources now finds only the unrelated mktemp rationale), REPLACED_LIVE_BRIDGE is renamed REPLACED_LOADED_JOB with both honest limits written into the comment, and README.md:111-115 now names make install-bridge instead of deferring to the supervision story. The corrections themselves re-verify against the tree.

Verified this round

  • Preflight completeness — a grep for indented import lines in bridge.py returns exactly one hit, import uvicorn at line 2125. So import agent_event_bus.bridge, uvicorn is the complete lazy-import set, not a partial patch, and it matches the runtime environment the plist sets (venv python, PYTHONPATH=<project>/src, -m agent_event_bus.bridge).
  • The /health correctionbridge.py:1186 derives registered from registration_state.get("webhook_id"), written only by the registration thread and popped on unregister. Nothing re-checks it against the bus, so the corollary holds.
  • The .err / .log splitbasicConfig in main() gets no stream=, so bridge records go to stderr; uvicorn.run configures only its own loggers and routes uvicorn.access to stdout. The plist comment, CLAUDE.md, docs/BRIDGE.md, and the installer Logs: labelling all agree.
  • No stale survivors — the corrections moved out of guide.md into docs/BRIDGE.md cleanly, and guide.md retains no bridge-log or /health claims left to contradict them.
  • mkdir -p for the log parents — correctly ordered before the plist is written, and covers both overridable paths.

Findings

Three Suggestions, posted inline: a stale within-20s in the poll-failure message now that the re-install path waits 12s first, a wording tension in the docs/BRIDGE.md boot-order step between never re-verified and the flip it depends on, and a drift note on the preflight hand-mirroring the lazy-import set. Nothing names a way the code produces a wrong result — the installer changes are additive guards, and the doc changes replace claims the host disproved with ones it confirmed.

Previously Addressed (Filtered)

Not re-raised: the launchctl list job-loaded-versus-process-alive gap and the 12s-bounds-throttle-not-shutdown gap (round 1, Suggestion — both now documented in the comment as accepted limits, with the instance-specific /health payload named as the durable follow-up); the absence of a test harness for scripts/*.sh (round 1, Suggestion — a project-shape gap rather than a gap in this PR).

Verdict

APPROVE - No blocking findings. All remaining feedback is documentation-consistency polish.


Automated review by Claude Code

# poll assert it is talking to the new process and drop this sleep entirely.
if [[ "$REPLACED_LOADED_JOB" == true ]]; then
echo "Waiting out the restart throttle so /health reports the NEW instance..."
sleep 12

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 12 is a bare literal that has to stay >= the plist’s ThrottleInterval, but nothing links the two. com.evansenter.agent-event-bus-bridge.plist sets <integer>10</integer>; if that is ever raised (say to 30, to slow a noisy crash loop), this gate silently under-waits and the poll is answered by the dying instance again — the exact failure the gate was added to close, reintroduced by an edit in a different file. The comment above explains why 12, but a reader editing the plist has no reason to come here.

Cheap options: name the coupling in the plist comment too ("install-bridge-launchagent.sh waits this out on re-install — keep them in sync"), or hoist THROTTLE_INTERVAL=10 to the top of the script next to LABEL and sleep the sum with the margin visible as a margin.

Secondary, same neighbourhood: the failure message at line 143 still reads "/health did not answer within 20s". On the re-install path the user has now waited ~32s by then. "within 20s of the restart throttle", or just dropping the number, would keep it honest.

(Both dissolve alongside the fixed sleep once /health carries a pid or start timestamp, as the comment already identifies.)

Comment thread docs/BRIDGE.md
accumulates rather than overwrites, and the bridge needs no append-mode file
handler of its own. The split follows from `basicConfig` being stderr-only:
bridge records land in `.err`, while `.log` collects uvicorn's access lines.

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 install-time-only nature of AGENT_EVENT_BUS_BRIDGE_LOG / _ERR landed in CLAUDE.md but not here, and this paragraph is exactly where an operator lands when they want to move these files. BRIDGE.md is the operator doc; CLAUDE.md is the doc for whoever is editing the repo — the reader who needs "you must set this on make install-bridge itself, not on the running bridge" is the former, and they have no reason to open the latter.

One sentence appended here would do it: both paths default under the data dir and are overridable via AGENT_EVENT_BUS_BRIDGE_LOG / _ERR, which the installer reads and bakes into the plist — so they must be in the environment of make install-bridge, and setting them for a running bridge does nothing.

Comment thread CLAUDE.md Outdated
| Data directory | `~/.claude/contrib/agent-event-bus/` |
| Database | `~/.claude/contrib/agent-event-bus/data.db` |
| Log files | `agent-event-bus.log`, `agent-event-bus.err`; bridge: `agent-event-bus-bridge.log`, `agent-event-bus-bridge.err` (the bridge's are launchd's stdout/stderr capture, which launchd TRUNCATES on every restart - it has no append-mode file logger of its own yet) |
| Log files | `agent-event-bus.log`, `agent-event-bus.err`; bridge: `agent-event-bus-bridge.log`, `agent-event-bus-bridge.err` (the bridge's are launchd's stdout/stderr capture; launchd APPENDS across restarts, so no file handler of its own is needed. Bridge records go to `.err` - it logs to stderr - while `.log` gets uvicorn access lines) |

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] While this row is being rewritten: it names four log files, but the bus actually writes three of its own and the third is missing. com.evansenter.agent-event-bus.plist hardcodes StandardOutPath to agent-event-bus.stdout (not substituted, unlike __ERR_FILE__), so the bus has agent-event-bus.log (the Python FileHandler, server.py:77), agent-event-bus.err (launchd stderr capture) and agent-event-bus.stdout (launchd stdout capture) — the last undocumented, and the one file AGENT_EVENT_BUS_LOG cannot move.

Minor, but it rubs against the correction this PR is making: the new plist comment says "launchd APPENDS to these" pointing at .stdout and .err, while this row names .log and .err. A reader reconciling the two finds a file in each place that is absent from the other.

Separately, that plist comment says "verified empirically" without naming the evidence, where the bridge plist cites the specific kill -9 observation. Same launchd behaviour so the generalisation is sound — saying it was verified on the bridge’s files would make the provenance auditable the same way the bridge’s does.

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 1 s three Suggestions all landed. I verified each against the tree rather than taking the diff s word for it:

  • Truncation claim fully retracted. grep -rn truncat over src/, scripts/, docs/, CLAUDE.md, README.md now returns only install-bridge-launchagent.sh:35 (the unrelated mktemp symlink rationale) plus unrelated payload-truncation hits in server.py/middleware.py/tests. The installer comment round 1 flagged as the last holdout — "every respawn wipes the previous traceback" — is gone, restated as "respawning every ThrottleInterval forever, never serving a delivery, with /health never answering to say so". grep -rn "append-mode\|file handler\|file logger" finds only the two places that now deny needing one.
  • README updated. README.md:111-115 names make install-bridge and points at docs/BRIDGE.md; the "until the supervision story lands" deferral is gone repo-wide (grep -rn "supervision story" returns no hits).
  • REPLACED_LIVE_BRIDGE renamed to REPLACED_LOADED_JOB, and both limits round 1 named are now written into the comment above the gate rather than left implicit. Renaming to match what launchctl list actually reports is the right call.

Spot-checks on the corrections themselves:

  • .err vs .log split — confirmed. bridge.py:2133 calls basicConfig with no stream= (stderr), and bridge.py:2156 runs uvicorn.run with the default log config, whose access handler targets stdout and whose disable_existing_loggers is False, so the root stderr handler survives the dictConfig. Bridge records land in .err, uvicorn access in .log, as documented in all three places.
  • Bus plist commentserver.py:77 is logging.FileHandler(LOG_FILE), default mode a, and AGENT_EVENT_BUS_LOG is threaded through the plist s EnvironmentVariables. "The Python logger also appends to its own .log file" is accurate.
  • _BRIDGE_LOG / _BRIDGE_ERR install-time only — confirmed: zero hits under src/; the only readers are install-bridge-launchagent.sh:16-17 and the plist placeholders.
  • /health correctionbridge.py:1186 derives registered from registration_state["webhook_id"], written only by startup registration and shutdown unregistration. The new step-2 wording now agrees with the /health bullet at docs/BRIDGE.md:299-302 instead of contradicting it.
  • mktemp plus trap — the EXIT trap covers the early exit 1 path the old rm -f missed, on top of the symlink fix. The uvicorn addition matches the plist s runtime environment (PYTHONPATH=<project>/src, venv python), so it exercises the real import path.

Previously Addressed (Filtered)

All three round-1 Suggestions are implemented, so none are re-raised. The round-1 note that scripts/*.sh has no test harness is a project-shape gap already stated, likewise not re-raised.

Findings

Three Suggestions, posted inline: the 12 literal s undeclared coupling to the plist s ThrottleInterval (plus the now-understated "within 20s" message), the install-time-only note landing in CLAUDE.md but not the operator doc, and the undocumented agent-event-bus.stdout that the reworded log-files row still omits. Nothing names a way this produces a wrong result.

Verdict

APPROVE - No blocking findings. The two corrections are load-bearing — they retract a caveat that was motivating a follow-up that would have been pure cost — and the installer hardening is sound.


Automated review by Claude Code

Round 2 approved with three suggestions, all documentation-consistency. Taking
them because I was pushing the first two anyway, so the rest cost nothing. No
behavior change: comments, doc prose, and one operator-facing string.

The poll-failure message claimed "within 20s", which was wrong in both
directions - the loop is 40 x (curl --max-time 2 + sleep 0.5), so 20s only if
every probe fails instantly and ~100s if they all hang, and the re-install path
adds the 12s throttle wait ahead of it. Dropped the figure; the log path is what
the operator needs.

The boot-order step said `registered` is the "startup result and never
re-verified" two lines below "it flips to true with no intervention" - the same
self-contradiction shape this PR exists to remove. Both halves were right:
register_with_retry's thread writes state["webhook_id"] (bridge.py:1467), which
IS the flip; what never happens is re-validation once that thread succeeds.
Reworded to "the result of the last registration attempt, never re-checked
against the bus afterwards".

Two cross-file couplings had nothing linking them, and editing either side alone
silently reopens a hole this PR closed:
- the preflight hand-mirrors bridge.py:main()'s deferred imports (today exactly
  `import uvicorn`; an indented-import grep confirms it is the complete set)
- the installer's 12s gate must stay >= the plist's ThrottleInterval
Named both, from both sides.

CLAUDE.md's log-files row named four files but the bus writes three of its own:
StandardOutPath is hardcoded to agent-event-bus.stdout, so it is a THIRD file
and the one AGENT_EVENT_BUS_LOG cannot move. It was undocumented, and its
absence made the row disagree with the plist comment this PR rewrote. Also gave
the bus plist's "verified empirically" its provenance, matching how the bridge
plist cites the kill -9 observation.

The _BRIDGE_LOG / _ERR install-time-only note had landed in CLAUDE.md only;
BRIDGE.md is the operator doc and is where someone goes to move those files.

make check green: 649 passed. bash -n and plistlib both clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFp3uCezpvbeyAMYnNrQ4R
@evansenter
evansenter merged commit 0cb2404 into main Aug 10, 2026
5 checks passed
Comment thread CLAUDE.md
unlike every other `_BRIDGE_*` name - are **install-time only**: they are read by
`scripts/install-bridge-launchagent.sh` and baked into the plist, so they must be in
the environment of `make install-bridge` itself. Setting them for a running bridge
does nothing, exactly as with the bus's `_LOG` / `_ERR` pair).

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 analogy in the closing clause is the one part of this paragraph that does not hold to the standard the rest of the PR is applying.

_BRIDGE_LOG / _BRIDGE_ERR are install-time only in the strong sense: zero readers under src/, so they do nothing at runtime in any launch context. The bus pair is not symmetric with that:

  • AGENT_EVENT_BUS_LOG is read at runtime - server.py:64 resolves LOG_FILE from it at import, and server.py:76 even creates its parent, so DEV_MODE=1 AGENT_EVENT_BUS_LOG=/tmp/x.log agent-event-bus really does relocate the log. It is install-time-only under launchd, because the plist bakes it into EnvironmentVariables - a property of the supervision, not of the variable.
  • AGENT_EVENT_BUS_ERR has no Python reader at all, so it genuinely is the same shape as the bridge pair.

So the clause is right for _ERR and overstated for _LOG. Wording like "as with the bus _ERR (and, under launchd, _LOG)" keeps the point without flattening the distinction.

Minor on its own - but this PR exists because a claim was carried between files without being re-checked, which is why it seemed worth naming rather than passing over.

# The log paths are overridable and may point outside DATA_DIR; launchd
# cannot create a missing parent for StandardOutPath/StandardErrorPath and
# the job simply fails to start.
mkdir -p "$(dirname "$BRIDGE_LOG_FILE")" "$(dirname "$BRIDGE_ERR_FILE")"

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] Good fix, and it makes the identical gap in the bus installer visible by contrast: scripts/install-launchagent.sh:29-30 does mkdir -p "$HOME/Library/LaunchAgents" and mkdir -p "$HOME/.claude" - never $HOME/.claude/contrib/agent-event-bus, and never the parents of an overridden AGENT_EVENT_BUS_LOG / _ERR. That plist writes StandardErrorPath to __ERR_FILE__ and StandardOutPath to a hardcoded .stdout under the data dir. install-systemd.sh:27 does create the data dir, so the launchd bus installer is the outlier of the three.

In practice the bus mostly survives it because server.py:76 creates LOG_FILE.parent - but that runs after launchd has already tried to open its two capture files, so on a genuinely fresh box it is the first run .err / .stdout that are lost. Out of scope for this PR; worth a one-liner mirroring line 67 whenever that file is next touched.

Second, smaller: the comment asserts that the job simply fails to start when the parent is missing. Every other launchd behavioural claim this PR touches now carries its evidence (the kill -9 observation, cited in both plists) precisely because an uncited one turned out to be false. This one is uncited, and the real behaviour may be the softer variant - launchd logging the open failure and the job running with those descriptors unusable. Verifying it on the host, or softening the wording to "may fail to start", would keep the claim hygiene in this file uniform.

@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 4

Summary

The latest push (3ee0587) is comment- and doc-only, and it closes all three round-3 Suggestions: the 12 / ThrottleInterval coupling is now stated in the plist comment as well as the script, the install-time-only note for _BRIDGE_LOG / _ERR landed in docs/BRIDGE.md (the operator doc) alongside CLAUDE.md, and the bus third log file agent-event-bus.stdout is now named in the log-files row. The stale "within 20s" message and the boot-order wording tension are resolved too.

Re-verified against the tree rather than taken on trust:

  • Preflight completeness - a grep for indented import / from lines in bridge.py still returns exactly one hit, import uvicorn at bridge.py:2125. So import agent_event_bus.bridge, uvicorn remains the complete deferred-import set, and the new keep-in-sync pointer sits directly above it.
  • The .stdout claim - scripts/com.evansenter.agent-event-bus.plist:47 hardcodes the .stdout path with no placeholder, while StandardErrorPath is __ERR_FILE__ and AGENT_EVENT_BUS_LOG is threaded through EnvironmentVariables. "The one file no env var relocates" is accurate.
  • The Python-logger half - server.py:64 resolves LOG_FILE from AGENT_EVENT_BUS_LOG, server.py:77 is a default-mode (append) FileHandler. The plist new "a THIRD file, not the Python logger .log" note holds.
  • /health corollary - bridge.py:1186 still derives registered from registration_state, written only by the registration thread and popped on unregister. The reworded "result of the last registration attempt, never re-checked against the bus" now covers the retry-thread flip that step 2 depends on, without appearing to deny it.

Previously Addressed (Filtered)

Not re-raised: the launchctl list job-loaded-versus-process-alive gap and the 12s-bounds-throttle-not-shutdown gap (rounds 1-2, both now written into the comment as accepted limits, with the instance-specific /health payload named as the durable fix); hoisting THROTTLE_INTERVAL into a variable (round 3 - the author took the bidirectional-comment option, which addresses the same drift); the absence of a test harness for scripts/*.sh (a project-shape gap, not a gap in this PR).

Findings

Two Suggestions, posted inline: the _LOG half of the new install-time-only analogy in CLAUDE.md is overstated (server.py:64 reads it at runtime - it is launchd that makes it install-time), and the bus installer still lacks the mkdir -p for log parents that this PR just added for the bridge, plus one uncited launchd claim in the new comment. Nothing here names a way the code produces a wrong result, and this push changes no executable line.

Verdict

APPROVE - No blocking findings. Remaining feedback is documentation precision and an out-of-scope parallel in the bus installer.


Automated review by Claude Code

evansenter pushed a commit that referenced this pull request Aug 10, 2026
#141 landed on main while this was open. One conflict, in CLAUDE.md's
environment-variable list: main expanded it to document _BRIDGE_LOG /
_BRIDGE_ERR as install-time-only, this branch added _LOG_PEER to the bus
list. Kept both - main's expanded clause with _LOG_PEER inserted.

No source overlap: #141 touched bridge.py, the plists and the installer,
none of which this branch goes near.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MbSWrbyPaRCrYmyMf4KzsS
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