feat(agent): unblock factory dispatch, runtime-aware containers, RFC gaps
- paths.go: resolve relative workspace_root against $HOME/Code so workspaces land in the conventional location regardless of launch cwd (MCP stdio vs CLI) - dispatch.go: container mounts use /home/agent (matches DEV_USER), plus runtime-aware dispatch (apple/docker/podman) with GPU toggle per RFC §15.5 - queue.go / runner/queue.go: DispatchConfig adds Runtime/Image/GPU fields; AgentIdentity parsing for the agents: block (RFC §10/§11) - pr.go / commands_forge.go / actions.go: agentic_delete_branch tool + branch/delete CLI (RFC §7) - brain/tools.go / provider.go: Org + IndexedAt fields on Memory (RFC §4) - config/agents.yaml: document new dispatch fields, fix identity table - tests: dispatch_runtime_test.go (21), expanded pr_test.go + queue_test.go, new CLI fixtures for branch/delete and pr/list Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
db6d06ae2b
commit
edfcb1bdfe
20 changed files with 935 additions and 32 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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 <repo> --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}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
203
pkg/agentic/dispatch_runtime_test.go
Normal file
203
pkg/agentic/dispatch_runtime_test.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
6
tests/cli/branch/Taskfile.yaml
Normal file
6
tests/cli/branch/Taskfile.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
version: "3"
|
||||
|
||||
tasks:
|
||||
test:
|
||||
cmds:
|
||||
- task -d delete test
|
||||
18
tests/cli/branch/delete/Taskfile.yaml
Normal file
18
tests/cli/branch/delete/Taskfile.yaml
Normal file
|
|
@ -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
|
||||
17
tests/cli/pr/list/Taskfile.yaml
Normal file
17
tests/cli/pr/list/Taskfile.yaml
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue