mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-11 16:36:32 +02:00
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>
116 lines
3.8 KiB
Go
116 lines
3.8 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) {
|
||
// /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)
|
||
}
|
||
}
|