mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-23 18:17:44 +02:00
* 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>
101 lines
2.8 KiB
Go
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)
|
|
}
|
|
}
|