mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 13:06:20 +02:00
* perf(daemon): parallelize runtime version detection during registration (MUL-5119) Registration probed each agent CLI's `--version` serially, so total latency was the sum of every probe. On an onboarding host with several coding tools installed that stacked into many seconds before runtimes registered — long enough that the desktop runtime step timed out into its empty 'no runtime found' state while the daemon was still working. Fan the probes out with a bounded errgroup so total latency tracks the slowest single probe instead of their sum. Each probe still self-heals a vanished pinned path and re-detects the live version (no cross-registration caching, so an in-place upgrade is still reported correctly); failures are logged and skipped as before. Results are sorted by provider for a deterministic payload. Co-authored-by: multica-agent <github@multica.ai> * fix(onboarding): stop the runtime step flashing 'no runtime found' while the daemon probes (MUL-5119) The runtime step flipped from scanning to the empty 'no runtime found' state on a fixed 5s wall-clock, so a machine that does have coding tools installed saw a false-negative flash whenever registration outlasted the timeout (cold start, slow/wedged CLI, many CLIs). Gate the empty flip on a desktop-only `runtimesPending` signal derived from the local daemon's live status (booting, or running with agent CLIs detected on the host): while pending, keep the scanning skeleton past the soft timeout. An absolute hard-timeout ceiling still guarantees a fallback so a wedged probe can't pin the step on the skeleton forever. Web omits the signal and keeps the plain wall-clock timeout. Also drop the two dead/duplicated affordances on the step: the permanently disabled 'Start exploring' button now renders only in the found phase, and the empty state's duplicate footer 'Skip for now' is removed in favour of its own Skip card. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
113 lines
3.6 KiB
Go
113 lines
3.6 KiB
Go
package daemon
|
||
|
||
import (
|
||
"context"
|
||
"sync/atomic"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// TestDetectBuiltinRuntimes_ProbesRunConcurrently proves the registration
|
||
// version probes fan out instead of running serially (MUL-5119). Each stubbed
|
||
// `--version` probe blocks briefly and records the peak number of in-flight
|
||
// probes; a serial loop would never exceed 1 and would take N×block, while the
|
||
// parallel path overlaps them and finishes in roughly one block.
|
||
func TestDetectBuiltinRuntimes_ProbesRunConcurrently(t *testing.T) {
|
||
origDetect := detectAgentVersion
|
||
origCheck := checkAgentMinVersion
|
||
t.Cleanup(func() {
|
||
detectAgentVersion = origDetect
|
||
checkAgentMinVersion = origCheck
|
||
})
|
||
|
||
const probeBlock = 100 * time.Millisecond
|
||
var inFlight, maxInFlight int32
|
||
detectAgentVersion = func(_ context.Context, _ string) (string, error) {
|
||
cur := atomic.AddInt32(&inFlight, 1)
|
||
for {
|
||
prev := atomic.LoadInt32(&maxInFlight)
|
||
if cur <= prev || atomic.CompareAndSwapInt32(&maxInFlight, prev, cur) {
|
||
break
|
||
}
|
||
}
|
||
time.Sleep(probeBlock)
|
||
atomic.AddInt32(&inFlight, -1)
|
||
return "9.9.9", nil
|
||
}
|
||
checkAgentMinVersion = func(_, _ string) error { return nil }
|
||
|
||
d := freshDaemon("")
|
||
d.cfg.Agents = map[string]AgentEntry{
|
||
"claude": {Path: "/usr/bin/true"},
|
||
"codex": {Path: "/usr/bin/true"},
|
||
"cursor": {Path: "/usr/bin/true"},
|
||
"opencode": {Path: "/usr/bin/true"},
|
||
"hermes": {Path: "/usr/bin/true"},
|
||
"pi": {Path: "/usr/bin/true"},
|
||
}
|
||
|
||
start := time.Now()
|
||
runtimes := d.detectBuiltinRuntimes(context.Background())
|
||
elapsed := time.Since(start)
|
||
|
||
if len(runtimes) != len(d.cfg.Agents) {
|
||
t.Fatalf("expected %d runtimes, got %d", len(d.cfg.Agents), len(runtimes))
|
||
}
|
||
if got := atomic.LoadInt32(&maxInFlight); got < 2 {
|
||
t.Fatalf("probes did not overlap (peak in-flight = %d); registration is still serial", got)
|
||
}
|
||
if serialFloor := time.Duration(len(d.cfg.Agents)) * probeBlock; elapsed >= serialFloor {
|
||
t.Fatalf("detectBuiltinRuntimes took %v (>= serial floor %v); not parallel", elapsed, serialFloor)
|
||
}
|
||
|
||
// Output is sorted by provider so the registration payload is deterministic
|
||
// despite random map iteration and nondeterministic completion order.
|
||
for i := 1; i < len(runtimes); i++ {
|
||
if runtimes[i-1]["type"] > runtimes[i]["type"] {
|
||
t.Fatalf("runtimes not sorted by type: %q before %q", runtimes[i-1]["type"], runtimes[i]["type"])
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestDetectBuiltinRuntimes_SkipsFailedProbes confirms a probe that fails
|
||
// version detection or the min-version gate is dropped from the payload while
|
||
// the healthy ones still register — matching the old serial loop's semantics.
|
||
func TestDetectBuiltinRuntimes_SkipsFailedProbes(t *testing.T) {
|
||
origDetect := detectAgentVersion
|
||
origCheck := checkAgentMinVersion
|
||
t.Cleanup(func() {
|
||
detectAgentVersion = origDetect
|
||
checkAgentMinVersion = origCheck
|
||
})
|
||
|
||
detectAgentVersion = func(_ context.Context, path string) (string, error) {
|
||
if path == "/broken" {
|
||
return "", context.DeadlineExceeded
|
||
}
|
||
return "9.9.9", nil
|
||
}
|
||
checkAgentMinVersion = func(provider, _ string) error {
|
||
if provider == "tooold" {
|
||
return context.Canceled // stand-in for a below-minimum version
|
||
}
|
||
return nil
|
||
}
|
||
|
||
d := freshDaemon("")
|
||
d.cfg.Agents = map[string]AgentEntry{
|
||
"claude": {Path: "/usr/bin/true"},
|
||
"codex": {Path: "/usr/bin/true"},
|
||
"broken": {Path: "/broken"},
|
||
"tooold": {Path: "/usr/bin/true"},
|
||
}
|
||
|
||
runtimes := d.detectBuiltinRuntimes(context.Background())
|
||
got := map[string]bool{}
|
||
for _, rt := range runtimes {
|
||
got[rt["type"]] = true
|
||
}
|
||
if len(runtimes) != 2 || !got["claude"] || !got["codex"] {
|
||
t.Fatalf("expected only claude+codex to register, got %v", runtimes)
|
||
}
|
||
}
|