- 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>
366 lines
9.7 KiB
Go
366 lines
9.7 KiB
Go
// SPDX-License-Identifier: EUPL-1.2
|
|
|
|
package runner
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// --- ConcurrencyLimit UnmarshalYAML ---
|
|
|
|
func TestQueue_ConcurrencyLimit_Good_Int(t *testing.T) {
|
|
var cl ConcurrencyLimit
|
|
node := &yaml.Node{Kind: yaml.ScalarNode, Value: "5", Tag: "!!int"}
|
|
err := cl.UnmarshalYAML(node)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, 5, cl.Total)
|
|
assert.Nil(t, cl.Models)
|
|
}
|
|
|
|
func TestQueue_ConcurrencyLimit_Good_Map(t *testing.T) {
|
|
input := `
|
|
total: 5
|
|
gpt-5.4: 1
|
|
gpt-5.3-codex-spark: 3
|
|
`
|
|
var cl ConcurrencyLimit
|
|
err := yaml.Unmarshal([]byte(input), &cl)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, 5, cl.Total)
|
|
assert.Equal(t, 1, cl.Models["gpt-5.4"])
|
|
assert.Equal(t, 3, cl.Models["gpt-5.3-codex-spark"])
|
|
}
|
|
|
|
func TestQueue_ConcurrencyLimit_Bad_InvalidYAML(t *testing.T) {
|
|
node := &yaml.Node{Kind: yaml.SequenceNode}
|
|
var cl ConcurrencyLimit
|
|
err := cl.UnmarshalYAML(node)
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestQueue_ConcurrencyLimit_Ugly_ZeroTotal(t *testing.T) {
|
|
input := `
|
|
total: 0
|
|
gpt-5.4: 1
|
|
`
|
|
var cl ConcurrencyLimit
|
|
err := yaml.Unmarshal([]byte(input), &cl)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, 0, cl.Total)
|
|
}
|
|
|
|
// --- baseAgent ---
|
|
|
|
func TestQueue_BaseAgent_Good_Simple(t *testing.T) {
|
|
assert.Equal(t, "codex", baseAgent("codex"))
|
|
assert.Equal(t, "claude", baseAgent("claude"))
|
|
assert.Equal(t, "gemini", baseAgent("gemini"))
|
|
}
|
|
|
|
func TestQueue_BaseAgent_Good_WithModel(t *testing.T) {
|
|
assert.Equal(t, "codex", baseAgent("codex:gpt-5.4"))
|
|
assert.Equal(t, "claude", baseAgent("claude:haiku"))
|
|
}
|
|
|
|
func TestQueue_BaseAgent_Good_CodexSpark(t *testing.T) {
|
|
assert.Equal(t, "codex", baseAgent("codex:gpt-5.3-codex-spark"))
|
|
}
|
|
|
|
func TestQueue_BaseAgent_Ugly_Empty(t *testing.T) {
|
|
assert.Equal(t, "", baseAgent(""))
|
|
}
|
|
|
|
func TestQueue_BaseAgent_Ugly_MultipleColons(t *testing.T) {
|
|
assert.Equal(t, "codex", baseAgent("codex:model:extra"))
|
|
}
|
|
|
|
// --- modelVariant ---
|
|
|
|
func TestQueue_ModelVariant_Good(t *testing.T) {
|
|
assert.Equal(t, "gpt-5.4", modelVariant("codex:gpt-5.4"))
|
|
assert.Equal(t, "haiku", modelVariant("claude:haiku"))
|
|
}
|
|
|
|
func TestQueue_ModelVariant_Bad_NoColon(t *testing.T) {
|
|
assert.Equal(t, "", modelVariant("codex"))
|
|
assert.Equal(t, "", modelVariant("claude"))
|
|
}
|
|
|
|
func TestQueue_ModelVariant_Ugly_Empty(t *testing.T) {
|
|
assert.Equal(t, "", modelVariant(""))
|
|
}
|
|
|
|
// --- canDispatchAgent ---
|
|
|
|
func TestQueue_CanDispatchAgent_Good_NoConfig(t *testing.T) {
|
|
svc := New()
|
|
can, _ := svc.canDispatchAgent("codex")
|
|
assert.True(t, can, "no config → unlimited")
|
|
}
|
|
|
|
func TestQueue_CanDispatchAgent_Good_UnknownAgent(t *testing.T) {
|
|
svc := New()
|
|
can, _ := svc.canDispatchAgent("unknown-agent")
|
|
assert.True(t, can)
|
|
}
|
|
|
|
func TestQueue_CanDispatchAgent_Bad_AtLimit(t *testing.T) {
|
|
svc := New()
|
|
// Simulate 5 running codex workspaces
|
|
for i := 0; i < 5; i++ {
|
|
svc.TrackWorkspace("ws-"+string(rune('a'+i)), &WorkspaceStatus{
|
|
Status: "running", Agent: "codex", PID: 99999, // PID won't be alive
|
|
})
|
|
}
|
|
// Since PIDs aren't alive, count should be 0
|
|
can, _ := svc.canDispatchAgent("codex")
|
|
assert.True(t, can, "dead PIDs don't count")
|
|
}
|
|
|
|
func TestQueue_CanDispatchAgent_Ugly_ZeroLimit(t *testing.T) {
|
|
svc := New()
|
|
// Zero total means unlimited
|
|
can, _ := svc.canDispatchAgent("codex")
|
|
assert.True(t, can)
|
|
}
|
|
|
|
// --- countRunningByAgent ---
|
|
|
|
func TestQueue_CountRunningByAgent_Good_Empty(t *testing.T) {
|
|
svc := New()
|
|
// Add a non-running entry so Registry is non-empty (avoids disk fallback)
|
|
svc.TrackWorkspace("ws-seed", &WorkspaceStatus{Status: "completed", Agent: "claude"})
|
|
assert.Equal(t, 0, svc.countRunningByAgent("codex"))
|
|
}
|
|
|
|
func TestQueue_CountRunningByAgent_Good_SkipsNonRunning(t *testing.T) {
|
|
svc := New()
|
|
svc.TrackWorkspace("ws-1", &WorkspaceStatus{Status: "completed", Agent: "codex"})
|
|
svc.TrackWorkspace("ws-2", &WorkspaceStatus{Status: "queued", Agent: "codex"})
|
|
assert.Equal(t, 0, svc.countRunningByAgent("codex"))
|
|
}
|
|
|
|
func TestQueue_CountRunningByAgent_Bad_SkipsMismatchedAgent(t *testing.T) {
|
|
svc := New()
|
|
svc.TrackWorkspace("ws-1", &WorkspaceStatus{Status: "running", Agent: "claude", PID: 1})
|
|
assert.Equal(t, 0, svc.countRunningByAgent("codex"))
|
|
}
|
|
|
|
func TestQueue_CountRunningByAgent_Ugly_DeadPIDsIgnored(t *testing.T) {
|
|
svc := New()
|
|
svc.TrackWorkspace("ws-1", &WorkspaceStatus{
|
|
Status: "running", Agent: "codex", PID: 999999999, // almost certainly not alive
|
|
})
|
|
assert.Equal(t, 0, svc.countRunningByAgent("codex"))
|
|
}
|
|
|
|
// --- countRunningByModel ---
|
|
|
|
func TestQueue_CountRunningByModel_Good_Empty(t *testing.T) {
|
|
svc := New()
|
|
assert.Equal(t, 0, svc.countRunningByModel("codex:gpt-5.4"))
|
|
}
|
|
|
|
func TestQueue_CountRunningByModel_Bad_WrongModel(t *testing.T) {
|
|
svc := New()
|
|
svc.TrackWorkspace("ws-1", &WorkspaceStatus{
|
|
Status: "running", Agent: "codex:gpt-5.3", PID: 1,
|
|
})
|
|
assert.Equal(t, 0, svc.countRunningByModel("codex:gpt-5.4"))
|
|
}
|
|
|
|
func TestQueue_CountRunningByModel_Ugly_ExactMatch(t *testing.T) {
|
|
svc := New()
|
|
svc.TrackWorkspace("ws-1", &WorkspaceStatus{
|
|
Status: "running", Agent: "codex:gpt-5.4", PID: 999999999,
|
|
})
|
|
// PID is dead so count is 0
|
|
assert.Equal(t, 0, svc.countRunningByModel("codex:gpt-5.4"))
|
|
}
|
|
|
|
// --- drainQueue ---
|
|
|
|
func TestQueue_DrainQueue_Good_FrozenDoesNothing(t *testing.T) {
|
|
svc := New()
|
|
svc.frozen = true
|
|
assert.NotPanics(t, func() {
|
|
svc.drainQueue()
|
|
})
|
|
}
|
|
|
|
func TestQueue_DrainQueue_Bad_EmptyWorkspace(t *testing.T) {
|
|
svc := New()
|
|
svc.frozen = false
|
|
assert.NotPanics(t, func() {
|
|
svc.drainQueue()
|
|
})
|
|
}
|
|
|
|
func TestQueue_DrainQueue_Ugly_NoStatusFiles(t *testing.T) {
|
|
svc := New()
|
|
svc.frozen = false
|
|
// drainOne scans disk — with no workspace root, it finds nothing
|
|
assert.False(t, svc.drainOne())
|
|
}
|
|
|
|
// --- loadAgentsConfig ---
|
|
|
|
func TestQueue_LoadAgentsConfig_Good_ReadsFile(t *testing.T) {
|
|
svc := New()
|
|
cfg := svc.loadAgentsConfig()
|
|
assert.NotNil(t, cfg)
|
|
assert.GreaterOrEqual(t, cfg.Version, 0)
|
|
}
|
|
|
|
func TestQueue_LoadAgentsConfig_Bad_NoFile(t *testing.T) {
|
|
t.Setenv("CORE_WORKSPACE", t.TempDir())
|
|
svc := New()
|
|
cfg := svc.loadAgentsConfig()
|
|
assert.NotNil(t, cfg, "should return defaults when no file found")
|
|
}
|
|
|
|
func TestQueue_LoadAgentsConfig_Ugly_InvalidYAML(t *testing.T) {
|
|
dir := t.TempDir()
|
|
t.Setenv("CORE_WORKSPACE", dir)
|
|
fs.Write(dir+"/agents.yaml", "{{{{invalid yaml")
|
|
svc := New()
|
|
cfg := svc.loadAgentsConfig()
|
|
assert.NotNil(t, cfg, "should return defaults on parse failure")
|
|
}
|
|
|
|
// --- AgentsConfig full parse ---
|
|
|
|
func TestQueue_AgentsConfig_Good_FullParse(t *testing.T) {
|
|
input := `
|
|
version: 1
|
|
dispatch:
|
|
default_agent: claude
|
|
default_template: coding
|
|
concurrency:
|
|
claude: 3
|
|
codex:
|
|
total: 5
|
|
gpt-5.4: 1
|
|
rates:
|
|
gemini:
|
|
reset_utc: "06:00"
|
|
sustained_delay: 120
|
|
`
|
|
var cfg AgentsConfig
|
|
err := yaml.Unmarshal([]byte(input), &cfg)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, 1, cfg.Version)
|
|
assert.Equal(t, 3, cfg.Concurrency["claude"].Total)
|
|
assert.Equal(t, 5, cfg.Concurrency["codex"].Total)
|
|
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)
|
|
}
|