Files
Bohan Jiang ac75c97797 fix(desktop): disable auto-start/stop toggles for a daemon the app can't control (WSL2) (#3940)
* feat(daemon): report OS in /health response

The desktop app reads daemon liveness over HTTP but starts/stops it via the
native CLI, which acts on the host process namespace. On Windows with the
daemon in WSL2, /health is reachable via localhost forwarding yet the daemon's
process is unreachable — so the app needs a signal to tell a daemon it manages
from one it merely sees. Expose runtime.GOOS as `os` so the desktop can
compare it against its own host OS. MUL-3154, #3916

Co-authored-by: multica-agent <github@multica.ai>

* fix(desktop): disable auto-start/stop for an unmanageable daemon

When the daemon runs in an environment the app can't drive — e.g. Linux in
WSL2 behind a Windows desktop, reachable only via localhost forwarding — the
Auto-start/Auto-stop toggles silently did nothing: the lifecycle CLI acts on
the host process namespace and never reaches the daemon's PID.

Detect it by comparing the daemon's reported OS (new /health `os` field)
against the host OS, and only when a daemon is actually running. When they
differ: disable both toggles with an explanatory note, skip the version-match
restart on auto-start, and skip the no-op stop on quit. Fails safe — a missing
`os` (older daemon) or a matching OS keeps the toggles live, so native
Mac/Windows/Linux daemons are unaffected.

MUL-3154, #3916

Co-authored-by: multica-agent <github@multica.ai>

* fix(desktop): centralize externally-managed guard at the lifecycle boundary

Review follow-up. The first cut only disabled the Settings toggles, but the
same unmanageable daemon (WSL2 etc.) could still be Stop/Restart-ed from the
Runtime card and from automatic lifecycle entries (logout, user switch,
reauth, first-workspace restart) — each of which would shell out to a native
CLI that can't reach the daemon's process.

Move the guard into the main-process lifecycle functions so every entry point
is covered by construction: stopDaemon() and restartDaemon() no-op for an
externally-managed daemon, and ensureRunningDaemonVersionMatches() treats it
as up-to-date (no misleading restart). The per-branch checks in the auto-start
handler and before-quit are removed — the boundary now covers them. The
Runtime card hides Stop/Restart and shows a 'Managed outside the app' hint,
mirroring the Settings tab. Adds a component test for the card's two states.

MUL-3154, #3916

Co-authored-by: multica-agent <github@multica.ai>

* fix(desktop): preflight the lifecycle guard against live /health

Review follow-up. The guard read a cached lastExternallyManaged, which only
fetchHealth() updates — but not every lifecycle entry polls before calling
stop/restart. syncToken()'s user-switch branch calls restartDaemon() directly
after its own fetchHealthAtPort(), without refreshing the cache; on a fresh
launch / account switch (no poll yet) the cache is still the initial false, so
restartDaemon() would shell out to the native CLI and hit the very WSL/native
PID-namespace problem this PR avoids.

Make stopDaemon()/restartDaemon() preflight against a live /health read each
call instead of trusting the poll cache. The decision is extracted to a pure
daemonLifecycleUnreachable(readDaemonOS, hostOS) so a unit test can prove the
*live* value (not a cache) drives it. lastExternallyManaged is removed — the UI
already reads the per-status externallyManaged field, so it had no other
consumer.

MUL-3154, #3916

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-10 12:27:41 +08:00
..