From eacf33299a010e2919390a96275ccee38416cc90 Mon Sep 17 00:00:00 2001 From: devv-eve Date: Tue, 14 Apr 2026 21:15:10 -0700 Subject: [PATCH 1/2] feat(sidebar): replace user menu ellipsis with full-row popover (#1044) Remove the three-dot menu from the sidebar footer user profile. The entire row is now clickable and opens an upward popover showing the user's full name, email, and a logout button. Co-authored-by: Devv Co-authored-by: Claude Opus 4.6 (1M context) --- packages/views/layout/app-sidebar.tsx | 74 +++++++++++++++++---------- 1 file changed, 48 insertions(+), 26 deletions(-) diff --git a/packages/views/layout/app-sidebar.tsx b/packages/views/layout/app-sidebar.tsx index c1bf4d610..51f354bc7 100644 --- a/packages/views/layout/app-sidebar.tsx +++ b/packages/views/layout/app-sidebar.tsx @@ -27,7 +27,6 @@ import { SquarePen, CircleUser, FolderKanban, - Ellipsis, PinOff, Zap, } from "lucide-react"; @@ -57,6 +56,11 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@multica/ui/components/ui/dropdown-menu"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@multica/ui/components/ui/popover"; import { useAuthStore } from "@multica/core/auth"; import { useWorkspaceStore } from "@multica/core/workspace"; import { workspaceListOptions, myInvitationListOptions, workspaceKeys } from "@multica/core/workspace/queries"; @@ -477,33 +481,51 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
-
- -
-

- {user?.name} -

-

- {user?.email} -

-
- - - - - - + + + +
+

+ {user?.name} +

+

+ {user?.email} +

+
+
+ +
+ +
+

+ {user?.name} +

+

+ {user?.email} +

+
+
+
+
+ +
+ +
From 8030f1adbcdfea29ac7ac38bfef99a60c68e7dd0 Mon Sep 17 00:00:00 2001 From: LinYushen Date: Wed, 15 Apr 2026 12:19:01 +0800 Subject: [PATCH 2/2] feat(desktop): restart local daemon when bundled CLI version differs (#1041) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * 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) * 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) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- apps/desktop/package.json | 4 +- apps/desktop/src/main/daemon-manager.ts | 107 +++++++++++++++++- .../desktop/src/main/version-decision.test.ts | 88 ++++++++++++++ apps/desktop/src/main/version-decision.ts | 37 ++++++ apps/desktop/vitest.config.ts | 10 ++ pnpm-lock.yaml | 3 + server/internal/daemon/daemon.go | 3 + server/internal/daemon/health.go | 54 +++++---- server/internal/daemon/health_test.go | 100 ++++++++++++++++ 9 files changed, 382 insertions(+), 24 deletions(-) create mode 100644 apps/desktop/src/main/version-decision.test.ts create mode 100644 apps/desktop/src/main/version-decision.ts create mode 100644 apps/desktop/vitest.config.ts create mode 100644 server/internal/daemon/health_test.go diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b40377273..366d77f0d 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -13,6 +13,7 @@ "preview": "electron-vite preview", "package": "pnpm run bundle-cli && electron-builder", "lint": "eslint .", + "test": "vitest run", "postinstall": "electron-builder install-app-deps" }, "dependencies": { @@ -47,6 +48,7 @@ "react": "catalog:", "react-dom": "catalog:", "tailwindcss": "^4", - "typescript": "catalog:" + "typescript": "catalog:", + "vitest": "catalog:" } } diff --git a/apps/desktop/src/main/daemon-manager.ts b/apps/desktop/src/main/daemon-manager.ts index 71dd3e6b7..82ac270af 100644 --- a/apps/desktop/src/main/daemon-manager.ts +++ b/apps/desktop/src/main/daemon-manager.ts @@ -18,6 +18,7 @@ import { join } from "path"; import { homedir } from "os"; import type { DaemonStatus, DaemonPrefs } from "../shared/daemon-types"; import { ensureManagedCli, managedCliPath } from "./cli-bootstrap"; +import { decideVersionAction } from "./version-decision"; const DEFAULT_HEALTH_PORT = 19514; const POLL_INTERVAL_MS = 5_000; @@ -39,6 +40,11 @@ let getMainWindow: () => BrowserWindow | null = () => null; let operationInProgress = false; let cachedCliBinary: string | null | undefined = undefined; let cliResolvePromise: Promise | null = null; +let cachedCliBinaryVersion: string | null | undefined = undefined; +// Set when a CLI version mismatch was detected but the running daemon is +// busy executing tasks. The poll loop retries the check on each tick and +// fires the restart once active_task_count drops to 0. +let pendingVersionRestart = false; let targetApiBaseUrl: string | null = null; let activeProfile: ActiveProfile | null = null; @@ -132,6 +138,8 @@ interface HealthPayload { daemon_id?: string; device_name?: string; server_url?: string; + cli_version?: string; + active_task_count?: number; agents?: string[]; workspaces?: unknown[]; } @@ -361,6 +369,91 @@ async function resolveCliBinary(): Promise { } } +/** + * Reads the version of the currently resolved CLI binary by invoking + * `multica version --output json`. Cached for the process lifetime — the + * bundled binary doesn't change after `bundle-cli.mjs` runs at dev/build time. + * Returns null on any failure (unknown `go` at bundle time, broken binary, + * etc.) so callers can fail open. + */ +async function getCliBinaryVersion(): Promise { + if (cachedCliBinaryVersion !== undefined) return cachedCliBinaryVersion; + const bin = await resolveCliBinary(); + if (!bin) { + cachedCliBinaryVersion = null; + return null; + } + try { + const stdout = await new Promise((resolve, reject) => { + execFile( + bin, + ["version", "--output", "json"], + { timeout: 5_000 }, + (err, out) => { + if (err) reject(err); + else resolve(out); + }, + ); + }); + const parsed = JSON.parse(stdout) as { version?: string }; + cachedCliBinaryVersion = parsed.version ?? null; + } catch (err) { + console.warn("[daemon] failed to read CLI binary version:", err); + cachedCliBinaryVersion = null; + } + return cachedCliBinaryVersion; +} + +/** + * Compares the running daemon's `cli_version` against the CLI binary we + * would use to spawn a new one, and restarts only when safe. The decision + * logic itself is in `version-decision.ts` (pure, unit-tested); this + * wrapper handles the async plumbing and side effects. + * + * Restart is only fired when ALL of: + * - a daemon is actually running on the active profile's port + * - both sides report a version and the strings differ + * - `active_task_count` is 0 (no in-flight agent work would be killed) + * + * On a confirmed mismatch while the daemon is busy, `pendingVersionRestart` + * is set; the poll loop retries this function on each 5s tick and will fire + * the restart as soon as the daemon drains. + */ +async function ensureRunningDaemonVersionMatches(): Promise< + "restarted" | "deferred" | "ok" | "not_running" +> { + const active = await ensureActiveProfile(); + const running = await fetchHealthAtPort(active.port); + const bundled = await getCliBinaryVersion(); + const action = decideVersionAction(bundled, running); + + switch (action) { + case "not_running": + pendingVersionRestart = false; + return "not_running"; + case "ok": + pendingVersionRestart = false; + return "ok"; + case "defer": { + if (!pendingVersionRestart) { + const activeTasks = running?.active_task_count ?? 0; + console.log( + `[daemon] CLI version mismatch (bundled=${bundled} running=${running?.cli_version}); deferring restart until ${activeTasks} active task(s) finish`, + ); + } + pendingVersionRestart = true; + return "deferred"; + } + case "restart": + console.log( + `[daemon] CLI version mismatch (bundled=${bundled} running=${running?.cli_version}) — restarting daemon`, + ); + pendingVersionRestart = false; + await restartDaemon(); + return "restarted"; + } +} + /** * Exchange the user's JWT for a long-lived PAT via POST /api/tokens. The * daemon needs a PAT (or `mul_` / `mdt_` token) because JWTs expire in 30 @@ -582,6 +675,10 @@ async function pollOnce(): Promise { const status = await fetchHealth(); currentState = status.state; sendStatus(status); + // Retry a deferred version-mismatch restart once the daemon drains. + if (pendingVersionRestart && status.state === "running") { + void ensureRunningDaemonVersionMatches(); + } } function startPolling(): void { @@ -738,6 +835,9 @@ export function setupDaemonManager( ipcMain.handle("daemon:retry-install", async () => { cachedCliBinary = undefined; cliResolvePromise = null; + // A retry-install may land a new CLI at a different version; drop the + // cached version string so the next check re-reads the binary. + cachedCliBinaryVersion = undefined; await bootstrapCli(); }); ipcMain.handle("daemon:get-prefs", () => loadPrefs()); @@ -755,7 +855,12 @@ export function setupDaemonManager( const bin = await resolveCliBinary(); if (!bin) return; const health = await fetchHealth(); - if (health.state === "running") return; + if (health.state === "running") { + // Daemon is up but may be running an older CLI than the one we just + // bundled. Restart it so the new binary actually takes effect. + await ensureRunningDaemonVersionMatches(); + return; + } await startDaemon(); }); diff --git a/apps/desktop/src/main/version-decision.test.ts b/apps/desktop/src/main/version-decision.test.ts new file mode 100644 index 000000000..cfc109ff3 --- /dev/null +++ b/apps/desktop/src/main/version-decision.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from "vitest"; +import { decideVersionAction } from "./version-decision"; + +describe("decideVersionAction", () => { + it("returns not_running when health payload is null", () => { + expect(decideVersionAction("v1.0.0", null)).toBe("not_running"); + }); + + it("returns not_running when status is not 'running'", () => { + expect( + decideVersionAction("v1.0.0", { status: "stopped", cli_version: "v1.0.0" }), + ).toBe("not_running"); + }); + + it("returns ok when bundled version is unknown (fail safe)", () => { + expect( + decideVersionAction(null, { + status: "running", + cli_version: "v1.0.0", + active_task_count: 0, + }), + ).toBe("ok"); + }); + + it("returns ok when running daemon does not report cli_version (older daemon)", () => { + expect( + decideVersionAction("v1.0.0", { + status: "running", + active_task_count: 0, + }), + ).toBe("ok"); + }); + + it("returns ok when versions match exactly", () => { + expect( + decideVersionAction("v1.2.3", { + status: "running", + cli_version: "v1.2.3", + active_task_count: 5, + }), + ).toBe("ok"); + }); + + it("returns restart when versions differ and daemon is idle", () => { + expect( + decideVersionAction("v1.2.3", { + status: "running", + cli_version: "v1.2.2", + active_task_count: 0, + }), + ).toBe("restart"); + }); + + it("treats missing active_task_count as 0 (old daemon that still reports cli_version)", () => { + expect( + decideVersionAction("v1.2.3", { + status: "running", + cli_version: "v1.2.2", + }), + ).toBe("restart"); + }); + + it("returns defer when versions differ but daemon is busy", () => { + expect( + decideVersionAction("v1.2.3", { + status: "running", + cli_version: "v1.2.2", + active_task_count: 2, + }), + ).toBe("defer"); + }); + + it("transitions defer → restart as tasks drain", () => { + // Same bundled version across three observations while the daemon ages. + const bundled = "v2.0.0"; + const base = { status: "running", cli_version: "v1.9.0" } as const; + + expect( + decideVersionAction(bundled, { ...base, active_task_count: 3 }), + ).toBe("defer"); + expect( + decideVersionAction(bundled, { ...base, active_task_count: 1 }), + ).toBe("defer"); + expect( + decideVersionAction(bundled, { ...base, active_task_count: 0 }), + ).toBe("restart"); + }); +}); diff --git a/apps/desktop/src/main/version-decision.ts b/apps/desktop/src/main/version-decision.ts new file mode 100644 index 000000000..9a0029401 --- /dev/null +++ b/apps/desktop/src/main/version-decision.ts @@ -0,0 +1,37 @@ +// Pure decision logic for the daemon version-check flow. Kept in its own +// module so it can be unit-tested without mocking Electron, execFile, or +// the HTTP health probe. + +export interface VersionCheckHealth { + status?: string; + cli_version?: string; + active_task_count?: number; +} + +export type VersionAction = "restart" | "defer" | "ok" | "not_running"; + +/** + * Decides what the daemon-manager should do given the currently-resolved + * bundled CLI version and the latest /health payload. + * + * not_running: no daemon is up, nothing to do + * ok: versions match, OR either side is unknown (fail safe) + * defer: versions differ but the daemon is busy — wait for drain + * restart: versions differ and the daemon is idle — safe to restart + * + * Pure function: no I/O, no side effects, no module state. + */ +export function decideVersionAction( + bundled: string | null, + running: VersionCheckHealth | null, +): VersionAction { + if (!running || running.status !== "running") return "not_running"; + + const runningVersion = running.cli_version; + if (!bundled || !runningVersion) return "ok"; + if (runningVersion === bundled) return "ok"; + + const activeTasks = running.active_task_count ?? 0; + if (activeTasks > 0) return "defer"; + return "restart"; +} diff --git a/apps/desktop/vitest.config.ts b/apps/desktop/vitest.config.ts new file mode 100644 index 000000000..bd8c8f627 --- /dev/null +++ b/apps/desktop/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + include: ["src/**/*.test.ts"], + environment: "node", + passWithNoTests: true, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be2068659..b9406098f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -181,6 +181,9 @@ importers: typescript: specifier: 'catalog:' version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.1.0(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3))(vite@8.0.1(@types/node@25.5.0)(jiti@2.6.1)) apps/docs: dependencies: diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index 67ed58c02..faf7f5bf9 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -39,6 +39,7 @@ type Daemon struct { cancelFunc context.CancelFunc // set by Run(); called by triggerRestart restartBinary string // non-empty after a successful update; path to the new binary updating atomic.Bool // prevents concurrent update attempts + activeTasks atomic.Int64 // number of tasks currently in handleTask; exposed via /health } // New creates a new Daemon instance. @@ -649,8 +650,10 @@ func (d *Daemon) pollLoop(ctx context.Context) error { } d.logger.Info("task received", "task", shortID(task.ID), "target", taskTarget) wg.Add(1) + d.activeTasks.Add(1) go func(t Task) { defer wg.Done() + defer d.activeTasks.Add(-1) defer func() { <-sem }() d.handleTask(ctx, t) }(*task) diff --git a/server/internal/daemon/health.go b/server/internal/daemon/health.go index 55072e7b5..9c7705d9d 100644 --- a/server/internal/daemon/health.go +++ b/server/internal/daemon/health.go @@ -14,14 +14,16 @@ import ( // 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"` - Agents []string `json:"agents"` - Workspaces []healthWorkspace `json:"workspaces"` + 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 { @@ -49,11 +51,10 @@ type repoCheckoutRequest struct { TaskID string `json:"task_id"` } -// 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", func(w http.ResponseWriter, r *http.Request) { +// 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 { @@ -70,19 +71,28 @@ func (d *Daemon) serveHealth(ctx context.Context, ln net.Listener, startedAt tim } 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, - Agents: agents, - Workspaces: wsList, + 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 { diff --git a/server/internal/daemon/health_test.go b/server/internal/daemon/health_test.go new file mode 100644 index 000000000..70aece2e7 --- /dev/null +++ b/server/internal/daemon/health_test.go @@ -0,0 +1,100 @@ +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) + } +}