refactor(dispatch): use go-process for agent spawning

Replace raw exec.Command with go-process.StartWithOptions for all agent
spawning (dispatch, queue, resume). Uses pipes for output capture instead
of file descriptor redirect — fixes Claude Code's empty log issue.

Shared spawnAgent() helper eliminates duplication across 3 files.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-16 17:52:55 +00:00
parent 267a5e5e6d
commit 42788a2a88
6 changed files with 62 additions and 123 deletions

Binary file not shown.

2
go.mod
View file

@ -28,7 +28,7 @@ require (
require (
forge.lthn.ai/core/go-crypt v0.1.6 // indirect
forge.lthn.ai/core/go-process v0.2.2 // indirect
forge.lthn.ai/core/go-process v0.2.4 // indirect
forge.lthn.ai/core/go-rag v0.1.0 // indirect
forge.lthn.ai/core/go-webview v0.1.0 // indirect
github.com/42wim/httpsig v1.2.3 // indirect

2
go.sum
View file

@ -22,6 +22,8 @@ forge.lthn.ai/core/go-log v0.0.3 h1:Ip//c94QzvSCeFWI7WVDLBRxd1CmqLgs/UZ5iIAqnBc=
forge.lthn.ai/core/go-log v0.0.3/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
forge.lthn.ai/core/go-process v0.2.2 h1:bnHFtzg92udochDDB6bD2luzzmr9ETKWmGzSsGjFFYE=
forge.lthn.ai/core/go-process v0.2.2/go.mod h1:gVTbxL16ccUIexlFcyDtCy7LfYvD8Rtyzfo8bnXAXrU=
forge.lthn.ai/core/go-process v0.2.4 h1:LAkPFcmaT3OKVcEPhcEgc35MGOUkEcQCEktRpiNxLd4=
forge.lthn.ai/core/go-process v0.2.4/go.mod h1:gVTbxL16ccUIexlFcyDtCy7LfYvD8Rtyzfo8bnXAXrU=
forge.lthn.ai/core/go-rag v0.1.0 h1:H5umiRryuq6J6l889s0OsxWpmq5P5c3A9Bkj0cQyO7k=
forge.lthn.ai/core/go-rag v0.1.0/go.mod h1:bB8Fy98G2zxVoe7k2B85gXvim6frJdbAMnDyW4peUVU=
forge.lthn.ai/core/go-ratelimit v0.1.0 h1:8Y6Mb/K5FMDng4B0wIh7beD05KXddi1BDwatI96XouA=

View file

@ -6,12 +6,11 @@ import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
"forge.lthn.ai/core/go-process"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@ -81,6 +80,55 @@ func agentCommand(agent, prompt string) (string, []string, error) {
}
}
// spawnAgent launches an agent process via go-process and returns the PID.
// Output is captured via pipes and written to the log file on completion.
// The background goroutine handles status updates, findings ingestion, and queue drain.
func (s *PrepSubsystem) spawnAgent(agent, prompt, wsDir, srcDir string) (int, string, error) {
command, args, err := agentCommand(agent, prompt)
if err != nil {
return 0, "", err
}
outputFile := filepath.Join(wsDir, fmt.Sprintf("agent-%s.log", agent))
proc, err := process.StartWithOptions(context.Background(), process.RunOptions{
Command: command,
Args: args,
Dir: srcDir,
Env: []string{"TERM=dumb", "NO_COLOR=1", "CI=true"},
Detach: true,
})
if err != nil {
return 0, "", fmt.Errorf("failed to spawn %s: %w", agent, err)
}
pid := proc.Info().PID
go func() {
proc.Wait()
// Write captured output to log file
if output := proc.Output(); output != "" {
os.WriteFile(outputFile, []byte(output), 0644)
}
// 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
s.drainQueue()
}()
return pid, outputFile, nil
}
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")
@ -153,42 +201,12 @@ func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest,
}, 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)
// Step 3: Spawn agent via go-process (pipes for output capture)
pid, outputFile, err := s.spawnAgent(input.Agent, prompt, wsDir, srcDir)
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,
@ -200,26 +218,6 @@ func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest,
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,

View file

@ -5,7 +5,6 @@ package agentic
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
@ -200,52 +199,16 @@ func (s *PrepSubsystem) drainQueue() {
srcDir := filepath.Join(wsDir, "src")
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."
command, args, err := agentCommand(st.Agent, prompt)
pid, _, err := s.spawnAgent(st.Agent, prompt, wsDir, srcDir)
if err != nil {
continue
}
outputFile := filepath.Join(wsDir, fmt.Sprintf("agent-%s.log", st.Agent))
outFile, err := os.Create(outputFile)
if err != nil {
continue
}
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()
continue
}
st.Status = "running"
st.PID = cmd.Process.Pid
st.PID = pid
st.Runs++
writeStatus(wsDir, st)
go func() {
cmd.Wait()
outFile.Close()
if st2, err := readStatus(wsDir); err == nil {
st2.Status = "completed"
st2.PID = 0
writeStatus(wsDir, st2)
}
// Ingest scan findings as issues
s.ingestFindings(wsDir)
s.drainQueue()
}()
return
}
}

View file

@ -6,9 +6,7 @@ import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"syscall"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@ -93,46 +91,24 @@ func (s *PrepSubsystem) resume(ctx context.Context, _ *mcp.CallToolRequest, inpu
}, nil
}
// Spawn agent as detached process (survives parent death)
outputFile := filepath.Join(wsDir, fmt.Sprintf("agent-%s-run%d.log", agent, st.Runs+1))
command, args, err := agentCommand(agent, prompt)
// Spawn agent via go-process
pid, _, err := s.spawnAgent(agent, prompt, wsDir, srcDir)
if err != nil {
return nil, ResumeOutput{}, err
}
devNull, _ := os.Open(os.DevNull)
outFile, _ := os.Create(outputFile)
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, ResumeOutput{}, fmt.Errorf("failed to spawn %s: %w", agent, err)
}
// Update status
st.Status = "running"
st.PID = cmd.Process.Pid
st.PID = pid
st.Runs++
st.Question = ""
writeStatus(wsDir, st)
go func() {
cmd.Wait()
outFile.Close()
}()
return nil, ResumeOutput{
Success: true,
Workspace: input.Workspace,
Agent: agent,
PID: cmd.Process.Pid,
OutputFile: outputFile,
PID: pid,
OutputFile: filepath.Join(wsDir, fmt.Sprintf("agent-%s.log", agent)),
}, nil
}