diff --git a/core-agent b/core-agent index 03133b8..b9bbf26 100755 Binary files a/core-agent and b/core-agent differ diff --git a/go.mod b/go.mod index ab0cd8c..8e1d256 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 08ab041..0421e11 100644 --- a/go.sum +++ b/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= diff --git a/pkg/agentic/dispatch.go b/pkg/agentic/dispatch.go index 5a360af..28edf35 100644 --- a/pkg/agentic/dispatch.go +++ b/pkg/agentic/dispatch.go @@ -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, diff --git a/pkg/agentic/queue.go b/pkg/agentic/queue.go index b0dba4b..badc8f2 100644 --- a/pkg/agentic/queue.go +++ b/pkg/agentic/queue.go @@ -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 } } diff --git a/pkg/agentic/resume.go b/pkg/agentic/resume.go index 1abb8fe..8dc7598 100644 --- a/pkg/agentic/resume.go +++ b/pkg/agentic/resume.go @@ -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 }