mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-10 18:36:45 +08:00
* fix(daemon): treat custom runtime fixed_args as the argv prefix (MUL-6260) A custom runtime profile configured as `ccms start q36` failed every task with `unknown option '-p'`. The profile's fixed_args were merged into ExecOptions.ExtraArgs, which each backend appends *after* the protocol flags it hardcodes, so the wrapper saw `-p` before its own `start` subcommand had been selected and rejected it (GH #7046). Ordering was only half the defect. ExtraArgs is opt-in — six of the twenty-one backends read it — so on the other fifteen families, including every ACP one, fixed_args were silently discarded rather than misordered. Model discovery and `--version` probing carried the profile's path but not its arguments, so even a fixed launch would have left the model picker and the reported version describing the wrapper instead of the CLI behind it. fixed_args now travel as agent.Config.LaunchPrefix and are spliced in directly after the executable, ahead of everything a backend builds. The prefix is applied by a new Command boundary in launch.go through which every runtime process in the package is constructed — launches, version probes and discovery alike — so a backend cannot forget it. A guard test fails the build if any file outside launch.go calls os/exec directly; distributed opt-in is what let ExtraArgs rot, and repeating it would rot the same way. Flag-style commands (`agent --model composer-2.5`) are unaffected: a CLI takes its own flags in any position, which is why prefix-first can be the single order rather than a per-runtime setting. One deliberate behaviour change. The prefix used to sit last and win every last-wins parse, so a profile pinning `--model` silently overrode the model chosen on the agent — the UI showed one model and ran another. Prefix-first inverts that: the more specific per-agent choice is the later token and wins. Overlaps are logged. Protocol-critical flags are filtered out of the prefix once in New(), which knows the family; positional tokens always pass through. Codex additionally strips `-c` overrides aimed at daemon-managed config namespaces, since those beat config.toml from any argv position. The order was never documented, and every shipped fixed_args example is flag-style, so this fills in a contract rather than breaking one. Co-authored-by: multica-agent <[email protected]> * fix(daemon): close the launch-prefix gaps found in review (MUL-6260) Four defects from the first round of review on the fixed_args-as-argv-prefix change. Hermes could escape its own task overlay. The profile selection was resolved from custom_args alone and stripped from custom_args alone, so a profile configured as `hermes -p research` kept that flag in the launch prefix and re-pointed HERMES_HOME past the overlay the daemon had just built. Resolution now parses the argv hermes actually sees — launch prefix then custom args, in process order — and the strip covers both regions. It also removes every selection rather than the first: taking out the first promotes the second, and two separately-configured argv regions make "two selections" an ordinary configuration rather than a user mistake. Positional tokens were not passing through as documented. filterLaunchPrefix delegated to filterCustomArgs, whose blocklists deliberately carry positional tokens — hermes and qwenpaw block `acp`, traecli blocks `acp`/`serve`, grok blocks `agent`/`stdio`/`serve` — because in custom_args those are someone re-issuing the backend's own subcommand. In a launch prefix the same token is the command's identity, so `hermes-wrapper acp tenant` was being rewritten to `tenant`. Only a leading `-` now makes a token eligible for the blocklist; a blocked flag still consumes its separate value so no stray value survives to be read as a subcommand. Codex fast mode lost to the prefix. `--disable fast_mode` beats `--enable fast_mode` regardless of argv order and `-c features.fast_mode=false` beats config.toml from any position, so prefix-first does not protect an explicitly selected priority tier. The removal half of enforceCodexFastMode is now a separate helper applied to the prefix too; the managed enable is still appended once, on the final args. Windows CI could not compile the daemon tests: the Command migration left an unused `path := runtimeCmd.Path` in a hook whose body never referenced it. Verified with GOOS=windows and GOOS=linux vet, which is what should have caught it the first time. Also softens the docs and the Command doc comment: flag-style prefixes usually parse the same in either position, but a CLI that separates global from subcommand flags can care, so the runtime docs now name the position change and tell operators with existing fixed_args to re-run one task after upgrading instead of promising the move is invisible. Co-authored-by: multica-agent <[email protected]> * fix(daemon): resolve and strip the Hermes profile from the real launch argv (MUL-6260) Second-round review found the Hermes profile chain still reasoning about a hand-assembled approximation of the command line instead of the command line itself. The backend launches `<prefix> acp <filtered custom args>`, but the daemon assembled `<prefix> <custom args>` — no `acp`, no filtering — and that token participates in Hermes' own argv scan. With fixed_args `--model` and custom_args `-p research`, the approximation reads `-p` as `--model`'s value and finds no selection, so the overlay is seeded from the default home; the real `--model acp -p research` skips `--model acp` and selects `research`. The selector was then stripped, so the task stayed inside the overlay — but an overlay built from the wrong profile, silently. Stripping had the matching defect, and this one was still an escape rather than a mismatch. A launch prefix ending in a bare `-p` captures the backend's `acp` as its profile value, so neither region holds a complete selection and per-region stripping removes nothing: `-p acp research --yolo` reaches Hermes intact and re-points HERMES_HOME straight out of the overlay. Both halves now go through one assembly. agent.HermesLaunchArgv builds exactly what hermesBackend.Execute builds — the backend calls the same helper, so they cannot drift — and the daemon resolves from it. StripHermesProfileSelectors strips against that same argv and hands surviving tokens back to the region they came from, iterating to a fixed point because removing one selection promotes the next. The `acp` token is never removed; the backend re-adds it, and dropping the flag that captured it is what breaks the selection. StripAllHermesProfileArgs, hermesLaunchArgs and hermesEffectiveArgs are gone: each was only ever correct on a complete argv, and keeping them would leave the same footgun for the next caller. Tests cover the case the review named (prefix ending in a value-taking flag, custom args opening with a selector), the straddling-selection escape, multiple occurrences across both regions, and an invalid value left untouched because Hermes discards it anyway. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: J <[email protected]> Co-authored-by: multica-agent <[email protected]>
253 lines
7.5 KiB
Go
253 lines
7.5 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestNewReturnsClaudeBackend(t *testing.T) {
|
|
t.Parallel()
|
|
b, err := New("claude", Config{ExecutablePath: "/nonexistent/claude"})
|
|
if err != nil {
|
|
t.Fatalf("New(claude) error: %v", err)
|
|
}
|
|
if _, ok := b.(*claudeBackend); !ok {
|
|
t.Fatalf("expected *claudeBackend, got %T", b)
|
|
}
|
|
}
|
|
|
|
func TestNewReturnsCodexBackend(t *testing.T) {
|
|
t.Parallel()
|
|
b, err := New("codex", Config{ExecutablePath: "/nonexistent/codex"})
|
|
if err != nil {
|
|
t.Fatalf("New(codex) error: %v", err)
|
|
}
|
|
if _, ok := b.(*codexBackend); !ok {
|
|
t.Fatalf("expected *codexBackend, got %T", b)
|
|
}
|
|
}
|
|
|
|
func TestNewReturnsCodebuddyBackend(t *testing.T) {
|
|
t.Parallel()
|
|
b, err := New("codebuddy", Config{ExecutablePath: "/nonexistent/codebuddy"})
|
|
if err != nil {
|
|
t.Fatalf("New(codebuddy) error: %v", err)
|
|
}
|
|
if _, ok := b.(*codebuddyBackend); !ok {
|
|
t.Fatalf("expected *codebuddyBackend, got %T", b)
|
|
}
|
|
}
|
|
|
|
func TestNewReturnsCopilotBackend(t *testing.T) {
|
|
t.Parallel()
|
|
b, err := New("copilot", Config{ExecutablePath: "/nonexistent/copilot"})
|
|
if err != nil {
|
|
t.Fatalf("New(copilot) error: %v", err)
|
|
}
|
|
if _, ok := b.(*copilotBackend); !ok {
|
|
t.Fatalf("expected *copilotBackend, got %T", b)
|
|
}
|
|
}
|
|
|
|
func TestNewReturnsQoderBackend(t *testing.T) {
|
|
t.Parallel()
|
|
b, err := New("qoder", Config{ExecutablePath: "/nonexistent/qodercli"})
|
|
if err != nil {
|
|
t.Fatalf("New(qoder) error: %v", err)
|
|
}
|
|
qoder, ok := b.(*qoderBackend)
|
|
if !ok {
|
|
t.Fatalf("expected *qoderBackend, got %T", b)
|
|
}
|
|
if qoder.defaultExecutable != "qodercli" {
|
|
t.Fatalf("default executable = %q, want qodercli", qoder.defaultExecutable)
|
|
}
|
|
}
|
|
|
|
func TestNewReturnsQoderCNBackend(t *testing.T) {
|
|
t.Parallel()
|
|
b, err := New("qoderclicn", Config{})
|
|
if err != nil {
|
|
t.Fatalf("New(qoderclicn) error: %v", err)
|
|
}
|
|
qoder, ok := b.(*qoderBackend)
|
|
if !ok {
|
|
t.Fatalf("expected *qoderBackend, got %T", b)
|
|
}
|
|
if qoder.defaultExecutable != "qoderclicn" {
|
|
t.Fatalf("default executable = %q, want qoderclicn", qoder.defaultExecutable)
|
|
}
|
|
}
|
|
|
|
func TestNewReturnsAntigravityBackend(t *testing.T) {
|
|
t.Parallel()
|
|
b, err := New("antigravity", Config{ExecutablePath: "/nonexistent/agy"})
|
|
if err != nil {
|
|
t.Fatalf("New(antigravity) error: %v", err)
|
|
}
|
|
if _, ok := b.(*antigravityBackend); !ok {
|
|
t.Fatalf("expected *antigravityBackend, got %T", b)
|
|
}
|
|
}
|
|
|
|
func TestNewRejectsUnknownType(t *testing.T) {
|
|
t.Parallel()
|
|
_, err := New("gpt", Config{})
|
|
if err == nil {
|
|
t.Fatal("expected error for unknown agent type")
|
|
}
|
|
}
|
|
|
|
func TestNewDefaultsLogger(t *testing.T) {
|
|
t.Parallel()
|
|
b, _ := New("claude", Config{})
|
|
cb := b.(*claudeBackend)
|
|
if cb.cfg.Logger == nil {
|
|
t.Fatal("expected non-nil logger")
|
|
}
|
|
}
|
|
|
|
func TestDetectVersionFailsForMissingBinary(t *testing.T) {
|
|
t.Parallel()
|
|
_, err := DetectVersion(context.Background(), Command{Path: "/nonexistent/binary"})
|
|
if err == nil {
|
|
t.Fatal("expected error for missing binary")
|
|
}
|
|
}
|
|
|
|
// TestDetectVersionTimesOutOnHang guards MUL-3812: a CLI whose `--version`
|
|
// never returns (e.g. a brew-installed claude wedged by a bun regression) must
|
|
// not stall version detection forever. The daemon detects every runtime's
|
|
// version sequentially inside its blocking preflight, so an unbounded probe
|
|
// would leave the daemon stuck "starting" and *every* runtime on the host
|
|
// disconnected. detectCLIVersion must bound the probe and return an error so
|
|
// the registration loop isolates the broken runtime and the rest still
|
|
// register. The script also leaves an orphaned child holding the stdout pipe
|
|
// open after the parent is killed, exercising the cmd.WaitDelay path.
|
|
func TestDetectVersionTimesOutOnHang(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("relies on a /bin/sh hang script")
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
script := filepath.Join(dir, "hang.sh")
|
|
pidFile := filepath.Join(dir, "child.pid")
|
|
// The CLI hangs forever (`wait`) and backgrounds a child that inherits and
|
|
// holds our stdout pipe open even after the parent is killed on timeout —
|
|
// the exact case cmd.WaitDelay must cover. The child records its PID so we
|
|
// can reap it in Cleanup instead of leaking a 60s `sleep` into CI.
|
|
body := fmt.Sprintf("#!/bin/sh\nsleep 60 &\necho $! > %q\nwait\n", pidFile)
|
|
if err := os.WriteFile(script, []byte(body), 0o755); err != nil {
|
|
t.Fatalf("write hang script: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
data, err := os.ReadFile(pidFile)
|
|
if err != nil {
|
|
return // child never recorded its PID; nothing to reap
|
|
}
|
|
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
|
|
if err != nil {
|
|
return
|
|
}
|
|
if proc, err := os.FindProcess(pid); err == nil {
|
|
_ = proc.Kill()
|
|
}
|
|
})
|
|
|
|
orig := detectVersionTimeout
|
|
detectVersionTimeout = 200 * time.Millisecond
|
|
t.Cleanup(func() { detectVersionTimeout = orig })
|
|
|
|
done := make(chan error, 1)
|
|
start := time.Now()
|
|
go func() {
|
|
_, err := DetectVersion(context.Background(), Command{Path: script})
|
|
done <- err
|
|
}()
|
|
|
|
select {
|
|
case err := <-done:
|
|
if err == nil {
|
|
t.Fatal("expected an error from a hanging --version probe, got nil")
|
|
}
|
|
if elapsed := time.Since(start); elapsed > 5*time.Second {
|
|
t.Fatalf("detection took %v; expected it to be bounded by the timeout", elapsed)
|
|
}
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("DetectVersion did not return: version probe is unbounded (regression of MUL-3812)")
|
|
}
|
|
}
|
|
|
|
func TestLaunchHeaderCoversAllSupportedBackends(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// SupportedTypes is the canonical factory/profile whitelist. Iterating it
|
|
// directly prevents this coverage check from drifting when a backend is
|
|
// added, so the UI preview always has a launch skeleton.
|
|
for _, t_ := range SupportedTypes {
|
|
if header := LaunchHeader(t_); header == "" {
|
|
t.Errorf("LaunchHeader(%q) returned empty string — add it to launchHeaders", t_)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLaunchHeaderAntigravityAvoidsTextOnlyPrintModeLabel(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
header := LaunchHeader("antigravity")
|
|
if header != "agy -p (non-interactive)" {
|
|
t.Fatalf("unexpected Antigravity launch header: %q", header)
|
|
}
|
|
if strings.Contains(header, "print mode") {
|
|
t.Fatalf("Antigravity launch header must not imply a text-only mode: %q", header)
|
|
}
|
|
}
|
|
|
|
func TestLaunchHeaderReturnsEmptyForUnknownType(t *testing.T) {
|
|
t.Parallel()
|
|
if header := LaunchHeader("made-up-agent"); header != "" {
|
|
t.Errorf("expected empty header for unknown type, got %q", header)
|
|
}
|
|
}
|
|
|
|
func TestRunContextZeroTimeoutHasNoDeadline(t *testing.T) {
|
|
t.Parallel()
|
|
// A zero (or negative) timeout must NOT impose a wall-clock deadline:
|
|
// liveness is delegated to the daemon's inactivity watchdog so an actively
|
|
// streaming long-running session is never killed merely for running long
|
|
// (MUL-3064).
|
|
for _, d := range []time.Duration{0, -time.Second} {
|
|
ctx, cancel := runContext(context.Background(), d)
|
|
if _, ok := ctx.Deadline(); ok {
|
|
cancel()
|
|
t.Fatalf("runContext(%s) imposed a deadline; want none", d)
|
|
}
|
|
cancel()
|
|
if ctx.Err() == nil {
|
|
t.Fatalf("runContext(%s): context should be cancelled after cancel()", d)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunContextPositiveTimeoutHasDeadline(t *testing.T) {
|
|
t.Parallel()
|
|
// A positive timeout keeps the hard wall-clock deadline (the opt-in
|
|
// absolute cap operators can still set via MULTICA_AGENT_TIMEOUT).
|
|
ctx, cancel := runContext(context.Background(), time.Hour)
|
|
defer cancel()
|
|
deadline, ok := ctx.Deadline()
|
|
if !ok {
|
|
t.Fatal("runContext(1h) should impose a deadline")
|
|
}
|
|
if remaining := time.Until(deadline); remaining <= 0 || remaining > time.Hour+time.Minute {
|
|
t.Fatalf("unexpected deadline remaining: %s", remaining)
|
|
}
|
|
}
|