Files
multica/server/internal/daemon/runtime_probe_concurrency_test.go
Bohan Jiang 7d2d745d9d fix(daemon): probe built-in agent CLIs once per registration batch (MUL-5225) (#5842)
Built-in agent CLIs are a machine-level fact, but runtime registration is
per-workspace, and every registration re-ran `<cli> --version` for every
configured agent. A daemon serving N workspaces spawned N×M probe processes at
startup instead of M (24 workspaces × 5 agents = 120 instead of 5).

- Detect the built-in payload once per workspace-sync batch and reuse it for
  every workspace that sync registers. Nothing is cached on the Daemon, so
  standalone re-registrations still re-probe and an in-place CLI upgrade is
  still reported with its current version. The probe is lazy: a steady-state
  sync with nothing to register never shells out.
- Retry a failed provider's probe once inside the round before dropping it,
  so one transient failure can't cost every workspace in the batch that
  runtime. Only failed providers retry, so the round stays O(M).
- Time each attempt across resolve + detect, so a slow probe inside the
  MUL-4486 self-heal is not misread as a fast failure and retried.

Fixes #5837

Co-authored-by: multica-agent <github@multica.ai>
2026-07-27 18:39:57 +08:00

116 lines
3.8 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) {
// /broken fails fast, so it earns its bounded retry before being dropped;
// shrink the retry delay so this test doesn't wait out the real one.
stubProbeRetry(t, time.Millisecond, time.Second)
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)
}
}