fix(ax): refine harvest and dispatch naming

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-03-30 22:46:21 +00:00
parent b8e948f2d6
commit 877de43257
7 changed files with 59 additions and 63 deletions

View file

@ -57,7 +57,7 @@ func (s *PrepSubsystem) registerDispatchTool(server *mcp.Server) {
}, s.dispatch)
}
// agentCommand returns the command and args for a given agent type.
// command, args, err := agentCommand("codex:review", "Review the last 2 commits via git diff HEAD~2")
// Supports model variants: "gemini", "gemini:flash", "codex", "claude", "claude:haiku".
func agentCommand(agent, prompt string) (string, []string, error) {
commandResult := agentCommandResult(agent, prompt)
@ -161,12 +161,8 @@ func agentCommandResult(agent, prompt string) core.Result {
// Override via AGENT_DOCKER_IMAGE env var.
const defaultDockerImage = "core-dev"
// containerCommand wraps an agent command to run inside a Docker container.
// All agents run containerised — no bare metal execution.
// agentType is the base agent name (e.g. "local", "codex", "claude").
//
// cmd, args := containerCommand("local", "codex", []string{"exec", "..."}, repoDir, metaDir)
func containerCommand(agentType, command string, args []string, repoDir, metaDir string) (string, []string) {
// command, args := containerCommand("local", "codex", []string{"exec", "--oss"}, "/srv/.core/workspace/core/go-io/task-5/repo", "/srv/.core/workspace/core/go-io/task-5/.meta")
func containerCommand(command string, args []string, repoDir, metaDir string) (string, []string) {
image := core.Env("AGENT_DOCKER_IMAGE")
if image == "" {
image = defaultDockerImage
@ -405,8 +401,7 @@ func (s *PrepSubsystem) spawnAgent(agent, prompt, workspaceDir string) (int, str
fs.Delete(WorkspaceBlockedPath(workspaceDir))
// All agents run containerised
agentBase := core.SplitN(agent, ":", 2)[0]
command, args = containerCommand(agentBase, command, args, repoDir, metaDir)
command, args = containerCommand(command, args, repoDir, metaDir)
procSvc, ok := core.ServiceFor[*process.Service](s.Core(), "process")
if !ok {

View file

@ -5,7 +5,7 @@ package agentic
import core "dappco.re/go/core"
func Example_containerCommand() {
cmd, args := containerCommand("codex", "codex", []string{"--model", "gpt-5.4"}, "/workspace", "/meta")
cmd, args := containerCommand("codex", []string{"--model", "gpt-5.4"}, "/workspace", "/meta")
core.Println(cmd)
core.Println(len(args) > 0)
// Output:

View file

@ -9,19 +9,19 @@ import (
)
func TestDispatchsync_ContainerCommand_Good(t *testing.T) {
cmd, args := containerCommand("codex", "codex", []string{"--model", "gpt-5.4"}, "/workspace", "/meta")
cmd, args := containerCommand("codex", []string{"--model", "gpt-5.4"}, "/workspace", "/meta")
assert.Equal(t, "docker", cmd)
assert.Contains(t, args, "run")
}
func TestDispatchsync_ContainerCommand_Bad_UnknownAgent(t *testing.T) {
cmd, args := containerCommand("unknown", "unknown", nil, "/workspace", "/meta")
cmd, args := containerCommand("unknown", nil, "/workspace", "/meta")
assert.Equal(t, "docker", cmd)
assert.NotEmpty(t, args)
}
func TestDispatchsync_ContainerCommand_Ugly_EmptyArgs(t *testing.T) {
assert.NotPanics(t, func() {
containerCommand("codex", "codex", nil, "", "")
containerCommand("codex", nil, "", "")
})
}

View file

@ -584,7 +584,7 @@ func TestDispatch_ContainerCommand_Bad(t *testing.T) {
t.Setenv("DIR_HOME", "/home/dev")
// Empty command string — docker still runs, just with no command after image
cmd, args := containerCommand("codex", "", []string{}, "/ws/repo", "/ws/.meta")
cmd, args := containerCommand("", []string{}, "/ws/repo", "/ws/.meta")
assert.Equal(t, "docker", cmd)
assert.Contains(t, args, "run")
// The image should still be present in args

View file

@ -121,7 +121,7 @@ func TestDispatch_ContainerCommand_Good_Codex(t *testing.T) {
t.Setenv("AGENT_DOCKER_IMAGE", "")
t.Setenv("DIR_HOME", "/home/dev")
cmd, args := containerCommand("codex", "codex", []string{"exec", "--dangerously-bypass-approvals-and-sandbox", "do it"}, "/ws/repo", "/ws/.meta")
cmd, args := containerCommand("codex", []string{"exec", "--dangerously-bypass-approvals-and-sandbox", "do it"}, "/ws/repo", "/ws/.meta")
assert.Equal(t, "docker", cmd)
assert.Contains(t, args, "run")
assert.Contains(t, args, "--rm")
@ -138,7 +138,7 @@ func TestDispatch_ContainerCommand_Good_CustomImage(t *testing.T) {
t.Setenv("AGENT_DOCKER_IMAGE", "my-custom-image:latest")
t.Setenv("DIR_HOME", "/home/dev")
cmd, args := containerCommand("codex", "codex", []string{"exec"}, "/ws/repo", "/ws/.meta")
cmd, args := containerCommand("codex", []string{"exec"}, "/ws/repo", "/ws/.meta")
assert.Equal(t, "docker", cmd)
assert.Contains(t, args, "my-custom-image:latest")
}
@ -147,7 +147,7 @@ func TestDispatch_ContainerCommand_Good_ClaudeMountsConfig(t *testing.T) {
t.Setenv("AGENT_DOCKER_IMAGE", "")
t.Setenv("DIR_HOME", "/home/dev")
_, args := containerCommand("claude", "claude", []string{"-p", "do it"}, "/ws/repo", "/ws/.meta")
_, args := containerCommand("claude", []string{"-p", "do it"}, "/ws/repo", "/ws/.meta")
joined := strings.Join(args, " ")
assert.Contains(t, joined, ".claude:/home/dev/.claude:ro")
}
@ -156,7 +156,7 @@ func TestDispatch_ContainerCommand_Good_GeminiMountsConfig(t *testing.T) {
t.Setenv("AGENT_DOCKER_IMAGE", "")
t.Setenv("DIR_HOME", "/home/dev")
_, args := containerCommand("gemini", "gemini", []string{"-p", "do it"}, "/ws/repo", "/ws/.meta")
_, args := containerCommand("gemini", []string{"-p", "do it"}, "/ws/repo", "/ws/.meta")
joined := strings.Join(args, " ")
assert.Contains(t, joined, ".gemini:/home/dev/.gemini:ro")
}
@ -165,7 +165,7 @@ func TestDispatch_ContainerCommand_Good_CodexNoClaudeMount(t *testing.T) {
t.Setenv("AGENT_DOCKER_IMAGE", "")
t.Setenv("DIR_HOME", "/home/dev")
_, args := containerCommand("codex", "codex", []string{"exec"}, "/ws/repo", "/ws/.meta")
_, args := containerCommand("codex", []string{"exec"}, "/ws/repo", "/ws/.meta")
joined := strings.Join(args, " ")
// codex agent must NOT mount .claude config
assert.NotContains(t, joined, ".claude:/home/dev/.claude:ro")
@ -175,7 +175,7 @@ func TestDispatch_ContainerCommand_Good_APIKeysPassedByRef(t *testing.T) {
t.Setenv("AGENT_DOCKER_IMAGE", "")
t.Setenv("DIR_HOME", "/home/dev")
_, args := containerCommand("codex", "codex", []string{"exec"}, "/ws/repo", "/ws/.meta")
_, args := containerCommand("codex", []string{"exec"}, "/ws/repo", "/ws/.meta")
joined := strings.Join(args, " ")
assert.Contains(t, joined, "OPENAI_API_KEY")
assert.Contains(t, joined, "ANTHROPIC_API_KEY")
@ -187,7 +187,7 @@ func TestDispatch_ContainerCommand_Ugly_EmptyDirs(t *testing.T) {
t.Setenv("DIR_HOME", "")
// Should not panic with empty paths
cmd, args := containerCommand("codex", "codex", []string{"exec"}, "", "")
cmd, args := containerCommand("codex", []string{"exec"}, "", "")
assert.Equal(t, "docker", cmd)
assert.NotEmpty(t, args)
}

View file

@ -190,7 +190,8 @@ func (s *PrepSubsystem) watch(ctx context.Context, request *mcp.CallToolRequest,
}, nil
}
// findActiveWorkspaces returns workspace names that are running or queued.
// active := s.findActiveWorkspaces()
// if len(active) == 0 { return nil }
func (s *PrepSubsystem) findActiveWorkspaces() []string {
var active []string
for _, entry := range WorkspaceStatusPaths() {

View file

@ -1,11 +1,10 @@
// SPDX-License-Identifier: EUPL-1.2
// Harvest completed agent workspaces — push changes back to source repos.
// result := m.harvestWorkspace("/srv/.core/workspace/core/go-io/task-5")
// if result != nil && result.rejected == "" { /* ready-for-review */ }
//
// After an agent completes, its commits live in the workspace clone.
// This code pushes the agent's branch to the source repo so the
// changes are available for review. It checks for binaries and
// large files before pushing.
// Completed workspaces are scanned, validated, and marked ready for review.
// The code does not auto-push; review remains an explicit action.
package monitor
@ -26,8 +25,8 @@ type harvestResult struct {
rejected string // non-empty if rejected (binary, too large, etc.)
}
// harvestCompleted scans for completed workspaces and pushes their
// branches back to the source repos. Returns a summary message.
// summary := m.harvestCompleted()
// if summary != "" { core.Print(nil, summary) }
func (m *Subsystem) harvestCompleted() string {
var harvested []harvestResult
@ -60,7 +59,8 @@ func (m *Subsystem) harvestCompleted() string {
return core.Concat("Harvested: ", core.Join(", ", parts...))
}
// harvestWorkspace checks a single workspace and pushes if ready.
// result := m.harvestWorkspace("/srv/.core/workspace/core/go-io/task-5")
// if result != nil && result.rejected == "" { /* ready-for-review */ }
func (m *Subsystem) harvestWorkspace(workspaceDir string) *harvestResult {
statusResult := fs.Read(agentic.WorkspaceStatusPath(workspaceDir))
if !statusResult.OK {
@ -90,7 +90,7 @@ func (m *Subsystem) harvestWorkspace(workspaceDir string) *harvestResult {
return nil
}
// Check if there are commits to push
// Check if there are commits ahead of the default branch
branch := workspaceStatus.Branch
if branch == "" {
branch = m.detectBranch(repoDir)
@ -103,10 +103,10 @@ func (m *Subsystem) harvestWorkspace(workspaceDir string) *harvestResult {
// Check for unpushed commits
unpushed := m.countUnpushed(repoDir, branch)
if unpushed == 0 {
return nil // already pushed or no commits
return nil // already on origin or no commits
}
// Safety checks before pushing
// Safety checks before marking ready-for-review
if reason := m.checkSafety(repoDir); reason != "" {
updateStatus(workspaceDir, "rejected", reason)
return &harvestResult{repo: workspaceStatus.Repo, branch: branch, rejected: reason}
@ -123,48 +123,48 @@ func (m *Subsystem) harvestWorkspace(workspaceDir string) *harvestResult {
return &harvestResult{repo: workspaceStatus.Repo, branch: branch, files: files}
}
// gitOutput runs a git command and returns trimmed stdout via Core Process.
func (m *Subsystem) gitOutput(dir string, args ...string) string {
processResult := m.Core().Process().RunIn(context.Background(), dir, "git", args...)
// output := m.gitOutput("/srv/.core/workspace/core/go-io/task-5/repo", "log", "--oneline")
func (m *Subsystem) gitOutput(repoDir string, args ...string) string {
processResult := m.Core().Process().RunIn(context.Background(), repoDir, "git", args...)
if !processResult.OK {
return ""
}
return core.Trim(processResult.Value.(string))
}
// gitOK runs a git command and returns true if it exits 0.
func (m *Subsystem) gitOK(dir string, args ...string) bool {
return m.Core().Process().RunIn(context.Background(), dir, "git", args...).OK
// ok := m.gitOK("/srv/.core/workspace/core/go-io/task-5/repo", "rev-parse", "--verify", "main")
func (m *Subsystem) gitOK(repoDir string, args ...string) bool {
return m.Core().Process().RunIn(context.Background(), repoDir, "git", args...).OK
}
// detectBranch returns the current branch name.
func (m *Subsystem) detectBranch(srcDir string) string {
return m.gitOutput(srcDir, "rev-parse", "--abbrev-ref", "HEAD")
// branch := m.detectBranch("/srv/.core/workspace/core/go-io/task-5/repo")
func (m *Subsystem) detectBranch(repoDir string) string {
return m.gitOutput(repoDir, "rev-parse", "--abbrev-ref", "HEAD")
}
// defaultBranch detects the default branch of the repo (main, master, etc.).
func (m *Subsystem) defaultBranch(srcDir string) string {
if ref := m.gitOutput(srcDir, "symbolic-ref", "refs/remotes/origin/HEAD", "--short"); ref != "" {
// base := m.defaultBranch("/srv/.core/workspace/core/go-io/task-5/repo")
func (m *Subsystem) defaultBranch(repoDir string) string {
if ref := m.gitOutput(repoDir, "symbolic-ref", "refs/remotes/origin/HEAD", "--short"); ref != "" {
if core.HasPrefix(ref, "origin/") {
return core.TrimPrefix(ref, "origin/")
}
return ref
}
for _, branch := range []string{"main", "master"} {
if m.gitOK(srcDir, "rev-parse", "--verify", branch) {
if m.gitOK(repoDir, "rev-parse", "--verify", branch) {
return branch
}
}
return "main"
}
// countUnpushed returns the number of commits ahead of origin's default branch.
func (m *Subsystem) countUnpushed(srcDir, branch string) int {
base := m.defaultBranch(srcDir)
out := m.gitOutput(srcDir, "rev-list", "--count", core.Concat("origin/", base, "..", branch))
// ahead := m.countUnpushed("/srv/.core/workspace/core/go-io/task-5/repo", "feature/ax-cleanup")
func (m *Subsystem) countUnpushed(repoDir, branch string) int {
base := m.defaultBranch(repoDir)
out := m.gitOutput(repoDir, "rev-list", "--count", core.Concat("origin/", base, "..", branch))
if out == "" {
// Fallback
out2 := m.gitOutput(srcDir, "log", "--oneline", core.Concat(base, "..", branch))
out2 := m.gitOutput(repoDir, "log", "--oneline", core.Concat(base, "..", branch))
if out2 == "" {
return 0
}
@ -181,10 +181,10 @@ func (m *Subsystem) countUnpushed(srcDir, branch string) int {
return count
}
// checkSafety rejects workspaces with binaries or oversized files.
func (m *Subsystem) checkSafety(srcDir string) string {
base := m.defaultBranch(srcDir)
out := m.gitOutput(srcDir, "diff", "--name-only", core.Concat(base, "...HEAD"))
// reason := m.checkSafety("/srv/.core/workspace/core/go-io/task-5/repo")
func (m *Subsystem) checkSafety(repoDir string) string {
base := m.defaultBranch(repoDir)
out := m.gitOutput(repoDir, "diff", "--name-only", core.Concat(base, "...HEAD"))
if out == "" {
return "safety check failed: git diff error"
}
@ -208,7 +208,7 @@ func (m *Subsystem) checkSafety(srcDir string) string {
return core.Sprintf("binary file added: %s", file)
}
fullPath := core.JoinPath(srcDir, file)
fullPath := core.JoinPath(repoDir, file)
if stat := fs.Stat(fullPath); stat.OK {
if info, ok := stat.Value.(interface{ Size() int64 }); ok && info.Size() > 1024*1024 {
return core.Sprintf("large file: %s (%d bytes)", file, info.Size())
@ -219,10 +219,10 @@ func (m *Subsystem) checkSafety(srcDir string) string {
return ""
}
// countChangedFiles returns the number of files changed vs the default branch.
func (m *Subsystem) countChangedFiles(srcDir string) int {
base := m.defaultBranch(srcDir)
out := m.gitOutput(srcDir, "diff", "--name-only", core.Concat(base, "...HEAD"))
// files := m.countChangedFiles("/srv/.core/workspace/core/go-io/task-5/repo")
func (m *Subsystem) countChangedFiles(repoDir string) int {
base := m.defaultBranch(repoDir)
out := m.gitOutput(repoDir, "diff", "--name-only", core.Concat(base, "...HEAD"))
if out == "" {
return 0
}
@ -233,9 +233,9 @@ func (m *Subsystem) countChangedFiles(srcDir string) int {
return len(lines)
}
// pushBranch pushes the agent's branch to origin.
func (m *Subsystem) pushBranch(srcDir, branch string) error {
processResult := m.Core().Process().RunIn(context.Background(), srcDir, "git", "push", "origin", branch)
// _ = m.pushBranch("/srv/.core/workspace/core/go-io/task-5/repo", "feature/ax-cleanup")
func (m *Subsystem) pushBranch(repoDir, branch string) error {
processResult := m.Core().Process().RunIn(context.Background(), repoDir, "git", "push", "origin", branch)
if !processResult.OK {
if err, ok := processResult.Value.(error); ok {
return core.E("harvest.pushBranch", "push failed", err)