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:
Snider 2026-04-14 11:45:09 +01:00
parent db6d06ae2b
commit edfcb1bdfe
20 changed files with 935 additions and 32 deletions

View file

@ -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]

View file

@ -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"},

View file

@ -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}
}

View file

@ -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")

View 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
}

View file

@ -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) {

View file

@ -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()

View file

@ -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)

View file

@ -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)
}

View file

@ -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)

View file

@ -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()

View file

@ -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)

View file

@ -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,
},
})

View file

@ -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,
},
})

View file

@ -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()

View file

@ -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)
}

View file

@ -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

View file

@ -0,0 +1,6 @@
version: "3"
tasks:
test:
cmds:
- task -d delete test

View 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

View 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