유대선
프로젝트로
·트러블슈팅·2

The log said '(re)loaded'. The brain was down.

Verifying the shipped brain end-to-end caught a green lie: the app reported the brain service '(re)loaded' while it was actually down, because launchctl bootout is async and the success was reported from issuing the command, not from the service loading.

What "verify it properly" turned up

The founder's instruction was blunt: build it, install it, confirm the brain actually runs — verify it properly. So I did the full shipped path: build the app, swap it into /Applications, clear the WebView cache, launch.

runtime.log looked great:

INFO ai_brain: brain LaunchAgent (re)loaded → 127.0.0.1:8757

Then /health returned nothing. launchctl print gui/$uid/ai.ddalkkak.brain showed no job loaded. No brain process existed. The log was lying.

Why it lied

Installing the new app migrates the brain from the dev-repo venv to the shipped app-data location — a new plist path. ai_brain_start handles that by:

bootout (old job)  →  bootstrap (new plist)

But launchctl bootout is asynchronous. The old job — and its hold on port 8757 — lingered for a beat after bootout returned, so the immediate bootstrap failed. The fallback was a kickstart -k on a service that was no longer loaded (a no-op), and then:

Ok(format!("brain LaunchAgent (re)loaded ... "))   // returned regardless of success

The function reported success because it had issued the commands, not because the service was loaded. A manual bootout (→ "No such process", i.e. already gone) + bootstrap (exit 0) seconds later brought it up cleanly — confirming the venv and code were fine all along. Only the load raced.

The one honest signal in the system was the ⚠️ keyword status badge, which polls /health — it would have shown down. But the log contradicted it, which is its own bug: two observability surfaces disagreeing is worse than one.

The fix

ai_brain_start now loops bootout → wait → bootstrap with backoff (up to 6 tries), returns Ok only when bootstrap actually succeeds, and returns Err otherwise (logged as a real failure, not a green "(re)loaded"). The race only fires on a plist-changing migration; steady-state launches hit the loaded && unchanged early-return and never touch launchctl.

Verified — the brain runs from the shipped path

The lesson

A status reporter must derive from the post-condition, not the attempt. "I ran the command" is not "it worked" — especially when the command is async. Either confirm the post-condition (is it loaded? does /health answer?) before claiming success, or propagate the real error. The whole reason to build-install-and-check by hand instead of trusting a green log is that a green log can be exactly this wrong.