Files
multica/server/internal/daemon/health_test.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

101 lines
2.8 KiB
Go

package daemon
import (
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestHealthHandlerReportsCLIVersionAndActiveTaskCount(t *testing.T) {
t.Parallel()
d := &Daemon{
cfg: Config{
CLIVersion: "v9.9.9",
DaemonID: "daemon-test",
DeviceName: "dev",
ServerBaseURL: "http://localhost:8080",
},
workspaces: map[string]*workspaceState{},
logger: slog.Default(),
}
d.activeTasks.Store(3)
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rec := httptest.NewRecorder()
d.healthHandler(time.Now()).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
// Decode into a raw map so the test locks in the exact wire-level JSON
// keys — the desktop TS client depends on snake_case (cli_version,
// active_task_count), so a silent struct-tag rename must fail here.
var raw map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil {
t.Fatalf("decode raw response: %v", err)
}
if got, want := raw["cli_version"], "v9.9.9"; got != want {
t.Errorf("cli_version key: got %v, want %q", got, want)
}
// JSON numbers decode to float64 through map[string]any.
if got, want := raw["active_task_count"], float64(3); got != want {
t.Errorf("active_task_count key: got %v, want %v", got, want)
}
if got, want := raw["status"], "running"; got != want {
t.Errorf("status key: got %v, want %q", got, want)
}
// Also round-trip into the typed struct as a separate check that the
// field values match, independent of key naming.
var resp HealthResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode typed response: %v", err)
}
if resp.CLIVersion != "v9.9.9" {
t.Errorf("CLIVersion: got %q, want %q", resp.CLIVersion, "v9.9.9")
}
if resp.ActiveTaskCount != 3 {
t.Errorf("ActiveTaskCount: got %d, want 3", resp.ActiveTaskCount)
}
}
func TestHealthHandlerActiveTaskCountTracksCounter(t *testing.T) {
t.Parallel()
d := &Daemon{
cfg: Config{CLIVersion: "v1.0.0"},
workspaces: map[string]*workspaceState{},
logger: slog.Default(),
}
handler := d.healthHandler(time.Now())
// Simulate the pollLoop increment/decrement protocol.
d.activeTasks.Add(1)
d.activeTasks.Add(1)
assertActiveTaskCount(t, handler, 2)
d.activeTasks.Add(-1)
assertActiveTaskCount(t, handler, 1)
d.activeTasks.Add(-1)
assertActiveTaskCount(t, handler, 0)
}
func assertActiveTaskCount(t *testing.T, h http.HandlerFunc, want int64) {
t.Helper()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/health", nil))
var resp HealthResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.ActiveTaskCount != want {
t.Errorf("active_task_count: got %d, want %d", resp.ActiveTaskCount, want)
}
}