agent/pkg/agentic/dispatch.go
Snider 267a5e5e6d fix(dispatch): use --output-format text for claude agent logging
Claude -p output wasn't reaching the log file. Explicitly set
--output-format text, --permission-mode bypassPermissions (replaces
deprecated flag), and --no-session-persistence for ephemeral workers.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-16 17:37:58 +00:00

231 lines
7.2 KiB
Go

// SPDX-License-Identifier: EUPL-1.2
package agentic
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// DispatchInput is the input for agentic_dispatch.
type DispatchInput struct {
Repo string `json:"repo"` // Target repo (e.g. "go-io")
Org string `json:"org,omitempty"` // Forge org (default "core")
Task string `json:"task"` // What the agent should do
Agent string `json:"agent,omitempty"` // "gemini" (default), "codex", "claude"
Template string `json:"template,omitempty"` // "conventions", "security", "coding" (default)
PlanTemplate string `json:"plan_template,omitempty"` // Plan template: bug-fix, code-review, new-feature, refactor, feature-port
Variables map[string]string `json:"variables,omitempty"` // Template variable substitution
Persona string `json:"persona,omitempty"` // Persona: engineering/backend-architect, testing/api-tester, etc.
Issue int `json:"issue,omitempty"` // Forge issue to work from
DryRun bool `json:"dry_run,omitempty"` // Preview without executing
}
// DispatchOutput is the output for agentic_dispatch.
type DispatchOutput struct {
Success bool `json:"success"`
Agent string `json:"agent"`
Repo string `json:"repo"`
WorkspaceDir string `json:"workspace_dir"`
Prompt string `json:"prompt,omitempty"`
PID int `json:"pid,omitempty"`
OutputFile string `json:"output_file,omitempty"`
}
func (s *PrepSubsystem) registerDispatchTool(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{
Name: "agentic_dispatch",
Description: "Dispatch a subagent (Gemini, Codex, or Claude) to work on a task. Preps a sandboxed workspace first, then spawns the agent inside it. Templates: conventions, security, coding.",
}, s.dispatch)
}
// agentCommand returns the command and args for a given agent type.
// Supports model variants: "gemini", "gemini:flash", "gemini:pro", "claude", "claude:haiku".
func agentCommand(agent, prompt string) (string, []string, error) {
parts := strings.SplitN(agent, ":", 2)
base := parts[0]
model := ""
if len(parts) > 1 {
model = parts[1]
}
switch base {
case "gemini":
args := []string{"-p", prompt, "--yolo", "--sandbox"}
if model != "" {
args = append(args, "-m", "gemini-2.5-"+model)
}
return "gemini", args, nil
case "codex":
return "codex", []string{"--approval-mode", "full-auto", "-q", prompt}, nil
case "claude":
args := []string{"-p", prompt, "--output-format", "text", "--permission-mode", "bypassPermissions", "--no-session-persistence"}
if model != "" {
args = append(args, "--model", model)
}
return "claude", args, nil
case "local":
home, _ := os.UserHomeDir()
script := filepath.Join(home, "Code", "core", "agent", "scripts", "local-agent.sh")
return "bash", []string{script, prompt}, nil
default:
return "", nil, fmt.Errorf("unknown agent: %s", agent)
}
}
func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest, input DispatchInput) (*mcp.CallToolResult, DispatchOutput, error) {
if input.Repo == "" {
return nil, DispatchOutput{}, fmt.Errorf("repo is required")
}
if input.Task == "" {
return nil, DispatchOutput{}, fmt.Errorf("task is required")
}
if input.Org == "" {
input.Org = "core"
}
if input.Agent == "" {
input.Agent = "gemini"
}
if input.Template == "" {
input.Template = "coding"
}
// Step 1: Prep the sandboxed workspace
prepInput := PrepInput{
Repo: input.Repo,
Org: input.Org,
Issue: input.Issue,
Task: input.Task,
Template: input.Template,
PlanTemplate: input.PlanTemplate,
Variables: input.Variables,
Persona: input.Persona,
}
_, prepOut, err := s.prepWorkspace(ctx, req, prepInput)
if err != nil {
return nil, DispatchOutput{}, fmt.Errorf("prep workspace failed: %w", err)
}
wsDir := prepOut.WorkspaceDir
srcDir := filepath.Join(wsDir, "src")
// The prompt is just: read PROMPT.md and do the work
prompt := "Read PROMPT.md for instructions. All context files (CLAUDE.md, TODO.md, CONTEXT.md, CONSUMERS.md, RECENT.md) are in the parent directory. Work in this directory."
if input.DryRun {
// Read PROMPT.md for the dry run output
promptContent, _ := os.ReadFile(filepath.Join(wsDir, "PROMPT.md"))
return nil, DispatchOutput{
Success: true,
Agent: input.Agent,
Repo: input.Repo,
WorkspaceDir: wsDir,
Prompt: string(promptContent),
}, nil
}
// Step 2: Check per-agent concurrency limit
if !s.canDispatchAgent(input.Agent) {
// Queue the workspace — write status as "queued" and return
writeStatus(wsDir, &WorkspaceStatus{
Status: "queued",
Agent: input.Agent,
Repo: input.Repo,
Org: input.Org,
Task: input.Task,
StartedAt: time.Now(),
Runs: 0,
})
return nil, DispatchOutput{
Success: true,
Agent: input.Agent,
Repo: input.Repo,
WorkspaceDir: wsDir,
OutputFile: "queued — waiting for a slot",
}, nil
}
// Step 3: Spawn agent as a detached process
// Uses Setpgid so the agent survives parent (MCP server) death.
// Output goes directly to log file (not buffered in memory).
command, args, err := agentCommand(input.Agent, prompt)
if err != nil {
return nil, DispatchOutput{}, err
}
outputFile := filepath.Join(wsDir, fmt.Sprintf("agent-%s.log", input.Agent))
outFile, err := os.Create(outputFile)
if err != nil {
return nil, DispatchOutput{}, fmt.Errorf("failed to create log file: %w", err)
}
// Fully detach from terminal:
// - Setpgid: own process group
// - Stdin from /dev/null
// - TERM=dumb prevents terminal control sequences
// - NO_COLOR=1 disables colour output
devNull, _ := os.Open(os.DevNull)
cmd := exec.Command(command, args...)
cmd.Dir = srcDir
cmd.Stdin = devNull
cmd.Stdout = outFile
cmd.Stderr = outFile
cmd.Env = append(os.Environ(), "TERM=dumb", "NO_COLOR=1", "CI=true")
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
if err := cmd.Start(); err != nil {
outFile.Close()
return nil, DispatchOutput{}, fmt.Errorf("failed to spawn %s: %w", input.Agent, err)
}
pid := cmd.Process.Pid
// Write initial status
writeStatus(wsDir, &WorkspaceStatus{
Status: "running",
Agent: input.Agent,
Repo: input.Repo,
Org: input.Org,
Task: input.Task,
PID: pid,
StartedAt: time.Now(),
Runs: 1,
})
// Background goroutine: close file handle when process exits,
// update status, then drain queue if a slot opened up.
go func() {
cmd.Wait()
outFile.Close()
// Update status to completed
if st, err := readStatus(wsDir); err == nil {
st.Status = "completed"
st.PID = 0
writeStatus(wsDir, st)
}
// Ingest scan findings as issues
s.ingestFindings(wsDir)
// Drain queue: pop next queued workspace and spawn it
s.drainQueue()
}()
return nil, DispatchOutput{
Success: true,
Agent: input.Agent,
Repo: input.Repo,
WorkspaceDir: wsDir,
PID: pid,
OutputFile: outputFile,
}, nil
}