Files
multica/server/internal/daemon/runtime_probe_concurrency_test.go
Jiayuan Zhang e8746c5030 fix(onboarding): eliminate Desktop runtime-step false-negative flash + parallelize daemon version detection (MUL-5119) (#5756)
* 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>
2026-07-23 01:29:47 +08:00

113 lines
3.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}