Files
multica/server/internal/daemon/health.go
LinYushen 8030f1adbc feat(desktop): restart local daemon when bundled CLI version differs (#1041)
* feat(desktop): restart local daemon when bundled CLI version differs

Desktop bundles a multica CLI binary at build time via bundle-cli.mjs.
If a local daemon is already running from a previous session with an
older CLI, the newly bundled version never takes effect until the user
manually restarts. Fix that on the login/auto-start path.

- Expose the daemon's CLI version on GET /health as cli_version (sourced
  from cfg.CLIVersion, which is already set from the ldflag at daemon
  startup in cmd_daemon.go).
- In the desktop main process, query the resolved CLI binary's version
  once via `multica version --output json` and cache it for the process
  lifetime.
- On daemon:auto-start, if the daemon is already running, compare the
  two versions. Restart only when BOTH sides are known and the strings
  differ — a restart kills in-flight agent tasks, so any uncertainty
  (bundled CLI unknown, older daemon without cli_version field, read
  failure) fails safe and leaves the daemon alone.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(daemon): defer version-mismatch restart until active tasks drain

Previous iteration restarted the daemon immediately on a confirmed CLI
version mismatch, which would kill any agent tasks mid-execution. Gate
the restart on an active-task counter so in-flight work always finishes.

- Daemon: add `activeTasks atomic.Int64` on the Daemon struct,
  increment/decrement it around handleTask, and expose it as
  `active_task_count` on GET /health.
- Desktop: when a version mismatch is confirmed but active_task_count >
  0, set a pendingVersionRestart flag instead of restarting. The 5s
  pollOnce loop retries ensureRunningDaemonVersionMatches on each tick
  and fires the restart the moment the count drops to 0.
- Eventual consistency: if the user keeps the daemon permanently busy,
  the version stays out of date — that's a strictly better failure mode
  than silently killing hour-long agent runs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(daemon): cover version-check decision + /health counter exposure

Addresses the test-coverage gap from the second review.

- Go: extract the /health handler into a named method `(d *Daemon)
  healthHandler(startedAt time.Time)` so it can be exercised via
  httptest without spinning up a listener. Add health_test.go covering
  cli_version + active_task_count field exposure and the increment /
  decrement protocol used by pollLoop.
- Desktop: extract the pure version-check decision logic into
  version-decision.ts (no electron, no I/O, no module state). The
  ensureRunningDaemonVersionMatches wrapper now delegates the "what
  should we do" decision to decideVersionAction and owns only the side
  effects (logging, flag mutation, restartDaemon call).
- Desktop: bolt vitest onto apps/desktop (vitest.config.ts + catalog
  devDep + test script) so main-process unit tests have a home. Add
  version-decision.test.ts covering all four action branches and the
  busy→idle drain transition.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(daemon): bust CLI version cache on retry-install, lock wire-level JSON keys

Two polish items from review.

- daemon:retry-install now also clears cachedCliBinaryVersion. Previously
  a retry that landed a newly-downloaded CLI at a different version
  would false-negative on the next version check because the cached
  version string was sticky for the process lifetime.
- TestHealthHandlerReportsCLIVersionAndActiveTaskCount now decodes into
  a raw map[string]any and asserts the exact snake_case keys
  (cli_version, active_task_count, status). The desktop TS client keys
  on these literal strings, so a silent struct-tag rename must fail the
  test. Typed struct round-trip kept as a separate value check.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 12:19:01 +08:00

152 lines
4.3 KiB
Go

package daemon
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"os"
"time"
"github.com/multica-ai/multica/server/internal/daemon/repocache"
)
// HealthResponse is returned by the daemon's local health endpoint.
type HealthResponse struct {
Status string `json:"status"`
PID int `json:"pid"`
Uptime string `json:"uptime"`
DaemonID string `json:"daemon_id"`
DeviceName string `json:"device_name"`
ServerURL string `json:"server_url"`
CLIVersion string `json:"cli_version"`
ActiveTaskCount int64 `json:"active_task_count"`
Agents []string `json:"agents"`
Workspaces []healthWorkspace `json:"workspaces"`
}
type healthWorkspace struct {
ID string `json:"id"`
Runtimes []string `json:"runtimes"`
}
// listenHealth binds the health port. Returns the listener or an error if
// another daemon is already running (port taken).
func (d *Daemon) listenHealth() (net.Listener, error) {
addr := fmt.Sprintf("127.0.0.1:%d", d.cfg.HealthPort)
ln, err := net.Listen("tcp", addr)
if err != nil {
return nil, fmt.Errorf("another daemon is already running on %s: %w", addr, err)
}
return ln, nil
}
// repoCheckoutRequest is the body of a POST /repo/checkout request.
type repoCheckoutRequest struct {
URL string `json:"url"`
WorkspaceID string `json:"workspace_id"`
WorkDir string `json:"workdir"`
AgentName string `json:"agent_name"`
TaskID string `json:"task_id"`
}
// healthHandler returns the /health HTTP handler. Extracted from serveHealth
// so tests can exercise it without spinning up a listener.
func (d *Daemon) healthHandler(startedAt time.Time) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
d.mu.Lock()
var wsList []healthWorkspace
for id, ws := range d.workspaces {
wsList = append(wsList, healthWorkspace{
ID: id,
Runtimes: ws.runtimeIDs,
})
}
d.mu.Unlock()
agents := make([]string, 0, len(d.cfg.Agents))
for name := range d.cfg.Agents {
agents = append(agents, name)
}
resp := HealthResponse{
Status: "running",
PID: os.Getpid(),
Uptime: time.Since(startedAt).Truncate(time.Second).String(),
DaemonID: d.cfg.DaemonID,
DeviceName: d.cfg.DeviceName,
ServerURL: d.cfg.ServerBaseURL,
CLIVersion: d.cfg.CLIVersion,
ActiveTaskCount: d.activeTasks.Load(),
Agents: agents,
Workspaces: wsList,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
}
// serveHealth runs the health HTTP server on the given listener.
// Blocks until ctx is cancelled.
func (d *Daemon) serveHealth(ctx context.Context, ln net.Listener, startedAt time.Time) {
mux := http.NewServeMux()
mux.HandleFunc("/health", d.healthHandler(startedAt))
mux.HandleFunc("/repo/checkout", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req repoCheckoutRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest)
return
}
if req.URL == "" {
http.Error(w, "url is required", http.StatusBadRequest)
return
}
if req.WorkDir == "" {
http.Error(w, "workdir is required", http.StatusBadRequest)
return
}
if d.repoCache == nil {
http.Error(w, "repo cache not initialized", http.StatusInternalServerError)
return
}
result, err := d.repoCache.CreateWorktree(repocache.WorktreeParams{
WorkspaceID: req.WorkspaceID,
RepoURL: req.URL,
WorkDir: req.WorkDir,
AgentName: req.AgentName,
TaskID: req.TaskID,
})
if err != nil {
d.logger.Error("repo checkout failed", "url", req.URL, "error", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
})
srv := &http.Server{Handler: mux}
go func() {
<-ctx.Done()
srv.Close()
}()
d.logger.Info("health server listening", "addr", ln.Addr().String())
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
d.logger.Warn("health server error", "error", err)
}
}