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:
parent
267a5e5e6d
commit
42788a2a88
6 changed files with 62 additions and 123 deletions
BIN
core-agent
BIN
core-agent
Binary file not shown.
2
go.mod
2
go.mod
|
|
@ -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
2
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue