mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 21:39:54 +02:00
* feat(daemon): auto-update CLI when idle (MUL-2100) Add a periodic poller that checks GitHub for a newer multica release every hour and self-updates when the daemon is idle, reusing the same brew-or-download upgrade path the Runtimes-page "Update" button already runs. - Refactor handleUpdate to call a shared runUpdate(target) helper so both server-triggered and auto-triggered upgrades go through the same brew detection + atomic replace + restart. - New autoUpdateLoop gates each tick on: opt-out flag, Desktop launch source, dev-build version, an in-flight update, and active tasks. The idle gate guarantees we never interrupt a running agent — busy ticks silently retry at the next interval. - Config: MULTICA_DAEMON_AUTO_UPDATE=false to disable (also via --no-auto-update), MULTICA_DAEMON_AUTO_UPDATE_INTERVAL to retune the poll period. - IsNewerVersion / IsReleaseVersion helpers in the cli package, with tests covering patch/minor/major bumps, dev-describe strings, and malformed input. - Daemon-side tests cover every skip path (updating, active tasks, fetch failure, no-newer) plus the success path that fires triggerRestart while keeping the updating flag held to the end. Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): close idle race + verify checksum in auto-update (MUL-2100) Two issues raised in PR #2679 review: 1. The first idle check in tryAutoUpdate only ran before the release-metadata fetch, so a poller that won the claim race during the fetch could end up handing handleTask a task that triggerRestart was about to cancel via root- ctx cancellation. Add a strict claim barrier: runRuntimePoller now tryEnterClaim()s before ClaimTask, and tryAutoUpdate flips pauseClaims under claimMu only after observing claimsInFlight + activeTasks == 0. Pollers that were already mid-claim hold claimsInFlight > 0, so the barrier refuses to engage and the update defers to the next tick. 2. The direct-download path replaced the running binary with whatever bytes GitHub returned, without checking checksums.txt. Pull the manifest first, buffer the archive, and reject on SHA-256 mismatch before extraction. The GoReleaser config already publishes checksums.txt; we just consume it. Also tighten parseReleaseVersion so it stops accepting dev-describe shapes like "v0.1.13-5-gabcdef0" through the patch trim, matching its docstring. The auto-update loop already guards on IsReleaseVersion, but the lenient parser was a footgun and the existing test name even said "not newer" while asserting the opposite. Tests: - TestTryAutoUpdate_DefersWhenClaimInFlightAtBarrier (new race coverage) - TestTryAutoUpdate_HoldsBarrierAcrossRestart / ReleasesBarrierOnUpgradeFailure - TestTryEnterClaim_RespectsBarrier - TestFindChecksumManifestAsset / TestParseChecksumManifest / TestVerifyAssetSHA256 - TestIsNewerVersion: dev-describe cases now expect false (matches docstring) Co-authored-by: multica-agent <github@multica.ai> * chore(daemon): default auto-update poll interval to 6h (MUL-2100) 1h was overly chatty for a release that lands at most a few times a week. Operators who want a different cadence can still set MULTICA_DAEMON_AUTO_UPDATE_INTERVAL or --auto-update-interval. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai>
259 lines
7.9 KiB
Go
259 lines
7.9 KiB
Go
package daemon
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"github.com/multica-ai/multica/server/internal/cli"
|
|
)
|
|
|
|
// newAutoUpdateTestDaemon returns a Daemon stripped to just the pieces
|
|
// tryAutoUpdate touches, plus a sentinel cancelFunc the test can assert on to
|
|
// detect that triggerRestart fired. The caller is expected to install its own
|
|
// runUpdateFn before calling tryAutoUpdate when it wants to exercise the
|
|
// upgrade-success path.
|
|
func newAutoUpdateTestDaemon(t *testing.T, currentVersion string) (*Daemon, *atomic.Int32) {
|
|
t.Helper()
|
|
var restartCalls atomic.Int32
|
|
d := &Daemon{
|
|
cfg: Config{CLIVersion: currentVersion, AutoUpdateEnabled: true},
|
|
logger: slog.Default(),
|
|
cancelFunc: func() {
|
|
restartCalls.Add(1)
|
|
},
|
|
}
|
|
d.runUpdateFn = func(string) (string, error) {
|
|
t.Fatalf("runUpdateFn called unexpectedly")
|
|
return "", nil
|
|
}
|
|
return d, &restartCalls
|
|
}
|
|
|
|
func withStubRelease(t *testing.T, release *cli.GitHubRelease, err error) {
|
|
t.Helper()
|
|
prev := fetchLatestRelease
|
|
fetchLatestRelease = func() (*cli.GitHubRelease, error) { return release, err }
|
|
t.Cleanup(func() { fetchLatestRelease = prev })
|
|
}
|
|
|
|
func TestTryAutoUpdate_SkipsWhenUpdating(t *testing.T) {
|
|
d, restartCalls := newAutoUpdateTestDaemon(t, "v0.1.13")
|
|
d.updating.Store(true)
|
|
withStubRelease(t, &cli.GitHubRelease{TagName: "v0.1.14"}, nil)
|
|
|
|
d.tryAutoUpdate(context.Background())
|
|
|
|
if restartCalls.Load() != 0 {
|
|
t.Fatalf("triggerRestart called while another update was in progress")
|
|
}
|
|
}
|
|
|
|
func TestTryAutoUpdate_SkipsWhenTasksRunning(t *testing.T) {
|
|
d, restartCalls := newAutoUpdateTestDaemon(t, "v0.1.13")
|
|
d.activeTasks.Store(1)
|
|
withStubRelease(t, &cli.GitHubRelease{TagName: "v0.1.14"}, nil)
|
|
|
|
d.tryAutoUpdate(context.Background())
|
|
|
|
if restartCalls.Load() != 0 {
|
|
t.Fatalf("triggerRestart fired with active tasks; auto-update must defer")
|
|
}
|
|
if d.updating.Load() {
|
|
t.Fatalf("updating flag should not have been claimed while tasks were running")
|
|
}
|
|
}
|
|
|
|
// TestTryAutoUpdate_DefersWhenClaimInFlightAtBarrier covers the race the
|
|
// review flagged: cheap pre-fetch idle check passes (activeTasks == 0), then
|
|
// during the release fetch a poller decides to claim and bumps
|
|
// claimsInFlight. trySetClaimBarrier must observe that and defer rather than
|
|
// proceed into runUpdate (which would lead to a triggerRestart cancelling
|
|
// the just-claimed task mid-run).
|
|
func TestTryAutoUpdate_DefersWhenClaimInFlightAtBarrier(t *testing.T) {
|
|
d, restartCalls := newAutoUpdateTestDaemon(t, "v0.1.13")
|
|
withStubRelease(t, &cli.GitHubRelease{TagName: "v0.1.14"}, nil)
|
|
|
|
d.claimsInFlight = 1 // poller is mid-ClaimTask while activeTasks is still 0
|
|
|
|
d.tryAutoUpdate(context.Background())
|
|
|
|
if restartCalls.Load() != 0 {
|
|
t.Fatalf("triggerRestart fired despite a claim being in flight at the barrier")
|
|
}
|
|
if d.updating.Load() {
|
|
t.Fatalf("updating flag must be released after a deferred upgrade so the next tick can retry")
|
|
}
|
|
if d.pauseClaims {
|
|
t.Fatalf("pauseClaims must be cleared after a deferred upgrade")
|
|
}
|
|
}
|
|
|
|
// TestTryAutoUpdate_HoldsBarrierAcrossRestart asserts the success path leaves
|
|
// pauseClaims set: process exit is imminent and clearing the barrier would
|
|
// open a window for a poller to claim a task that the imminent restart is
|
|
// about to cancel.
|
|
func TestTryAutoUpdate_HoldsBarrierAcrossRestart(t *testing.T) {
|
|
d, restartCalls := newAutoUpdateTestDaemon(t, "v0.1.13")
|
|
withStubRelease(t, &cli.GitHubRelease{TagName: "v0.1.14"}, nil)
|
|
d.runUpdateFn = func(string) (string, error) { return "upgraded", nil }
|
|
|
|
d.tryAutoUpdate(context.Background())
|
|
|
|
if restartCalls.Load() != 1 {
|
|
t.Fatalf("triggerRestart fired %d times, want 1", restartCalls.Load())
|
|
}
|
|
if !d.pauseClaims {
|
|
t.Fatalf("pauseClaims must remain set across the restart kick; got cleared")
|
|
}
|
|
}
|
|
|
|
// TestTryAutoUpdate_ReleasesBarrierOnUpgradeFailure asserts the failure path
|
|
// clears pauseClaims so the daemon can keep claiming tasks normally and
|
|
// retry the upgrade on the next tick.
|
|
func TestTryAutoUpdate_ReleasesBarrierOnUpgradeFailure(t *testing.T) {
|
|
d, restartCalls := newAutoUpdateTestDaemon(t, "v0.1.13")
|
|
withStubRelease(t, &cli.GitHubRelease{TagName: "v0.1.14"}, nil)
|
|
d.runUpdateFn = func(string) (string, error) {
|
|
return "brew network error", errors.New("brew upgrade failed")
|
|
}
|
|
|
|
d.tryAutoUpdate(context.Background())
|
|
|
|
if restartCalls.Load() != 0 {
|
|
t.Fatalf("triggerRestart fired despite upgrade failure")
|
|
}
|
|
if d.pauseClaims {
|
|
t.Fatalf("pauseClaims must be cleared after a failed upgrade so pollers resume claiming")
|
|
}
|
|
}
|
|
|
|
// TestTryEnterClaim_RespectsBarrier asserts the poller-side helper returns
|
|
// false while pauseClaims is held and that pairs of enter/exit balance the
|
|
// counter so a later barrier set sees idle.
|
|
func TestTryEnterClaim_RespectsBarrier(t *testing.T) {
|
|
d := &Daemon{}
|
|
|
|
if !d.tryEnterClaim() {
|
|
t.Fatal("tryEnterClaim should succeed when barrier is unset")
|
|
}
|
|
d.exitClaim()
|
|
if d.claimsInFlight != 0 {
|
|
t.Fatalf("claimsInFlight not balanced: %d", d.claimsInFlight)
|
|
}
|
|
|
|
if !d.trySetClaimBarrier() {
|
|
t.Fatal("trySetClaimBarrier should succeed when idle")
|
|
}
|
|
if d.tryEnterClaim() {
|
|
t.Fatal("tryEnterClaim must refuse while barrier is held")
|
|
}
|
|
d.releaseClaimBarrier()
|
|
if !d.tryEnterClaim() {
|
|
t.Fatal("tryEnterClaim should succeed after barrier release")
|
|
}
|
|
d.exitClaim()
|
|
}
|
|
|
|
func TestTryAutoUpdate_SkipsWhenFetchFails(t *testing.T) {
|
|
d, restartCalls := newAutoUpdateTestDaemon(t, "v0.1.13")
|
|
withStubRelease(t, nil, errors.New("network down"))
|
|
|
|
d.tryAutoUpdate(context.Background())
|
|
|
|
if restartCalls.Load() != 0 {
|
|
t.Fatalf("triggerRestart fired despite fetch failure")
|
|
}
|
|
}
|
|
|
|
func TestTryAutoUpdate_SkipsWhenNotNewer(t *testing.T) {
|
|
d, restartCalls := newAutoUpdateTestDaemon(t, "v0.1.13")
|
|
withStubRelease(t, &cli.GitHubRelease{TagName: "v0.1.13"}, nil)
|
|
|
|
d.tryAutoUpdate(context.Background())
|
|
|
|
if restartCalls.Load() != 0 {
|
|
t.Fatalf("triggerRestart fired even though latest == current")
|
|
}
|
|
}
|
|
|
|
func TestTryAutoUpdate_RunsUpgradeAndRestartsOnNewer(t *testing.T) {
|
|
d, restartCalls := newAutoUpdateTestDaemon(t, "v0.1.13")
|
|
withStubRelease(t, &cli.GitHubRelease{TagName: "v0.1.14"}, nil)
|
|
|
|
var upgradedTo string
|
|
d.runUpdateFn = func(target string) (string, error) {
|
|
upgradedTo = target
|
|
return "upgraded", nil
|
|
}
|
|
|
|
d.tryAutoUpdate(context.Background())
|
|
|
|
if upgradedTo != "v0.1.14" {
|
|
t.Fatalf("runUpdateFn called with %q, want v0.1.14", upgradedTo)
|
|
}
|
|
if restartCalls.Load() != 1 {
|
|
t.Fatalf("triggerRestart fired %d times, want 1", restartCalls.Load())
|
|
}
|
|
if !d.updating.Load() {
|
|
t.Fatalf("updating flag should remain set across the restart kick; got cleared")
|
|
}
|
|
}
|
|
|
|
func TestTryAutoUpdate_DoesNotRestartOnUpgradeFailure(t *testing.T) {
|
|
d, restartCalls := newAutoUpdateTestDaemon(t, "v0.1.13")
|
|
withStubRelease(t, &cli.GitHubRelease{TagName: "v0.1.14"}, nil)
|
|
|
|
d.runUpdateFn = func(string) (string, error) {
|
|
return "brew: network error", errors.New("brew upgrade failed")
|
|
}
|
|
|
|
d.tryAutoUpdate(context.Background())
|
|
|
|
if restartCalls.Load() != 0 {
|
|
t.Fatalf("triggerRestart fired despite upgrade failure")
|
|
}
|
|
if d.updating.Load() {
|
|
t.Fatalf("updating flag must be released after a failed upgrade so the next tick can retry")
|
|
}
|
|
}
|
|
|
|
func TestAutoUpdateLoop_EarlyExits(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
cfg Config
|
|
}{
|
|
{
|
|
name: "disabled by config",
|
|
cfg: Config{AutoUpdateEnabled: false, CLIVersion: "v0.1.13"},
|
|
},
|
|
{
|
|
name: "managed by desktop",
|
|
cfg: Config{AutoUpdateEnabled: true, CLIVersion: "v0.1.13", LaunchedBy: "desktop"},
|
|
},
|
|
{
|
|
name: "dev build",
|
|
cfg: Config{AutoUpdateEnabled: true, CLIVersion: "v0.1.13-235-gabcdef0"},
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
d := &Daemon{cfg: tt.cfg, logger: slog.Default()}
|
|
d.runUpdateFn = func(string) (string, error) {
|
|
t.Fatalf("runUpdateFn called from an early-exit code path")
|
|
return "", nil
|
|
}
|
|
withStubRelease(t, &cli.GitHubRelease{TagName: "v0.1.14"}, nil)
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
d.autoUpdateLoop(context.Background())
|
|
close(done)
|
|
}()
|
|
<-done
|
|
})
|
|
}
|
|
}
|