mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 17: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]>
312 lines
12 KiB
Go
312 lines
12 KiB
Go
package daemon
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/multica-ai/multica/server/pkg/agent"
|
|
)
|
|
|
|
// stubNotExecutableProbe makes every version probe fail the way the OS fails a
|
|
// file it will not execute — an npm placeholder stub whose postinstall was
|
|
// blocked (MUL-6164) — wrapped exactly as detectCLIVersion wraps it, so the
|
|
// daemon sees the same error shape it sees in the field.
|
|
func stubNotExecutableProbe(t *testing.T, path string) {
|
|
t.Helper()
|
|
orig := detectAgentVersion
|
|
t.Cleanup(func() { detectAgentVersion = orig })
|
|
detectAgentVersion = func(_ context.Context, runtimeCmd agent.Command) (string, error) {
|
|
p := runtimeCmd.Path
|
|
enoexec := &fs.PathError{Op: "fork/exec", Path: p, Err: syscall.ENOEXEC}
|
|
return "", fmt.Errorf("detect version for %s: %w", p, agent.ExplainExecError(enoexec))
|
|
}
|
|
}
|
|
|
|
// stubConfirmWindow collapses the confirmation window so a second probe round
|
|
// counts as confirmation immediately. Two rounds are still required.
|
|
func stubConfirmWindow(t *testing.T, window time.Duration) {
|
|
t.Helper()
|
|
orig := notExecutableConfirmWindow
|
|
t.Cleanup(func() { notExecutableConfirmWindow = orig })
|
|
notExecutableConfirmWindow = window
|
|
}
|
|
|
|
// npmPackagedCLI stages the layout an npm-installed agent CLI has — a bin entry
|
|
// under a package whose manifest declares the postinstall that was supposed to
|
|
// replace it — so the daemon can derive a real repair command from it.
|
|
func npmPackagedCLI(t *testing.T) string {
|
|
t.Helper()
|
|
root := filepath.Join(t.TempDir(), "claude-code")
|
|
if err := os.MkdirAll(filepath.Join(root, "bin"), 0o755); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
manifest := `{"name":"@anthropic-ai/claude-code","scripts":{"postinstall":"node install.cjs"}}`
|
|
if err := os.WriteFile(filepath.Join(root, "package.json"), []byte(manifest), 0o644); err != nil {
|
|
t.Fatalf("write manifest: %v", err)
|
|
}
|
|
return filepath.Join(root, "bin", "claude.exe")
|
|
}
|
|
|
|
func notExecutableDaemon(t *testing.T) *Daemon {
|
|
t.Helper()
|
|
stubProbeRetry(t, time.Millisecond, time.Millisecond)
|
|
execPath := npmPackagedCLI(t)
|
|
stubNotExecutableProbe(t, execPath)
|
|
d := freshDaemon("")
|
|
d.cfg.Agents = map[string]AgentEntry{"claude": {Path: execPath, Command: "claude"}}
|
|
return d
|
|
}
|
|
|
|
// A CLI that cannot exec is a fact about the file on disk, so it must end up on
|
|
// the demotable side of the probe rather than the transient one — that is what
|
|
// takes the runtime offline instead of leaving it online to claim tasks it
|
|
// cannot run. The first sighting still waits: an installer overwriting the bin
|
|
// entry in place is briefly indistinguishable from a broken install.
|
|
func TestDetectBuiltinRuntimes_NotExecutableDemotesOnlyAfterConfirmation(t *testing.T) {
|
|
stubConfirmWindow(t, 0)
|
|
d := notExecutableDaemon(t)
|
|
|
|
runtimes, demotable, unavailable := d.detectBuiltinRuntimes(context.Background())
|
|
if len(runtimes) != 0 {
|
|
t.Fatalf("registered %v, want none: the CLI cannot run", runtimes)
|
|
}
|
|
if _, ok := demotable["claude"]; ok {
|
|
t.Errorf("first sighting demoted %v, want it held for confirmation", demotable)
|
|
}
|
|
if _, ok := unavailable["claude"]; !ok {
|
|
t.Errorf("first sighting = %v, want claude treated as transient for one round", unavailable)
|
|
}
|
|
|
|
_, demotable, unavailable = d.detectBuiltinRuntimes(context.Background())
|
|
if _, ok := unavailable["claude"]; ok {
|
|
t.Errorf("second sighting = %v, want claude condemned, not held again", unavailable)
|
|
}
|
|
verdict, ok := demotable["claude"]
|
|
if !ok {
|
|
t.Fatalf("second sighting did not condemn claude: %v", demotable)
|
|
}
|
|
// The reason is what /health shows and what the demotion logs, so it has to
|
|
// carry the repair instructions, not just the errno.
|
|
for _, want := range []string{"not executable", "postinstall"} {
|
|
if !strings.Contains(verdict.reason, want) {
|
|
t.Errorf("reason is missing %q, so the user still cannot act on it:\n%s", want, verdict.reason)
|
|
}
|
|
}
|
|
// And the structured half the server stores: without a code the admission
|
|
// paths cannot tell this apart from a sleeping laptop.
|
|
if verdict.offline == nil || verdict.offline.Code != RuntimeOfflineCodeNotExecutable {
|
|
t.Fatalf("verdict carries no offline reason for the server: %+v", verdict.offline)
|
|
}
|
|
if verdict.offline.Repair == nil || !strings.Contains(verdict.offline.Repair.Command, "install.cjs") {
|
|
t.Errorf("offline reason lost the repair command: %+v", verdict.offline.Repair)
|
|
}
|
|
}
|
|
|
|
// Two probes inside the window are two samples of the same instant as far as
|
|
// this verdict is concerned — an in-place install can span them.
|
|
func TestDetectBuiltinRuntimes_NotExecutableWaitsOutTheWindow(t *testing.T) {
|
|
stubConfirmWindow(t, time.Hour)
|
|
d := notExecutableDaemon(t)
|
|
|
|
for round := 1; round <= 3; round++ {
|
|
_, demotable, _ := d.detectBuiltinRuntimes(context.Background())
|
|
if _, ok := demotable["claude"]; ok {
|
|
t.Fatalf("round %d demoted inside the confirmation window", round)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The window is measured from the FIRST sighting, and a healthy probe resets it
|
|
// — otherwise an unrelated failure months later would inherit a stale clock and
|
|
// demote on its first sighting.
|
|
func TestConfirmNotExecutable_WindowStartsAtFirstSighting(t *testing.T) {
|
|
stubConfirmWindow(t, time.Minute)
|
|
d := freshDaemon("")
|
|
start := time.Now()
|
|
|
|
if d.confirmNotExecutable("claude", start) {
|
|
t.Fatal("first sighting confirmed the verdict on its own")
|
|
}
|
|
if d.confirmNotExecutable("claude", start.Add(59*time.Second)) {
|
|
t.Error("confirmed before the window elapsed")
|
|
}
|
|
if !d.confirmNotExecutable("claude", start.Add(time.Minute)) {
|
|
t.Error("did not confirm after the window elapsed")
|
|
}
|
|
|
|
d.clearNotExecutable("claude")
|
|
if d.confirmNotExecutable("claude", start.Add(time.Hour)) {
|
|
t.Error("a recovered provider must start a fresh window, not inherit the old one")
|
|
}
|
|
}
|
|
|
|
// The user-visible half of the feature: once the verdict is confirmed, the
|
|
// provider's runtimes actually go offline instead of staying online to claim
|
|
// tasks that die at fork/exec — and they come back on their own after the
|
|
// repair, without a daemon restart.
|
|
func TestRefreshAgentVersions_TakesNotExecutableRuntimeOfflineAndBack(t *testing.T) {
|
|
stubConfirmWindow(t, 0)
|
|
fx := newVersionRefreshFixture(t)
|
|
d := fx.daemon
|
|
|
|
// npm blocks the postinstall on the next `npm i -g`: same path, same
|
|
// executable bit, a placeholder stub where the binary used to be.
|
|
fx.setProbeErr(func(path string, _ int) error {
|
|
enoexec := &fs.PathError{Op: "fork/exec", Path: path, Err: syscall.ENOEXEC}
|
|
return fmt.Errorf("detect version for %s: %w", path, agent.ExplainExecError(enoexec))
|
|
})
|
|
|
|
d.refreshAgentVersions(context.Background()) // first sighting: held
|
|
for _, workspaceID := range []string{"ws-1", "ws-2"} {
|
|
if got := registeredProviders(t, d, workspaceID); len(got) != 1 {
|
|
t.Fatalf("%s providers = %v, want codex still online during confirmation", workspaceID, got)
|
|
}
|
|
}
|
|
|
|
d.refreshAgentVersions(context.Background()) // confirmed: demote
|
|
for _, workspaceID := range []string{"ws-1", "ws-2"} {
|
|
if got := registeredProviders(t, d, workspaceID); len(got) != 0 {
|
|
t.Errorf("%s providers = %v, want none: the CLI cannot run, so the runtime must not keep claiming tasks", workspaceID, got)
|
|
}
|
|
}
|
|
if reason := d.skippedAgentsSnapshot()["codex"]; !strings.Contains(reason, "not executable") {
|
|
t.Errorf("/health reason = %q, want it to say why the runtime went offline", reason)
|
|
}
|
|
|
|
// The user runs the repair. Nothing restarts; the converge path re-registers.
|
|
fx.setProbeErr(nil)
|
|
d.convergeRuntimeRegistrations(context.Background())
|
|
for _, workspaceID := range []string{"ws-1", "ws-2"} {
|
|
if got := registeredProviders(t, d, workspaceID); len(got) != 1 || got[0] != "codex" {
|
|
t.Errorf("%s providers after repair = %v, want [codex] back without a restart", workspaceID, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A provider that probes OK again clears its pending verdict, which is what
|
|
// makes recovery automatic: no runtime + no hold puts it back in
|
|
// providersMissingRuntimes for the converge path to register.
|
|
func TestDetectBuiltinRuntimes_HealthyProbeClearsPendingVerdict(t *testing.T) {
|
|
stubConfirmWindow(t, 0)
|
|
stubProbeRetry(t, time.Millisecond, time.Millisecond)
|
|
d := freshDaemon("")
|
|
d.cfg.Agents = map[string]AgentEntry{"claude": {Path: "/fake/claude", Command: "claude"}}
|
|
|
|
broken := true
|
|
orig := detectAgentVersion
|
|
t.Cleanup(func() { detectAgentVersion = orig })
|
|
detectAgentVersion = func(_ context.Context, runtimeCmd agent.Command) (string, error) {
|
|
p := runtimeCmd.Path
|
|
if broken {
|
|
enoexec := &fs.PathError{Op: "fork/exec", Path: p, Err: syscall.ENOEXEC}
|
|
return "", fmt.Errorf("detect version for %s: %w", p, agent.ExplainExecError(enoexec))
|
|
}
|
|
return "2.1.231", nil
|
|
}
|
|
|
|
d.detectBuiltinRuntimes(context.Background()) // first sighting: clock starts
|
|
broken = false
|
|
if runtimes, _, _ := d.detectBuiltinRuntimes(context.Background()); len(runtimes) != 1 {
|
|
t.Fatalf("repaired CLI registered %v, want one runtime", runtimes)
|
|
}
|
|
broken = true
|
|
if _, demotable, _ := d.detectBuiltinRuntimes(context.Background()); len(demotable) != 0 {
|
|
t.Errorf("a fresh failure demoted immediately (%v): the healthy probe should have reset the clock", demotable)
|
|
}
|
|
}
|
|
|
|
// The verdict has to survive the server losing it.
|
|
//
|
|
// A register whose REQUEST is still in flight across the verdict is upserted
|
|
// after it, and that upsert overwrites the runtime row's metadata wholesale —
|
|
// including the reason the demotion just stored. The local hold correctly
|
|
// refuses the response, but if the cleanup that takes the revived row offline
|
|
// again sends no reason, the server is left with a bare "offline". Every
|
|
// admission path then reads that as "the machine will come back" and goes right
|
|
// back to queueing, which is the behaviour this change exists to remove
|
|
// (MUL-6164).
|
|
func TestDeregisterRevivedRuntimes_ReattachesTheUnusableReason(t *testing.T) {
|
|
stubConfirmWindow(t, 0)
|
|
// Stable ids before the first registration: the interleave only exists when
|
|
// the stale response names the same row the demotion took offline.
|
|
fx := newVersionRefreshFixtureWith(t, func(fx *batchFixture) { fx.enableStableRuntimeIDs() })
|
|
d := fx.daemon
|
|
runtimeID := fx.runtimeIDFor("ws-1", "codex")
|
|
if runtimeID == "" {
|
|
t.Fatal("fixture did not expose a stable runtime id")
|
|
}
|
|
|
|
// Hold ONE register inside the server handler, before it upserts. That is
|
|
// the request the verdict has to survive; every later register passes
|
|
// straight through.
|
|
var once sync.Once
|
|
reached, release := make(chan struct{}), make(chan struct{})
|
|
fx.setRegisterGate(func(string) {
|
|
once.Do(func() {
|
|
close(reached)
|
|
<-release
|
|
})
|
|
})
|
|
|
|
inFlight := []map[string]string{{"name": "Codex", "type": "codex", "version": "9.9.9", "status": "online"}}
|
|
respCh := make(chan *RegisterResponse, 1)
|
|
go func() {
|
|
resp, err := d.registerBuiltinRuntimesForWorkspaceLocked(context.Background(), "ws-1", inFlight)
|
|
if err != nil {
|
|
respCh <- nil
|
|
return
|
|
}
|
|
respCh <- resp
|
|
}()
|
|
<-reached
|
|
|
|
// While it is held: the CLI breaks and the verdict lands, reason and all.
|
|
fx.setProbeErr(func(path string, _ int) error {
|
|
enoexec := &fs.PathError{Op: "fork/exec", Path: path, Err: syscall.ENOEXEC}
|
|
return fmt.Errorf("detect version for %s: %w", path, agent.ExplainExecError(enoexec))
|
|
})
|
|
d.refreshAgentVersions(context.Background()) // first sighting
|
|
d.refreshAgentVersions(context.Background()) // confirmed: demote
|
|
if _, ok := fx.runtimeOfflineReason(runtimeID); !ok {
|
|
t.Fatal("demotion did not record a reason on the runtime row (precondition)")
|
|
}
|
|
|
|
// The held register now upserts, wiping the reason and putting the row back
|
|
// online — the loss this test exists for.
|
|
close(release)
|
|
staleResp := <-respCh
|
|
if staleResp == nil {
|
|
t.Fatal("in-flight register failed")
|
|
}
|
|
if _, ok := fx.runtimeOfflineReason(runtimeID); ok {
|
|
t.Fatal("precondition: the late upsert should have cleared the stored reason")
|
|
}
|
|
|
|
_, revived, ok := d.mergeBuiltinRegisterResponse("ws-1", staleResp)
|
|
if !ok {
|
|
t.Fatal("merge of the late register response failed")
|
|
}
|
|
if len(revived.ids) == 0 {
|
|
t.Fatal("late response was rejected locally but named no row to take offline again")
|
|
}
|
|
|
|
d.deregisterRevivedRuntimes(context.Background(), "ws-1", revived)
|
|
|
|
reason, ok := fx.runtimeOfflineReason(runtimeID)
|
|
if !ok {
|
|
t.Fatal("the revived row went offline with no reason: admission would treat an unusable CLI as a " +
|
|
"sleeping machine and queue for it again")
|
|
}
|
|
if reason.Code != RuntimeOfflineCodeNotExecutable {
|
|
t.Errorf("re-attached reason = %q, want %q", reason.Code, RuntimeOfflineCodeNotExecutable)
|
|
}
|
|
}
|