diff --git a/config/agents.yaml b/config/agents.yaml index b1486d2..040e2d1 100644 --- a/config/agents.yaml +++ b/config/agents.yaml @@ -6,8 +6,21 @@ dispatch: default_agent: claude # Default prompt template default_template: coding - # Workspace root (relative to this file's parent) + # Workspace root. Absolute paths used as-is. + # Relative paths resolve against $HOME/Code (e.g. ".core/workspace" → "$HOME/Code/.core/workspace"). workspace_root: .core/workspace + # Container runtime — auto | apple | docker | podman. + # auto picks the first available runtime in preference order: + # Apple Container (macOS 26+) → Docker → Podman. + # CORE_AGENT_RUNTIME env var overrides this for ad-hoc dispatch. + runtime: auto + # Default container image for non-native agent dispatch. + # Built by go-build LinuxKit (core-dev, core-ml, core-minimal). + # AGENT_DOCKER_IMAGE env var overrides this for ad-hoc dispatch. + image: core-dev + # GPU passthrough — Metal on Apple Containers (when available), + # NVIDIA on Docker via --gpus=all. Default false. + gpu: false # Per-agent concurrency limits (0 = unlimited) concurrency: @@ -77,7 +90,12 @@ agents: active: true roles: [worker, review] clotho: - host: remote + host: local runner: claude - active: false + active: true + roles: [review, qa] + codex: + host: cloud + runner: openai + active: true roles: [worker] diff --git a/pkg/agentic/actions.go b/pkg/agentic/actions.go index 16a9f22..76b6aba 100644 --- a/pkg/agentic/actions.go +++ b/pkg/agentic/actions.go @@ -395,6 +395,25 @@ func (s *PrepSubsystem) handlePRClose(ctx context.Context, options core.Options) return s.cmdPRClose(normaliseForgeActionOptions(options)) } +// result := c.Action("agentic.branch.delete").Run(ctx, core.NewOptions( +// +// core.Option{Key: "repo", Value: "go-io"}, +// core.Option{Key: "branch", Value: "agent/fix-tests"}, +// +// )) +func (s *PrepSubsystem) handleBranchDelete(ctx context.Context, options core.Options) core.Result { + input := DeleteBranchInput{ + Org: optionStringValue(options, "org"), + Repo: optionStringValue(options, "repo", "_arg"), + Branch: optionStringValue(options, "branch"), + } + _, out, err := s.deleteBranch(ctx, nil, input) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: out, OK: true} +} + // result := c.Action("agentic.review-queue").Run(ctx, core.NewOptions( // // core.Option{Key: "workspace", Value: "core/go-io/task-5"}, diff --git a/pkg/agentic/commands_forge.go b/pkg/agentic/commands_forge.go index 1dcb9bd..083e45b 100644 --- a/pkg/agentic/commands_forge.go +++ b/pkg/agentic/commands_forge.go @@ -138,6 +138,8 @@ func (s *PrepSubsystem) registerForgeCommands() { c.Command("agentic:repo/list", core.Command{Description: "List Forge repos for an org", Action: s.cmdRepoList}) c.Command("repo/sync", core.Command{Description: "Fetch and optionally reset a local repo from origin", Action: s.cmdRepoSync}) c.Command("agentic:repo/sync", core.Command{Description: "Fetch and optionally reset a local repo from origin", Action: s.cmdRepoSync}) + c.Command("branch/delete", core.Command{Description: "Delete a branch on Forge", Action: s.cmdBranchDelete}) + c.Command("agentic:branch/delete", core.Command{Description: "Delete a branch on Forge", Action: s.cmdBranchDelete}) } func (s *PrepSubsystem) cmdIssueGet(options core.Options) core.Result { @@ -628,3 +630,33 @@ func (s *PrepSubsystem) currentBranch(repoDir string) string { } return core.Trim(result.Value.(string)) } + +// result := c.Command("branch/delete").Run(core.NewOptions( +// +// core.Option{Key: "_arg", Value: "go-io"}, +// core.Option{Key: "branch", Value: "agent/fix-tests"}, +// core.Option{Key: "org", Value: "core"}, +// +// )) +func (s *PrepSubsystem) cmdBranchDelete(options core.Options) core.Result { + ctx := context.Background() + org, repo, _ := parseForgeArgs(options) + branch := options.String("branch") + if repo == "" || branch == "" { + core.Print(nil, "usage: core-agent branch delete --branch=agent/fix-tests [--org=core]") + return core.Result{Value: core.E("agentic.cmdBranchDelete", "repo and branch are required", nil), OK: false} + } + + _, output, err := s.deleteBranch(ctx, nil, DeleteBranchInput{ + Org: org, + Repo: repo, + Branch: branch, + }) + if err != nil { + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + core.Print(nil, "deleted %s/%s@%s", output.Org, output.Repo, output.Branch) + return core.Result{Value: output, OK: true} +} diff --git a/pkg/agentic/dispatch.go b/pkg/agentic/dispatch.go index a8a46e9..e9fafd8 100644 --- a/pkg/agentic/dispatch.go +++ b/pkg/agentic/dispatch.go @@ -4,6 +4,8 @@ package agentic import ( "context" + "os/exec" + "runtime" "time" "dappco.re/go/agent/pkg/messages" @@ -160,17 +162,6 @@ func agentCommandResult(agent, prompt string) core.Result { } } -// isNativeAgent returns true if the agent should run natively (not in Docker). -// Claude agents need direct filesystem access, MCP tools, and native binary execution. -// -// isNativeAgent("claude") // true -// isNativeAgent("claude:opus") // true -// isNativeAgent("codex") // false (runs in Docker) -func isNativeAgent(agent string) bool { - parts := core.SplitN(agent, ":", 2) - return parts[0] == "claude" -} - // isLEMProfile returns true if the model name is a known LEM profile // (lemer, lemma, lemmy, lemrd) configured in codex config.toml. // @@ -207,22 +198,193 @@ func shellQuote(value string) string { const defaultDockerImage = "core-dev" +// Container runtime identifiers used by dispatch to route agent containers to +// the correct backend. Apple Container provides hardware VM isolation on +// macOS 26+, Docker is the cross-platform default, Podman is the rootless +// fallback for Linux environments. +const ( + // RuntimeAuto picks the first available runtime in preference order. + // resolved := resolveContainerRuntime("auto") // → "apple" on macOS 26+, "docker" elsewhere + RuntimeAuto = "auto" + // RuntimeApple uses Apple Containers (macOS 26+, Virtualisation.framework). + // resolved := resolveContainerRuntime("apple") // → "apple" if /usr/bin/container or `container` in PATH + RuntimeApple = "apple" + // RuntimeDocker uses Docker Engine (Docker Desktop on macOS, dockerd on Linux). + // resolved := resolveContainerRuntime("docker") // → "docker" if `docker` in PATH + RuntimeDocker = "docker" + // RuntimePodman uses Podman (rootless containers, popular on RHEL/Fedora). + // resolved := resolveContainerRuntime("podman") // → "podman" if `podman` in PATH + RuntimePodman = "podman" +) + +// containerRuntimeBinary returns the executable name for a runtime identifier. +// +// containerRuntimeBinary("apple") // "container" +// containerRuntimeBinary("docker") // "docker" +// containerRuntimeBinary("podman") // "podman" +func containerRuntimeBinary(runtime string) string { + switch runtime { + case RuntimeApple: + return "container" + case RuntimePodman: + return "podman" + default: + return "docker" + } +} + +// goosIsDarwin reports whether the running process is on macOS. Captured at +// package init so tests can compare against a fixed value without taking a +// dependency on the `runtime` package themselves. +var goosIsDarwin = runtime.GOOS == "darwin" + +// runtimeAvailable reports whether the runtime's binary is available on PATH +// or via known absolute paths. Apple Container additionally requires macOS as +// the host operating system because the binary is a thin wrapper over +// Virtualisation.framework. +// +// runtimeAvailable("docker") // true if `docker` binary on PATH +// runtimeAvailable("apple") // true on macOS when `container` binary on PATH +func runtimeAvailable(name string) bool { + switch name { + case RuntimeApple: + if !goosIsDarwin { + return false + } + case RuntimeDocker, RuntimePodman: + // supported on every platform that ships the binary + default: + return false + } + binary := containerRuntimeBinary(name) + if _, err := exec.LookPath(binary); err == nil { + return true + } + return false +} + +// resolveContainerRuntime returns the concrete runtime identifier for the +// requested runtime preference. "auto" picks the first available runtime in +// the preferred order (apple → docker → podman). An explicit runtime is +// honoured if the binary is on PATH; otherwise it falls back to docker so +// dispatch never silently breaks. +// +// resolveContainerRuntime("") // → "docker" (fallback) +// resolveContainerRuntime("auto") // → "apple" on macOS 26+, "docker" elsewhere +// resolveContainerRuntime("apple") // → "apple" if available, else "docker" +// resolveContainerRuntime("podman") // → "podman" if available, else "docker" +func resolveContainerRuntime(preferred string) string { + switch preferred { + case RuntimeApple, RuntimeDocker, RuntimePodman: + if runtimeAvailable(preferred) { + return preferred + } + } + for _, candidate := range []string{RuntimeApple, RuntimeDocker, RuntimePodman} { + if runtimeAvailable(candidate) { + return candidate + } + } + return RuntimeDocker +} + +// dispatchRuntime returns the configured runtime preference (yaml +// `dispatch.runtime`) or the default ("auto"). The CORE_AGENT_RUNTIME +// environment variable wins for ad-hoc overrides during tests or CI. +// +// rt := s.dispatchRuntime() // "auto" | "apple" | "docker" | "podman" +func (s *PrepSubsystem) dispatchRuntime() string { + if envValue := core.Env("CORE_AGENT_RUNTIME"); envValue != "" { + return envValue + } + if s == nil || s.ServiceRuntime == nil { + return RuntimeAuto + } + dispatchConfig, ok := s.Core().Config().Get("agents.dispatch").Value.(DispatchConfig) + if !ok || dispatchConfig.Runtime == "" { + return RuntimeAuto + } + return dispatchConfig.Runtime +} + +// dispatchImage returns the configured container image (yaml `dispatch.image`) +// falling back to AGENT_DOCKER_IMAGE and finally `core-dev`. +// +// image := s.dispatchImage() // "core-dev" | "core-ml" | configured value +func (s *PrepSubsystem) dispatchImage() string { + if envValue := core.Env("AGENT_DOCKER_IMAGE"); envValue != "" { + return envValue + } + if s != nil && s.ServiceRuntime != nil { + dispatchConfig, ok := s.Core().Config().Get("agents.dispatch").Value.(DispatchConfig) + if ok && dispatchConfig.Image != "" { + return dispatchConfig.Image + } + } + return defaultDockerImage +} + +// dispatchGPU reports whether GPU passthrough is enabled (yaml `dispatch.gpu`). +// When true, dispatch adds Metal passthrough on Apple Containers (when +// available) or `--gpus=all` on Docker for NVIDIA passthrough. +// +// gpu := s.dispatchGPU() // false unless agents.yaml sets dispatch.gpu: true +func (s *PrepSubsystem) dispatchGPU() bool { + if s == nil || s.ServiceRuntime == nil { + return false + } + dispatchConfig, ok := s.Core().Config().Get("agents.dispatch").Value.(DispatchConfig) + if !ok { + return false + } + return dispatchConfig.GPU +} + // command, args := containerCommand("codex", []string{"exec", "--model", "gpt-5.4"}, "/srv/.core/workspace/core/go-io/task-5", "/srv/.core/workspace/core/go-io/task-5/.meta") func containerCommand(command string, args []string, workspaceDir, metaDir string) (string, []string) { - image := core.Env("AGENT_DOCKER_IMAGE") + return containerCommandFor(RuntimeDocker, defaultDockerImage, false, command, args, workspaceDir, metaDir) +} + +// containerCommandFor builds the runtime-specific command line for executing +// an agent inside a container. Docker and Podman share an identical CLI +// surface (run/-rm/-v/-e), so they only differ in binary name. Apple +// Containers use the same flag shape (`container run -v ...`) per the +// Virtualisation.framework wrapper introduced in macOS 26. +// +// command, args := containerCommandFor(RuntimeDocker, "core-dev", false, "codex", []string{"exec"}, ws, meta) +// command, args := containerCommandFor(RuntimeApple, "core-dev", true, "claude", nil, ws, meta) +func containerCommandFor(containerRuntime, image string, gpu bool, command string, args []string, workspaceDir, metaDir string) (string, []string) { if image == "" { image = defaultDockerImage } + if envImage := core.Env("AGENT_DOCKER_IMAGE"); envImage != "" { + image = envImage + } home := HomeDir() - dockerArgs := []string{ - "run", "--rm", - "--add-host=host.docker.internal:host-gateway", + containerArgs := []string{"run", "--rm"} + // Apple Containers don't support `--add-host=host-gateway`; the host-gateway + // alias is a Docker-only convenience for reaching the host loopback. + if containerRuntime != RuntimeApple { + containerArgs = append(containerArgs, "--add-host=host.docker.internal:host-gateway") + } + if gpu { + switch containerRuntime { + case RuntimeDocker, RuntimePodman: + // NVIDIA passthrough — `--gpus=all` is the standard NVIDIA Container Toolkit flag. + containerArgs = append(containerArgs, "--gpus=all") + case RuntimeApple: + // Metal passthrough — flagged for the macOS 26 roadmap; emit the + // flag so Apple's runtime can opt-in once it ships GPU support. + containerArgs = append(containerArgs, "--gpu=metal") + } + } + containerArgs = append(containerArgs, "-v", core.Concat(workspaceDir, ":/workspace"), "-v", core.Concat(metaDir, ":/workspace/.meta"), "-w", "/workspace/repo", - "-v", core.Concat(core.JoinPath(home, ".codex"), ":/home/dev/.codex:ro"), + "-v", core.Concat(core.JoinPath(home, ".codex"), ":/home/agent/.codex"), "-e", "OPENAI_API_KEY", "-e", "ANTHROPIC_API_KEY", "-e", "GEMINI_API_KEY", @@ -234,17 +396,17 @@ func containerCommand(command string, args []string, workspaceDir, metaDir strin "-e", "GIT_USER_EMAIL=virgil@lethean.io", "-e", "GONOSUMCHECK=dappco.re/*,forge.lthn.ai/*", "-e", "GOFLAGS=-mod=mod", - } + ) if command == "claude" { - dockerArgs = append(dockerArgs, - "-v", core.Concat(core.JoinPath(home, ".claude"), ":/home/dev/.claude:ro"), + containerArgs = append(containerArgs, + "-v", core.Concat(core.JoinPath(home, ".claude"), ":/home/agent/.claude:ro"), ) } if command == "gemini" { - dockerArgs = append(dockerArgs, - "-v", core.Concat(core.JoinPath(home, ".gemini"), ":/home/dev/.gemini:ro"), + containerArgs = append(containerArgs, + "-v", core.Concat(core.JoinPath(home, ".gemini"), ":/home/agent/.gemini:ro"), ) } @@ -261,9 +423,9 @@ func containerCommand(command string, args []string, workspaceDir, metaDir strin } quoted.WriteString("; chmod -R a+w /workspace /workspace/.meta 2>/dev/null; true") - dockerArgs = append(dockerArgs, image, "sh", "-c", quoted.String()) + containerArgs = append(containerArgs, image, "sh", "-c", quoted.String()) - return "docker", dockerArgs + return containerRuntimeBinary(containerRuntime), containerArgs } // outputFile := agentOutputFile(workspaceDir, "codex") @@ -438,7 +600,8 @@ func (s *PrepSubsystem) spawnAgent(agent, prompt, workspaceDir string) (int, str fs.Delete(WorkspaceBlockedPath(workspaceDir)) if !isNativeAgent(agent) { - command, args = containerCommand(command, args, workspaceDir, metaDir) + runtimeName := resolveContainerRuntime(s.dispatchRuntime()) + command, args = containerCommandFor(runtimeName, s.dispatchImage(), s.dispatchGPU(), command, args, workspaceDir, metaDir) } processResult := s.Core().Service("process") diff --git a/pkg/agentic/dispatch_runtime_test.go b/pkg/agentic/dispatch_runtime_test.go new file mode 100644 index 0000000..8dc2148 --- /dev/null +++ b/pkg/agentic/dispatch_runtime_test.go @@ -0,0 +1,203 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "strings" + "testing" + + core "dappco.re/go/core" + "github.com/stretchr/testify/assert" +) + +// --- containerRuntimeBinary --- + +func TestDispatchRuntime_ContainerRuntimeBinary_Good(t *testing.T) { + assert.Equal(t, "container", containerRuntimeBinary(RuntimeApple)) + assert.Equal(t, "docker", containerRuntimeBinary(RuntimeDocker)) + assert.Equal(t, "podman", containerRuntimeBinary(RuntimePodman)) +} + +func TestDispatchRuntime_ContainerRuntimeBinary_Bad(t *testing.T) { + // Unknown runtime falls back to docker so dispatch never silently breaks. + assert.Equal(t, "docker", containerRuntimeBinary("")) + assert.Equal(t, "docker", containerRuntimeBinary("kubernetes")) +} + +func TestDispatchRuntime_ContainerRuntimeBinary_Ugly(t *testing.T) { + // Whitespace-laden runtime name is treated as unknown; docker fallback wins. + assert.Equal(t, "docker", containerRuntimeBinary(" apple ")) +} + +// --- runtimeAvailable --- + +func TestDispatchRuntime_RuntimeAvailable_Good(t *testing.T) { + // Inspect only the failure path that doesn't depend on host binaries. + // Apple Container is by definition unavailable on non-darwin. + if !isDarwin() { + assert.False(t, runtimeAvailable(RuntimeApple)) + } +} + +func TestDispatchRuntime_RuntimeAvailable_Bad(t *testing.T) { + // Unknown runtimes are never available. + assert.False(t, runtimeAvailable("")) + assert.False(t, runtimeAvailable("kubernetes")) +} + +func TestDispatchRuntime_RuntimeAvailable_Ugly(t *testing.T) { + // Apple Container on non-macOS hosts is always unavailable, regardless of + // whether a binary called "container" happens to be on PATH. + if !isDarwin() { + assert.False(t, runtimeAvailable(RuntimeApple)) + } +} + +// --- resolveContainerRuntime --- + +func TestDispatchRuntime_ResolveContainerRuntime_Good(t *testing.T) { + // Empty preference falls back to one of the known runtimes (docker is the + // hard fallback, but the function may surface apple/podman when those + // binaries exist on the test host). + resolved := resolveContainerRuntime("") + assert.Contains(t, []string{RuntimeApple, RuntimeDocker, RuntimePodman}, resolved) +} + +func TestDispatchRuntime_ResolveContainerRuntime_Bad(t *testing.T) { + // An unknown runtime preference still resolves to a known runtime. + resolved := resolveContainerRuntime("kubernetes") + assert.Contains(t, []string{RuntimeApple, RuntimeDocker, RuntimePodman}, resolved) +} + +func TestDispatchRuntime_ResolveContainerRuntime_Ugly(t *testing.T) { + // Apple preference on non-darwin host falls back to a non-apple runtime. + if !isDarwin() { + resolved := resolveContainerRuntime(RuntimeApple) + assert.NotEqual(t, RuntimeApple, resolved) + } +} + +// --- containerCommandFor --- + +func TestDispatchRuntime_ContainerCommandFor_Good(t *testing.T) { + t.Setenv("AGENT_DOCKER_IMAGE", "") + t.Setenv("DIR_HOME", "/home/dev") + + // Docker runtime emits docker binary and includes host-gateway alias. + cmd, args := containerCommandFor(RuntimeDocker, "core-dev", false, "codex", []string{"exec"}, "/ws", "/ws/.meta") + assert.Equal(t, "docker", cmd) + joined := strings.Join(args, " ") + assert.Contains(t, joined, "--add-host=host.docker.internal:host-gateway") + assert.Contains(t, joined, "core-dev") +} + +func TestDispatchRuntime_ContainerCommandFor_Bad(t *testing.T) { + t.Setenv("AGENT_DOCKER_IMAGE", "") + t.Setenv("DIR_HOME", "/home/dev") + + // Empty image resolves to the default rather than passing "" to docker. + cmd, args := containerCommandFor(RuntimeDocker, "", false, "codex", nil, "/ws", "/ws/.meta") + assert.Equal(t, "docker", cmd) + assert.Contains(t, args, defaultDockerImage) +} + +func TestDispatchRuntime_ContainerCommandFor_Ugly(t *testing.T) { + t.Setenv("AGENT_DOCKER_IMAGE", "") + t.Setenv("DIR_HOME", "/home/dev") + + // Apple runtime emits the `container` binary and SKIPS the host-gateway + // alias because Apple Containers don't support `--add-host=host-gateway`. + cmd, args := containerCommandFor(RuntimeApple, "core-dev", false, "codex", []string{"exec"}, "/ws", "/ws/.meta") + assert.Equal(t, "container", cmd) + joined := strings.Join(args, " ") + assert.NotContains(t, joined, "--add-host=host.docker.internal:host-gateway") + + // Podman runtime emits the `podman` binary. + cmd2, _ := containerCommandFor(RuntimePodman, "core-dev", false, "codex", []string{"exec"}, "/ws", "/ws/.meta") + assert.Equal(t, "podman", cmd2) + + // GPU passthrough on docker emits `--gpus=all`. + _, gpuArgs := containerCommandFor(RuntimeDocker, "core-dev", true, "codex", []string{"exec"}, "/ws", "/ws/.meta") + assert.Contains(t, strings.Join(gpuArgs, " "), "--gpus=all") + + // GPU passthrough on apple emits `--gpu=metal` for Metal passthrough. + _, appleGPUArgs := containerCommandFor(RuntimeApple, "core-dev", true, "codex", []string{"exec"}, "/ws", "/ws/.meta") + assert.Contains(t, strings.Join(appleGPUArgs, " "), "--gpu=metal") +} + +// --- dispatchRuntime / dispatchImage / dispatchGPU --- + +func TestDispatchRuntime_DispatchRuntime_Good(t *testing.T) { + t.Setenv("CORE_AGENT_RUNTIME", "") + c := core.New() + c.Config().Set("agents.dispatch", DispatchConfig{Runtime: "podman"}) + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(c, AgentOptions{})} + assert.Equal(t, "podman", s.dispatchRuntime()) +} + +func TestDispatchRuntime_DispatchRuntime_Bad(t *testing.T) { + t.Setenv("CORE_AGENT_RUNTIME", "") + // Nil subsystem returns the auto default. + var s *PrepSubsystem + assert.Equal(t, RuntimeAuto, s.dispatchRuntime()) +} + +func TestDispatchRuntime_DispatchRuntime_Ugly(t *testing.T) { + // Env var override wins over configured runtime. + t.Setenv("CORE_AGENT_RUNTIME", "apple") + c := core.New() + c.Config().Set("agents.dispatch", DispatchConfig{Runtime: "podman"}) + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(c, AgentOptions{})} + assert.Equal(t, "apple", s.dispatchRuntime()) +} + +func TestDispatchRuntime_DispatchImage_Good(t *testing.T) { + t.Setenv("AGENT_DOCKER_IMAGE", "") + c := core.New() + c.Config().Set("agents.dispatch", DispatchConfig{Image: "core-ml"}) + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(c, AgentOptions{})} + assert.Equal(t, "core-ml", s.dispatchImage()) +} + +func TestDispatchRuntime_DispatchImage_Bad(t *testing.T) { + t.Setenv("AGENT_DOCKER_IMAGE", "") + // Nil subsystem falls back to the default image. + var s *PrepSubsystem + assert.Equal(t, defaultDockerImage, s.dispatchImage()) +} + +func TestDispatchRuntime_DispatchImage_Ugly(t *testing.T) { + // Env var override wins over configured image. + t.Setenv("AGENT_DOCKER_IMAGE", "ad-hoc-image") + c := core.New() + c.Config().Set("agents.dispatch", DispatchConfig{Image: "core-ml"}) + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(c, AgentOptions{})} + assert.Equal(t, "ad-hoc-image", s.dispatchImage()) +} + +func TestDispatchRuntime_DispatchGPU_Good(t *testing.T) { + c := core.New() + c.Config().Set("agents.dispatch", DispatchConfig{GPU: true}) + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(c, AgentOptions{})} + assert.True(t, s.dispatchGPU()) +} + +func TestDispatchRuntime_DispatchGPU_Bad(t *testing.T) { + // Nil subsystem returns false (GPU off by default). + var s *PrepSubsystem + assert.False(t, s.dispatchGPU()) +} + +func TestDispatchRuntime_DispatchGPU_Ugly(t *testing.T) { + // Missing dispatch config returns false instead of panicking. + c := core.New() + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(c, AgentOptions{})} + assert.False(t, s.dispatchGPU()) +} + +// isDarwin checks the host operating system without importing runtime in the +// test file (the import happens in dispatch.go where it's needed for the real +// detection logic). +func isDarwin() bool { + return goosIsDarwin +} diff --git a/pkg/agentic/logic_test.go b/pkg/agentic/logic_test.go index bdd06ea..90377b3 100644 --- a/pkg/agentic/logic_test.go +++ b/pkg/agentic/logic_test.go @@ -218,7 +218,7 @@ func TestDispatch_ContainerCommand_Good_ClaudeMountsConfig(t *testing.T) { _, args := containerCommand("claude", []string{"-p", "do it"}, "/ws", "/ws/.meta") joined := strings.Join(args, " ") - assert.Contains(t, joined, ".claude:/home/dev/.claude:ro") + assert.Contains(t, joined, ".claude:/home/agent/.claude:ro") } func TestDispatch_ContainerCommand_Good_GeminiMountsConfig(t *testing.T) { @@ -227,7 +227,7 @@ func TestDispatch_ContainerCommand_Good_GeminiMountsConfig(t *testing.T) { _, args := containerCommand("gemini", []string{"-p", "do it"}, "/ws", "/ws/.meta") joined := strings.Join(args, " ") - assert.Contains(t, joined, ".gemini:/home/dev/.gemini:ro") + assert.Contains(t, joined, ".gemini:/home/agent/.gemini:ro") } func TestDispatch_ContainerCommand_Good_CodexNoClaudeMount(t *testing.T) { @@ -237,7 +237,7 @@ func TestDispatch_ContainerCommand_Good_CodexNoClaudeMount(t *testing.T) { _, args := containerCommand("codex", []string{"exec"}, "/ws", "/ws/.meta") joined := strings.Join(args, " ") // codex agent must NOT mount .claude config - assert.NotContains(t, joined, ".claude:/home/dev/.claude:ro") + assert.NotContains(t, joined, ".claude:/home/agent/.claude:ro") } func TestDispatch_ContainerCommand_Good_APIKeysPassedByRef(t *testing.T) { diff --git a/pkg/agentic/paths.go b/pkg/agentic/paths.go index b44416e..bc9f04f 100644 --- a/pkg/agentic/paths.go +++ b/pkg/agentic/paths.go @@ -27,8 +27,18 @@ var fs = (&core.Fs{}).NewUnrestricted() var workspaceRootOverride string +// setWorkspaceRootOverride("/srv/.core/workspace") // absolute — used as-is +// setWorkspaceRootOverride(".core/workspace") // relative — resolved to $HOME/Code/.core/workspace +// setWorkspaceRootOverride("") // unset — WorkspaceRoot() falls back to CoreRoot()+"/workspace" func setWorkspaceRootOverride(root string) { - workspaceRootOverride = core.Trim(root) + root = core.Trim(root) + if root != "" && !core.PathIsAbs(root) { + // Resolve relative paths against $HOME/Code — the convention. + // Without this, workspaces resolve against the binary's cwd which + // varies by launch context (MCP stdio vs CLI vs dispatch worker). + root = core.JoinPath(HomeDir(), "Code", root) + } + workspaceRootOverride = root } // f := agentic.LocalFs() diff --git a/pkg/agentic/pr.go b/pkg/agentic/pr.go index 4133bea..48e0af4 100644 --- a/pkg/agentic/pr.go +++ b/pkg/agentic/pr.go @@ -377,6 +377,14 @@ func (s *PrepSubsystem) registerClosePRTool(svc *coremcp.Service) { }, s.closePR) } +// s.registerDeleteBranchTool(svc) +func (s *PrepSubsystem) registerDeleteBranchTool(svc *coremcp.Service) { + coremcp.AddToolRecorded(svc, svc.Server(), "agentic", &mcp.Tool{ + Name: "agentic_delete_branch", + Description: "Delete a branch on the Forge remote. Used after successful merge or close to clean up agent branches.", + }, s.deleteBranch) +} + func (s *PrepSubsystem) listPRs(ctx context.Context, _ *mcp.CallToolRequest, input ListPRsInput) (*mcp.CallToolResult, ListPRsOutput, error) { if s.forgeToken == "" { return nil, ListPRsOutput{}, core.E("listPRs", "no Forge token configured", nil) @@ -466,6 +474,57 @@ func (s *PrepSubsystem) closePR(ctx context.Context, _ *mcp.CallToolRequest, inp }, nil } +// input := agentic.DeleteBranchInput{Org: "core", Repo: "go-io", Branch: "agent/fix-tests"} +type DeleteBranchInput struct { + // input := agentic.DeleteBranchInput{Org: "core"} + Org string `json:"org,omitempty"` + // input := agentic.DeleteBranchInput{Repo: "go-io"} + Repo string `json:"repo"` + // input := agentic.DeleteBranchInput{Branch: "agent/fix-tests"} + Branch string `json:"branch"` +} + +// out := agentic.DeleteBranchOutput{Success: true, Repo: "go-io", Branch: "agent/fix-tests"} +type DeleteBranchOutput struct { + // out := agentic.DeleteBranchOutput{Success: true} + Success bool `json:"success"` + // out := agentic.DeleteBranchOutput{Org: "core"} + Org string `json:"org,omitempty"` + // out := agentic.DeleteBranchOutput{Repo: "go-io"} + Repo string `json:"repo"` + // out := agentic.DeleteBranchOutput{Branch: "agent/fix-tests"} + Branch string `json:"branch"` +} + +// s.deleteBranch(ctx, nil, agentic.DeleteBranchInput{Repo: "go-io", Branch: "agent/fix-tests"}) +func (s *PrepSubsystem) deleteBranch(ctx context.Context, _ *mcp.CallToolRequest, input DeleteBranchInput) (*mcp.CallToolResult, DeleteBranchOutput, error) { + if s.forgeToken == "" { + return nil, DeleteBranchOutput{}, core.E("deleteBranch", "no Forge token configured", nil) + } + if s.forge == nil { + return nil, DeleteBranchOutput{}, core.E("deleteBranch", "forge client is not configured", nil) + } + if input.Repo == "" || input.Branch == "" { + return nil, DeleteBranchOutput{}, core.E("deleteBranch", "repo and branch are required", nil) + } + + org := input.Org + if org == "" { + org = "core" + } + + if err := s.forge.Branches.DeleteBranch(ctx, org, input.Repo, input.Branch); err != nil { + return nil, DeleteBranchOutput{}, core.E("deleteBranch", core.Concat("failed to delete branch ", input.Branch), err) + } + + return nil, DeleteBranchOutput{ + Success: true, + Org: org, + Repo: input.Repo, + Branch: input.Branch, + }, nil +} + func (s *PrepSubsystem) listRepoPRs(ctx context.Context, org, repo, state string) ([]PRInfo, error) { var pullRequests []pullRequestView err := s.forge.Client().Get(ctx, core.Sprintf("/api/v1/repos/%s/%s/pulls?limit=50&page=1", org, repo), &pullRequests) diff --git a/pkg/agentic/pr_test.go b/pkg/agentic/pr_test.go index 616fc86..80cc638 100644 --- a/pkg/agentic/pr_test.go +++ b/pkg/agentic/pr_test.go @@ -710,3 +710,72 @@ func TestPr_ListRepoPRs_Ugly(t *testing.T) { require.NoError(t, err) assert.Empty(t, prs) } + +func TestPr_DeleteBranch_Good_Success(t *testing.T) { + var method, path string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + method = r.Method + path = r.URL.Path + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(srv.Close) + + s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), + forge: forge.NewForge(srv.URL, "test-token"), + forgeURL: srv.URL, + forgeToken: "test-token", + backoff: make(map[string]time.Time), + failCount: make(map[string]int), + } + + _, out, err := s.deleteBranch(context.Background(), nil, DeleteBranchInput{ + Repo: "test-repo", + Branch: "agent/fix-tests", + }) + require.NoError(t, err) + assert.True(t, out.Success) + assert.Equal(t, "core", out.Org) + assert.Equal(t, "test-repo", out.Repo) + assert.Equal(t, "agent/fix-tests", out.Branch) + assert.Equal(t, http.MethodDelete, method) + assert.Contains(t, path, "/branches/agent/fix-tests") +} + +func TestPr_DeleteBranch_Bad_MissingRepo(t *testing.T) { + s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), + forge: forge.NewForge("http://localhost:1", "test-token"), + forgeToken: "test-token", + } + + _, _, err := s.deleteBranch(context.Background(), nil, DeleteBranchInput{ + Branch: "agent/fix-tests", + }) + require.Error(t, err) +} + +func TestPr_DeleteBranch_Bad_MissingBranch(t *testing.T) { + s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), + forge: forge.NewForge("http://localhost:1", "test-token"), + forgeToken: "test-token", + } + + _, _, err := s.deleteBranch(context.Background(), nil, DeleteBranchInput{ + Repo: "test-repo", + }) + require.Error(t, err) +} + +func TestPr_DeleteBranch_Ugly_NoForgeToken(t *testing.T) { + s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), + } + + _, _, err := s.deleteBranch(context.Background(), nil, DeleteBranchInput{ + Repo: "test-repo", + Branch: "agent/fix-tests", + }) + require.Error(t, err) +} diff --git a/pkg/agentic/prep.go b/pkg/agentic/prep.go index 839158f..8f950fb 100644 --- a/pkg/agentic/prep.go +++ b/pkg/agentic/prep.go @@ -196,6 +196,8 @@ func (s *PrepSubsystem) OnStartup(ctx context.Context) core.Result { c.Action("agentic.pr.list", s.handlePRList).Description = "List Forge PRs for a repo" c.Action("agentic.pr.merge", s.handlePRMerge).Description = "Merge a Forge PR" c.Action("agentic.pr.close", s.handlePRClose).Description = "Close a Forge PR" + c.Action("agentic.branch.delete", s.handleBranchDelete).Description = "Delete a branch on the Forge remote" + c.Action("agent.branch.delete", s.handleBranchDelete).Description = "Delete a branch on the Forge remote" c.Action("agentic.review-queue", s.handleReviewQueue).Description = "Run CodeRabbit review on completed workspaces" @@ -435,6 +437,7 @@ func (s *PrepSubsystem) RegisterTools(svc *coremcp.Service) { s.registerCreatePRTool(svc) s.registerListPRsTool(svc) s.registerClosePRTool(svc) + s.registerDeleteBranchTool(svc) s.registerMirrorTool(svc) s.registerShutdownTools(svc) s.registerPlanTools(svc) diff --git a/pkg/agentic/queue.go b/pkg/agentic/queue.go index bea065c..2b186d9 100644 --- a/pkg/agentic/queue.go +++ b/pkg/agentic/queue.go @@ -10,11 +10,23 @@ import ( "gopkg.in/yaml.v3" ) -// config := agentic.DispatchConfig{DefaultAgent: "claude", DefaultTemplate: "coding"} +// config := agentic.DispatchConfig{DefaultAgent: "claude", DefaultTemplate: "coding", Runtime: "auto", Image: "core-dev"} type DispatchConfig struct { DefaultAgent string `yaml:"default_agent"` DefaultTemplate string `yaml:"default_template"` WorkspaceRoot string `yaml:"workspace_root"` + // Runtime selects the container runtime — auto | apple | docker | podman. + // auto detects in preference order: Apple Container -> Docker -> Podman. + // Apple Containers (macOS 26+) provide hardware VM isolation and sub-second + // startup; Docker is the cross-platform fallback; Podman is the rootless + // option for Linux environments where Docker is unavailable. + Runtime string `yaml:"runtime"` + // Image is the default container image for non-native agent dispatch. + // Used by go-build LinuxKit images such as "core-dev", "core-ml", "core-minimal". + Image string `yaml:"image"` + // GPU enables GPU passthrough — Metal on Apple Containers (when available), + // NVIDIA on Docker. Default false. + GPU bool `yaml:"gpu"` } // rate := agentic.RateConfig{ResetUTC: "06:00", DailyLimit: 200, MinDelay: 15, SustainedDelay: 120, BurstWindow: 2, BurstDelay: 15} @@ -60,12 +72,35 @@ func (c *ConcurrencyLimit) UnmarshalYAML(value *yaml.Node) error { return nil } +// identity := agentic.AgentIdentity{Host: "local", Runner: "claude", Active: true, Roles: []string{"dispatch", "review"}} +// AgentIdentity represents one entry in the agents.yaml `agents:` block — +// the named identity (e.g. cladius, charon, codex) that can dispatch work. +type AgentIdentity struct { + // Host is "local", "cloud", "remote", or an explicit IP/hostname. + // identity := agentic.AgentIdentity{Host: "local"} + Host string `yaml:"host"` + // Runner is the runtime that backs this identity ("claude", "openai", "gemini"). + // identity := agentic.AgentIdentity{Runner: "claude"} + Runner string `yaml:"runner"` + // Active reports whether this identity participates in dispatch. + // identity := agentic.AgentIdentity{Active: true} + Active bool `yaml:"active"` + // Roles enumerates the workflows this identity can handle: + // dispatch, worker, review, qa, plan. + // identity := agentic.AgentIdentity{Roles: []string{"dispatch", "review", "plan"}} + Roles []string `yaml:"roles"` +} + // config := agentic.AgentsConfig{Version: 1, Dispatch: agentic.DispatchConfig{DefaultAgent: "claude"}} type AgentsConfig struct { Version int `yaml:"version"` Dispatch DispatchConfig `yaml:"dispatch"` Concurrency map[string]ConcurrencyLimit `yaml:"concurrency"` Rates map[string]RateConfig `yaml:"rates"` + // Agents declares named identities (cladius, charon, codex, clotho) + // keyed by name. Each identity carries host/runner/roles metadata used + // by message routing, brain attribution, and session ownership. + Agents map[string]AgentIdentity `yaml:"agents"` } // config := s.loadAgentsConfig() diff --git a/pkg/agentic/queue_test.go b/pkg/agentic/queue_test.go index c77f9d3..5e3f8e2 100644 --- a/pkg/agentic/queue_test.go +++ b/pkg/agentic/queue_test.go @@ -30,6 +30,117 @@ func TestQueue_DispatchConfig_Good_Defaults(t *testing.T) { assert.Equal(t, 3, cfg.Concurrency["gemini"].Total) } +func TestQueue_DispatchConfig_Good_RuntimeImageGPUFromYAML(t *testing.T) { + root := t.TempDir() + setTestWorkspace(t, root) + require.True(t, fs.Write(core.JoinPath(root, "agents.yaml"), core.Concat( + "version: 1\n", + "dispatch:\n", + " runtime: apple\n", + " image: core-ml\n", + " gpu: true\n", + )).OK) + + t.Cleanup(func() { + setWorkspaceRootOverride("") + }) + + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), codePath: t.TempDir()} + cfg := s.loadAgentsConfig() + + assert.Equal(t, "apple", cfg.Dispatch.Runtime) + assert.Equal(t, "core-ml", cfg.Dispatch.Image) + assert.True(t, cfg.Dispatch.GPU) +} + +func TestQueue_DispatchConfig_Bad_OmittedRuntimeFields(t *testing.T) { + root := t.TempDir() + setTestWorkspace(t, root) + require.True(t, fs.Write(core.JoinPath(root, "agents.yaml"), "version: 1\ndispatch:\n default_agent: codex\n").OK) + t.Cleanup(func() { setWorkspaceRootOverride("") }) + + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), codePath: t.TempDir()} + cfg := s.loadAgentsConfig() + + assert.Empty(t, cfg.Dispatch.Runtime) + assert.Empty(t, cfg.Dispatch.Image) + assert.False(t, cfg.Dispatch.GPU) +} + +func TestQueue_DispatchConfig_Ugly_PartialRuntimeBlock(t *testing.T) { + root := t.TempDir() + setTestWorkspace(t, root) + require.True(t, fs.Write(core.JoinPath(root, "agents.yaml"), "version: 1\ndispatch:\n runtime: docker\n").OK) + t.Cleanup(func() { setWorkspaceRootOverride("") }) + + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), codePath: t.TempDir()} + cfg := s.loadAgentsConfig() + + assert.Equal(t, "docker", cfg.Dispatch.Runtime) + assert.Empty(t, cfg.Dispatch.Image) + assert.False(t, cfg.Dispatch.GPU) +} + +// --- AgentIdentity --- + +func TestQueue_AgentIdentity_Good_FullParseFromYAML(t *testing.T) { + root := t.TempDir() + setTestWorkspace(t, root) + require.True(t, fs.Write(core.JoinPath(root, "agents.yaml"), core.Concat( + "version: 1\n", + "agents:\n", + " cladius:\n", + " host: local\n", + " runner: claude\n", + " active: true\n", + " roles: [dispatch, review, plan]\n", + " codex:\n", + " host: cloud\n", + " runner: openai\n", + " active: true\n", + " roles: [worker]\n", + )).OK) + t.Cleanup(func() { setWorkspaceRootOverride("") }) + + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), codePath: t.TempDir()} + cfg := s.loadAgentsConfig() + + assert.Equal(t, "local", cfg.Agents["cladius"].Host) + assert.Equal(t, "claude", cfg.Agents["cladius"].Runner) + assert.True(t, cfg.Agents["cladius"].Active) + assert.Contains(t, cfg.Agents["cladius"].Roles, "dispatch") + assert.Equal(t, "cloud", cfg.Agents["codex"].Host) +} + +func TestQueue_AgentIdentity_Bad_MissingAgentsBlock(t *testing.T) { + root := t.TempDir() + setTestWorkspace(t, root) + require.True(t, fs.Write(core.JoinPath(root, "agents.yaml"), "version: 1\n").OK) + t.Cleanup(func() { setWorkspaceRootOverride("") }) + + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), codePath: t.TempDir()} + cfg := s.loadAgentsConfig() + assert.Empty(t, cfg.Agents) +} + +func TestQueue_AgentIdentity_Ugly_OnlyHostSet(t *testing.T) { + root := t.TempDir() + setTestWorkspace(t, root) + require.True(t, fs.Write(core.JoinPath(root, "agents.yaml"), core.Concat( + "agents:\n", + " ghost:\n", + " host: 192.168.0.42\n", + )).OK) + t.Cleanup(func() { setWorkspaceRootOverride("") }) + + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), codePath: t.TempDir()} + cfg := s.loadAgentsConfig() + + assert.Equal(t, "192.168.0.42", cfg.Agents["ghost"].Host) + assert.Empty(t, cfg.Agents["ghost"].Runner) + assert.False(t, cfg.Agents["ghost"].Active) +} + func TestQueue_DispatchConfig_Good_WorkspaceRootOverride(t *testing.T) { root := t.TempDir() setTestWorkspace(t, root) diff --git a/pkg/brain/provider.go b/pkg/brain/provider.go index 1c0bd09..94c20ed 100644 --- a/pkg/brain/provider.go +++ b/pkg/brain/provider.go @@ -211,6 +211,7 @@ func (p *BrainProvider) remember(c *gin.Context) { "content": input.Content, "type": input.Type, "tags": input.Tags, + "org": input.Org, "project": input.Project, "confidence": input.Confidence, "supersedes": input.Supersedes, @@ -321,6 +322,7 @@ func (p *BrainProvider) list(c *gin.Context) { "project": c.Query("project"), "type": c.Query("type"), "agent_id": c.Query("agent_id"), + "org": c.Query("org"), "limit": limit, }, }) diff --git a/pkg/brain/tools.go b/pkg/brain/tools.go index 95c84c2..15864fa 100644 --- a/pkg/brain/tools.go +++ b/pkg/brain/tools.go @@ -20,6 +20,8 @@ type RememberInput struct { Content string `json:"content"` Type string `json:"type"` Tags []string `json:"tags,omitempty"` + // Usage example: `input := brain.RememberInput{Org: "core"}` — optional organisation scope; empty = global. + Org string `json:"org,omitempty"` Project string `json:"project,omitempty"` Confidence float64 `json:"confidence,omitempty"` Supersedes string `json:"supersedes,omitempty"` @@ -54,6 +56,8 @@ type RecallFilter struct { Project string `json:"project,omitempty"` Type any `json:"type,omitempty"` AgentID string `json:"agent_id,omitempty"` + // Usage example: `filter := brain.RecallFilter{Org: "core"}` — scope recall to a specific org; empty = all. + Org string `json:"org,omitempty"` MinConfidence float64 `json:"min_confidence,omitempty"` } @@ -79,11 +83,15 @@ type Memory struct { Type string `json:"type"` Content string `json:"content"` Tags []string `json:"tags,omitempty"` + // Usage example: `memory := brain.Memory{Org: "core"}` — optional organisation scope (null/empty = global). + Org string `json:"org,omitempty"` Project string `json:"project,omitempty"` Source string `json:"source,omitempty"` Confidence float64 `json:"confidence"` SupersedesID string `json:"supersedes_id,omitempty"` SupersedesCount int `json:"supersedes_count,omitempty"` + // Usage example: `memory := brain.Memory{IndexedAt: "2026-04-14T10:00:00Z"}` — when Qdrant/ES indexing finished (empty = pending). + IndexedAt string `json:"indexed_at,omitempty"` ExpiresAt string `json:"expires_at,omitempty"` DeletedAt string `json:"deleted_at,omitempty"` CreatedAt string `json:"created_at"` @@ -120,6 +128,8 @@ type ListInput struct { Project string `json:"project,omitempty"` Type string `json:"type,omitempty"` AgentID string `json:"agent_id,omitempty"` + // Usage example: `input := brain.ListInput{Org: "core"}` — filter by org; empty = all. + Org string `json:"org,omitempty"` Limit int `json:"limit,omitempty"` } @@ -166,6 +176,7 @@ func (s *Subsystem) brainRemember(_ context.Context, _ *mcp.CallToolRequest, inp "content": input.Content, "type": input.Type, "tags": input.Tags, + "org": input.Org, "project": input.Project, "confidence": input.Confidence, "supersedes": input.Supersedes, @@ -239,6 +250,7 @@ func (s *Subsystem) brainList(_ context.Context, _ *mcp.CallToolRequest, input L "project": input.Project, "type": input.Type, "agent_id": input.AgentID, + "org": input.Org, "limit": input.Limit, }, }) diff --git a/pkg/runner/queue.go b/pkg/runner/queue.go index ad96da6..d5a28ad 100644 --- a/pkg/runner/queue.go +++ b/pkg/runner/queue.go @@ -13,11 +13,19 @@ import ( // config := runner.DispatchConfig{ // DefaultAgent: "codex", DefaultTemplate: "coding", WorkspaceRoot: "/srv/core/workspace", +// Runtime: "auto", Image: "core-dev", GPU: false, // } type DispatchConfig struct { DefaultAgent string `yaml:"default_agent"` DefaultTemplate string `yaml:"default_template"` WorkspaceRoot string `yaml:"workspace_root"` + // Runtime selects the container runtime — auto | apple | docker | podman. + // auto detects in preference order: Apple Container -> Docker -> Podman. + Runtime string `yaml:"runtime"` + // Image is the default container image for non-native agent dispatch. + Image string `yaml:"image"` + // GPU enables GPU passthrough — Metal on Apple Containers, NVIDIA on Docker. + GPU bool `yaml:"gpu"` } // rate := runner.RateConfig{ @@ -64,6 +72,14 @@ func (c *ConcurrencyLimit) UnmarshalYAML(value *yaml.Node) error { return nil } +// identity := runner.AgentIdentity{Host: "local", Runner: "claude", Active: true, Roles: []string{"dispatch"}} +type AgentIdentity struct { + Host string `yaml:"host"` + Runner string `yaml:"runner"` + Active bool `yaml:"active"` + Roles []string `yaml:"roles"` +} + // config := runner.AgentsConfig{ // Version: 1, // Dispatch: runner.DispatchConfig{DefaultAgent: "codex", DefaultTemplate: "coding"}, @@ -73,6 +89,9 @@ type AgentsConfig struct { Dispatch DispatchConfig `yaml:"dispatch"` Concurrency map[string]ConcurrencyLimit `yaml:"concurrency"` Rates map[string]RateConfig `yaml:"rates"` + // Agents declares named identities (cladius, charon, codex, clotho) + // keyed by name. Each identity carries host/runner/roles metadata. + Agents map[string]AgentIdentity `yaml:"agents"` } // config := s.loadAgentsConfig() diff --git a/pkg/runner/queue_test.go b/pkg/runner/queue_test.go index e5ed74d..10e568d 100644 --- a/pkg/runner/queue_test.go +++ b/pkg/runner/queue_test.go @@ -258,3 +258,109 @@ rates: assert.Equal(t, 1, cfg.Concurrency["codex"].Models["gpt-5.4"]) assert.Equal(t, 120, cfg.Rates["gemini"].SustainedDelay) } + +// --- DispatchConfig runtime/image/gpu --- + +func TestQueue_DispatchConfig_Good_RuntimeImageGPU(t *testing.T) { + input := ` +version: 1 +dispatch: + default_agent: claude + runtime: apple + image: core-ml + gpu: true +` + var cfg AgentsConfig + err := yaml.Unmarshal([]byte(input), &cfg) + assert.NoError(t, err) + assert.Equal(t, "apple", cfg.Dispatch.Runtime) + assert.Equal(t, "core-ml", cfg.Dispatch.Image) + assert.True(t, cfg.Dispatch.GPU) +} + +func TestQueue_DispatchConfig_Bad_OmittedRuntimeFields(t *testing.T) { + // When runtime / image / gpu are missing the yaml unmarshals into the + // struct's zero values. Callers treat empty runtime as "auto". + input := ` +version: 1 +dispatch: + default_agent: claude +` + var cfg AgentsConfig + err := yaml.Unmarshal([]byte(input), &cfg) + assert.NoError(t, err) + assert.Empty(t, cfg.Dispatch.Runtime) + assert.Empty(t, cfg.Dispatch.Image) + assert.False(t, cfg.Dispatch.GPU) +} + +func TestQueue_DispatchConfig_Ugly_PartialRuntimeBlock(t *testing.T) { + // Only runtime is set; image keeps its zero value, gpu defaults to false. + input := ` +version: 1 +dispatch: + runtime: docker +` + var cfg AgentsConfig + err := yaml.Unmarshal([]byte(input), &cfg) + assert.NoError(t, err) + assert.Equal(t, "docker", cfg.Dispatch.Runtime) + assert.Empty(t, cfg.Dispatch.Image) + assert.False(t, cfg.Dispatch.GPU) +} + +// --- AgentIdentity --- + +func TestQueue_AgentIdentity_Good_FullParse(t *testing.T) { + input := ` +version: 1 +agents: + cladius: + host: local + runner: claude + active: true + roles: [dispatch, review, plan] + codex: + host: cloud + runner: openai + active: true + roles: [worker] +` + var cfg AgentsConfig + err := yaml.Unmarshal([]byte(input), &cfg) + assert.NoError(t, err) + assert.Equal(t, "local", cfg.Agents["cladius"].Host) + assert.Equal(t, "claude", cfg.Agents["cladius"].Runner) + assert.True(t, cfg.Agents["cladius"].Active) + assert.Contains(t, cfg.Agents["cladius"].Roles, "dispatch") + assert.Contains(t, cfg.Agents["cladius"].Roles, "review") + assert.Equal(t, "cloud", cfg.Agents["codex"].Host) +} + +func TestQueue_AgentIdentity_Bad_MissingAgentsBlock(t *testing.T) { + input := ` +version: 1 +` + var cfg AgentsConfig + err := yaml.Unmarshal([]byte(input), &cfg) + assert.NoError(t, err) + assert.Empty(t, cfg.Agents) +} + +func TestQueue_AgentIdentity_Ugly_OnlyHostSet(t *testing.T) { + // An identity with only host set populates host and leaves zero values + // for runner / active / roles. Routing code treats Active=false as a + // disabled identity and SHOULD NOT crash on missing fields. + input := ` +agents: + ghost: + host: 192.168.0.42 +` + var cfg AgentsConfig + err := yaml.Unmarshal([]byte(input), &cfg) + assert.NoError(t, err) + assert.Equal(t, "192.168.0.42", cfg.Agents["ghost"].Host) + assert.Empty(t, cfg.Agents["ghost"].Runner) + assert.False(t, cfg.Agents["ghost"].Active) + assert.Empty(t, cfg.Agents["ghost"].Roles) +} diff --git a/tests/cli/Taskfile.yaml b/tests/cli/Taskfile.yaml index cd486de..ccb1d24 100644 --- a/tests/cli/Taskfile.yaml +++ b/tests/cli/Taskfile.yaml @@ -16,6 +16,7 @@ tasks: - task -d repo test - task -d issue test - task -d pr test + - task -d branch test - task -d sync test # brain subsystem - task -d brain test diff --git a/tests/cli/branch/Taskfile.yaml b/tests/cli/branch/Taskfile.yaml new file mode 100644 index 0000000..b42a192 --- /dev/null +++ b/tests/cli/branch/Taskfile.yaml @@ -0,0 +1,6 @@ +version: "3" + +tasks: + test: + cmds: + - task -d delete test diff --git a/tests/cli/branch/delete/Taskfile.yaml b/tests/cli/branch/delete/Taskfile.yaml new file mode 100644 index 0000000..7255d8d --- /dev/null +++ b/tests/cli/branch/delete/Taskfile.yaml @@ -0,0 +1,18 @@ +version: "3" + +tasks: + test: + cmds: + - | + bash <<'EOF' + set -euo pipefail + source ../../_lib/run.sh + + go build -trimpath -ldflags="-s -w" -o bin/core-agent ../../../../cmd/core-agent + + output="$(mktemp)" + run_capture_all 1 "$output" ./bin/core-agent branch/delete + assert_contains "usage:" "$output" + assert_contains "repo" "$output" + assert_contains "branch" "$output" + EOF diff --git a/tests/cli/pr/list/Taskfile.yaml b/tests/cli/pr/list/Taskfile.yaml new file mode 100644 index 0000000..f338215 --- /dev/null +++ b/tests/cli/pr/list/Taskfile.yaml @@ -0,0 +1,17 @@ +version: "3" + +tasks: + test: + cmds: + - | + bash <<'EOF' + set -euo pipefail + source ../../_lib/run.sh + + go build -trimpath -ldflags="-s -w" -o bin/core-agent ../../../../cmd/core-agent + + output="$(mktemp)" + run_capture_all 1 "$output" ./bin/core-agent pr/list + assert_contains "usage:" "$output" + assert_contains "repo" "$output" + EOF