mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-12 19:06:06 +02:00
* perf(agents): make runtime model discovery fast on runtime switch (MUL-5444) Switching runtime in the agent creation form left the model picker spinning for ~8-20s. Two costs stacked up: - the list-models request sat in the store until the daemon's next scheduled heartbeat (0-15s, avg 7.5s of pure dead wait), and - the daemon then enumerated the catalog locally (static for claude, but a CLI/ACP round trip up to ~15s for everyone else). Both are addressed with the two standard techniques for a slow, low-frequency, read-only operation: push instead of poll, and stale-while-revalidate. Push (removes the heartbeat wait): - new additive `daemon:pending_work` hint, runtime-scoped, delivered through the existing daemon WS hub and the Redis relay so the API node holding the socket does the delivery. - the daemon answers a hint with ONE immediate heartbeat and dispatches what it claimed. The hint deliberately carries no work, so nothing has to be un-claimed when delivery fails and a duplicate hint cannot duplicate work - PopPending stays the atomic claim. - per-runtime coalescing plus a 1s floor keeps a caller-triggered hint from becoming a heartbeat amplifier. Cache (removes the discovery wait on repeat opens): - server-side per-runtime catalog cache (in-memory single-node, Redis multi-node) written on every successful report. - a snapshot younger than 15min answers the POST immediately as an already-completed request; older than 60s it also enqueues a background refresh that only warms the cache. - only supported, non-empty catalogs are cached; a completed-but-empty report invalidates instead, while a failed report keeps serving the last known good list. Frontend: staleTime 60s -> 5min and gcTime 30min, so a runtime revisited in the same session renders from cache and revalidates in the background instead of showing the spinner again. Compatibility: every wire change is additive. Old daemons ignore the unknown hint type and keep using the scheduled heartbeat; new daemons against an old server simply never receive one. The cached response is shaped exactly like a completed live discovery apart from the optional `cached` / `cached_at` markers. Co-authored-by: multica-agent <github@multica.ai> * fix(agents): address review on model discovery SWR (MUL-5444) Sol-Boy's review on #6098 found the client cache could outlive the server's own staleness promise, and that the two changed endpoints were still cast rather than validated. Must-fix 1 — client freshness now derives from the served answer. `staleTime` was a flat 5min, so a 14-minute-old snapshot (which the server returns while queueing its own refresh) was held as fresh for another 5min: observable staleness became server window + client window, and the refreshed catalog never reached the tab that triggered the refresh. `staleTime` is now a function of the query data: a `cached` answer is stale on arrival (bound stays the server's window alone, and the next mount/focus picks up the refreshed snapshot), while a live discovery — which just measured the truth — is trusted for the full 5min so a cold runtime is never re-enumerated inside one form session. `gcTime` stays 30min, so a revisited runtime still renders from cache and revalidates in the background; the pickers gate their spinner on `isLoading`, which stays false throughout. Must-fix 2 — both model-discovery responses go through a zod schema. `POST /api/runtimes/{id}/models` and its poll companion were casting network JSON to `RuntimeModelListRequest`, which the root CLAUDE.md API-compatibility rules forbid. Added a lenient schema (`status` stays `z.string()`, `supported` defaults to true, `.loose()` keeps unknown fields) plus a fallback record whose `status` is `failed`: a malformed body now surfaces "discovery failed" with manual entry still usable instead of a fabricated empty catalog or an endless spinner. `resolveRuntimeModels` was tightened to match — only an explicit `completed` is a catalog, so an unrecognised status is an error rather than a silent empty list, and `supported` can no longer be `undefined`. Nit — the in-memory catalog cache now deep-copies each entry's `Thinking` (and its level slice) and `ServiceTiers`, so it delivers the independent value its comment promises and matches the Redis backend's JSON round-trip semantics. Tests: staleTime policy for cached/live/no-data; a QueryObserver test proving the refreshed catalog reaches the same client with no blank loading state; unknown-status and omitted-`supported` handling; schema tests for live, cached, old-backend and nine malformed shapes; client tests that both endpoints degrade to an explicit failure; nested-field mutation isolation for the cache. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai>
187 lines
6.4 KiB
Go
187 lines
6.4 KiB
Go
package daemon
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/multica-ai/multica/server/pkg/protocol"
|
|
)
|
|
|
|
// pendingWorkHintDaemon builds a Daemon that knows exactly one runtime and
|
|
// talks to an httptest server, so the hint path is exercised against the real
|
|
// daemon.Client instead of a mock. The per-runtime hint floor is disabled by
|
|
// default; TestHandlePendingWorkHint_ThrottlesRepeatHints restores it.
|
|
func pendingWorkHintDaemon(t *testing.T, handler http.HandlerFunc) (*Daemon, *int32) {
|
|
t.Helper()
|
|
withPendingWorkHintMinInterval(t, 0)
|
|
var calls int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
atomic.AddInt32(&calls, 1)
|
|
handler(w, r)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
d := &Daemon{
|
|
cfg: Config{HeartbeatInterval: time.Hour},
|
|
client: NewClient(srv.URL),
|
|
logger: slog.Default(),
|
|
runtimeIndex: map[string]Runtime{"rt-1": {ID: "rt-1", Provider: "claude"}},
|
|
pendingWorkInflight: make(map[string]struct{}),
|
|
pendingWorkLastRun: make(map[string]time.Time),
|
|
}
|
|
return d, &calls
|
|
}
|
|
|
|
func withPendingWorkHintMinInterval(t *testing.T, d time.Duration) {
|
|
t.Helper()
|
|
prev := pendingWorkHintMinInterval
|
|
pendingWorkHintMinInterval = d
|
|
t.Cleanup(func() { pendingWorkHintMinInterval = prev })
|
|
}
|
|
|
|
// TestHandlePendingWorkHint_SendsImmediateHeartbeat is the core of MUL-5444:
|
|
// a server-pushed hint must produce a heartbeat right now instead of leaving the
|
|
// queued model-list request to wait for the next scheduled tick (up to a full
|
|
// HeartbeatInterval, 15s by default).
|
|
func TestHandlePendingWorkHint_SendsImmediateHeartbeat(t *testing.T) {
|
|
var gotPath, gotRuntime string
|
|
d, calls := pendingWorkHintDaemon(t, func(w http.ResponseWriter, r *http.Request) {
|
|
gotPath = r.URL.Path
|
|
var body struct {
|
|
RuntimeID string `json:"runtime_id"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
gotRuntime = body.RuntimeID
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"runtime_id":"rt-1","status":"ok"}`))
|
|
})
|
|
|
|
d.handlePendingWorkHint("rt-1", protocol.PendingWorkKindModelList)
|
|
|
|
if got := atomic.LoadInt32(calls); got != 1 {
|
|
t.Fatalf("expected exactly 1 heartbeat, got %d", got)
|
|
}
|
|
if gotPath != "/api/daemon/heartbeat" {
|
|
t.Fatalf("heartbeat path = %q", gotPath)
|
|
}
|
|
if gotRuntime != "rt-1" {
|
|
t.Fatalf("heartbeat runtime_id = %q, want rt-1", gotRuntime)
|
|
}
|
|
}
|
|
|
|
// TestHandlePendingWorkHint_IgnoresUnknownRuntime keeps a stale or
|
|
// cross-machine relay fanout from making this daemon heartbeat for a runtime it
|
|
// does not own.
|
|
func TestHandlePendingWorkHint_IgnoresUnknownRuntime(t *testing.T) {
|
|
d, calls := pendingWorkHintDaemon(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"runtime_id":"other","status":"ok"}`))
|
|
})
|
|
|
|
d.handlePendingWorkHint("not-mine", protocol.PendingWorkKindModelList)
|
|
d.handlePendingWorkHint("", protocol.PendingWorkKindModelList)
|
|
|
|
if got := atomic.LoadInt32(calls); got != 0 {
|
|
t.Fatalf("expected no heartbeat for an unknown runtime, got %d", got)
|
|
}
|
|
}
|
|
|
|
// TestHandlePendingWorkHint_CoalescesConcurrentHints pins the stampede guard:
|
|
// several UI surfaces (model picker, thinking level, service tier) each request
|
|
// the catalog for the same runtime within milliseconds, and one heartbeat
|
|
// already claims whatever is queued.
|
|
func TestHandlePendingWorkHint_CoalescesConcurrentHints(t *testing.T) {
|
|
release := make(chan struct{})
|
|
arrived := make(chan struct{}, 1)
|
|
d, calls := pendingWorkHintDaemon(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
select {
|
|
case arrived <- struct{}{}:
|
|
default:
|
|
}
|
|
<-release
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"runtime_id":"rt-1","status":"ok"}`))
|
|
})
|
|
|
|
var wg sync.WaitGroup
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
d.handlePendingWorkHint("rt-1", protocol.PendingWorkKindModelList)
|
|
}()
|
|
|
|
// Wait until the first hint is actually inside the HTTP call, then fire the
|
|
// siblings that must be dropped.
|
|
select {
|
|
case <-arrived:
|
|
case <-time.After(2 * time.Second):
|
|
close(release)
|
|
wg.Wait()
|
|
t.Fatal("first hint never reached the server")
|
|
}
|
|
for i := 0; i < 3; i++ {
|
|
d.handlePendingWorkHint("rt-1", protocol.PendingWorkKindModelList)
|
|
}
|
|
close(release)
|
|
wg.Wait()
|
|
|
|
if got := atomic.LoadInt32(calls); got != 1 {
|
|
t.Fatalf("expected concurrent hints to coalesce into 1 heartbeat, got %d", got)
|
|
}
|
|
|
|
// The guard must release afterwards, otherwise the runtime would ignore
|
|
// every later hint for the lifetime of the process.
|
|
d.handlePendingWorkHint("rt-1", protocol.PendingWorkKindModelList)
|
|
if got := atomic.LoadInt32(calls); got != 2 {
|
|
t.Fatalf("expected a later hint to heartbeat again, got %d total", got)
|
|
}
|
|
}
|
|
|
|
// TestHandlePendingWorkHint_ThrottlesRepeatHints pins the amplification guard:
|
|
// the hint is triggered by any workspace member hitting the list-models
|
|
// endpoint, so back-to-back requests must not turn into back-to-back heartbeats.
|
|
func TestHandlePendingWorkHint_ThrottlesRepeatHints(t *testing.T) {
|
|
d, calls := pendingWorkHintDaemon(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"runtime_id":"rt-1","status":"ok"}`))
|
|
})
|
|
withPendingWorkHintMinInterval(t, time.Minute)
|
|
|
|
for i := 0; i < 5; i++ {
|
|
d.handlePendingWorkHint("rt-1", protocol.PendingWorkKindModelList)
|
|
}
|
|
|
|
if got := atomic.LoadInt32(calls); got != 1 {
|
|
t.Fatalf("expected repeat hints inside the floor to collapse into 1 heartbeat, got %d", got)
|
|
}
|
|
|
|
// Past the floor, a hint is served again — the throttle is a floor, not a
|
|
// one-shot latch.
|
|
withPendingWorkHintMinInterval(t, time.Nanosecond)
|
|
time.Sleep(2 * time.Millisecond)
|
|
d.handlePendingWorkHint("rt-1", protocol.PendingWorkKindModelList)
|
|
if got := atomic.LoadInt32(calls); got != 2 {
|
|
t.Fatalf("expected a hint past the floor to heartbeat, got %d total", got)
|
|
}
|
|
}
|
|
|
|
// TestHandlePendingWorkHint_SurvivesHeartbeatFailure documents that a failed
|
|
// hint is a no-op: the scheduled heartbeat remains the correctness path, so the
|
|
// daemon must not panic or retry-storm here.
|
|
func TestHandlePendingWorkHint_SurvivesHeartbeatFailure(t *testing.T) {
|
|
d, calls := pendingWorkHintDaemon(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
http.Error(w, `{"error":"boom"}`, http.StatusInternalServerError)
|
|
})
|
|
|
|
d.handlePendingWorkHint("rt-1", protocol.PendingWorkKindModelList)
|
|
|
|
if got := atomic.LoadInt32(calls); got != 1 {
|
|
t.Fatalf("expected exactly 1 attempt, got %d", got)
|
|
}
|
|
}
|