fix: Multiple bug fixes for agent lifecycle and task creation (#122)

This commit is contained in:
João Salavisa
2026-01-29 23:40:02 +00:00
committed by GitHub
parent d6755b702f
commit 49f98b88ff
15 changed files with 262 additions and 38 deletions
@@ -225,6 +225,10 @@ func (m *Manager) Start(ctx context.Context) error {
m.cmd = exec.Command(m.cfg.AgentArgs[0], m.cfg.AgentArgs[1:]...)
m.cmd.Dir = m.cfg.WorkDir
m.cmd.Env = m.cfg.AgentEnv
// Create a new process group so we can kill all child processes together.
// This is important for adapters like OpenCode that spawn child processes
// (npx -> sh -> node -> opencode binary).
setProcGroup(m.cmd)
// Set up pipes
var err error
@@ -476,52 +480,85 @@ func (m *Manager) Stop(ctx context.Context) error {
status := m.Status()
if status == StatusStopped || status == StatusStopping {
m.logger.Info("Stop called but already stopped/stopping", zap.String("status", string(status)))
return nil
}
m.logger.Info("stopping agent process")
m.logger.Info("stopping agent process - START")
m.status.Store(StatusStopping)
// Stop shell session
m.logger.Debug("stopping shell session")
if m.shell != nil {
if err := m.shell.Stop(); err != nil {
m.logger.Debug("failed to stop shell session", zap.Error(err))
}
m.shell = nil
}
m.logger.Debug("shell session stopped")
// Stop all running workspace processes (dev server, setup, cleanup, custom).
m.logger.Debug("stopping workspace processes")
if m.processRunner != nil {
if err := m.processRunner.StopAll(ctx); err != nil {
m.logger.Debug("failed to stop workspace processes", zap.Error(err))
}
}
m.logger.Debug("workspace processes stopped")
// Stop workspace tracker
m.logger.Debug("stopping workspace tracker")
if m.workspaceTracker != nil {
m.workspaceTracker.Stop()
}
m.logger.Debug("workspace tracker stopped")
// Close the adapter
m.logger.Debug("closing adapter")
if m.adapter != nil {
if err := m.adapter.Close(); err != nil {
m.logger.Debug("failed to close adapter", zap.Error(err))
}
}
m.logger.Debug("adapter closed")
// Close stop channel to signal readers
m.logger.Debug("closing stop channel")
if m.stopCh != nil {
close(m.stopCh)
}
m.logger.Debug("stop channel closed")
// Close stdin to signal EOF to agent
m.logger.Debug("closing stdin")
if m.stdin != nil {
if err := m.stdin.Close(); err != nil {
m.logger.Debug("failed to close stdin", zap.Error(err))
}
}
m.logger.Debug("stdin closed")
// Some adapters (like OpenCode) run as HTTP servers and don't exit on stdin close.
// For these, we need to kill the process group immediately.
// We kill the process group to ensure all child processes are killed too.
// This is important because OpenCode spawns: npx -> sh -> node -> opencode binary
if m.adapter != nil && m.adapter.RequiresProcessKill() {
if m.cmd != nil && m.cmd.Process != nil {
pid := m.cmd.Process.Pid
m.logger.Debug("killing process group", zap.Int("pgid", pid))
// Kill the entire process group (platform-specific implementation)
if err := killProcessGroup(pid); err != nil {
m.logger.Debug("failed to kill process group, trying single process", zap.Error(err))
// Fallback to killing just the process
if err := m.cmd.Process.Kill(); err != nil {
m.logger.Warn("failed to kill process", zap.Error(err))
}
}
}
}
// Wait for process to exit with timeout
m.logger.Debug("waiting for process to exit")
done := make(chan struct{})
go func() {
m.wg.Wait()
@@ -542,6 +579,7 @@ func (m *Manager) Stop(ctx context.Context) error {
}
m.status.Store(StatusStopped)
m.logger.Info("stopping agent process - COMPLETE")
return nil
}
@@ -0,0 +1,22 @@
//go:build unix
package process
import (
"os/exec"
"syscall"
)
// setProcGroup configures the command to run in its own process group.
// This allows us to kill all child processes together.
func setProcGroup(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
}
// killProcessGroup kills the entire process group for the given PID.
// Returns nil if successful, or an error if the kill failed.
func killProcessGroup(pid int) error {
// Kill the entire process group by using negative PID
return syscall.Kill(-pid, syscall.SIGKILL)
}
@@ -0,0 +1,29 @@
//go:build windows
package process
import (
"fmt"
"os/exec"
"syscall"
)
// setProcGroup configures the command to run in its own process group.
// On Windows, we use CREATE_NEW_PROCESS_GROUP flag.
func setProcGroup(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{
CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP,
}
}
// killProcessGroup kills the entire process tree for the given PID.
// On Windows, we use taskkill with /T flag to kill the process tree.
func killProcessGroup(pid int) error {
// taskkill /F /T /PID <pid>
// /F = force kill
// /T = kill child processes (tree)
// /PID = process ID
kill := exec.Command("taskkill", "/F", "/T", "/PID", fmt.Sprintf("%d", pid))
return kill.Run()
}
@@ -697,8 +697,12 @@ func (wt *WorkspaceTracker) enrichWithDiffData(ctx context.Context, update *type
// uncommitted changes in the "files changed" count.
}
// Also check for staged changes
stagedCmd := exec.CommandContext(ctx, "git", "diff", "--cached", "--numstat")
// For staged files that don't have unstaged changes, we need to get the diff from the index.
// The first diff (git diff baseRef) shows worktree vs baseRef, but if a file is staged
// and has no additional unstaged changes, its diff won't appear there.
// We use git diff --cached baseRef to get the staged diff content for such files,
// using the same baseRef for consistency.
stagedCmd := exec.CommandContext(ctx, "git", "diff", "--cached", "--numstat", baseRef)
stagedCmd.Dir = wt.workDir
stagedOut, err := stagedCmd.Output()
if err != nil {
@@ -723,18 +727,19 @@ func (wt *WorkspaceTracker) enrichWithDiffData(ctx context.Context, update *type
// Update existing file info if it exists
if fileInfo, exists := update.Files[filePath]; exists {
// Add staged changes to the counts
fileInfo.Additions += additions
fileInfo.Deletions += deletions
// Only set additions/deletions if they weren't already set by the first diff.
// This prevents double-counting when changes appear in both diffs.
if fileInfo.Additions == 0 && fileInfo.Deletions == 0 {
fileInfo.Additions = additions
fileInfo.Deletions = deletions
}
// Get the staged diff content
diffCmd := exec.CommandContext(ctx, "git", "diff", "--cached", "--", filePath)
diffCmd.Dir = wt.workDir
if diffOut, err := diffCmd.Output(); err == nil {
// Append staged diff to existing diff
if fileInfo.Diff != "" {
fileInfo.Diff += "\n\n--- Staged changes ---\n" + string(diffOut)
} else {
// Get the staged diff content if we don't have diff content yet
// Use the same baseRef for consistency with unstaged files
if fileInfo.Diff == "" {
diffCmd := exec.CommandContext(ctx, "git", "diff", "--cached", baseRef, "--", filePath)
diffCmd.Dir = wt.workDir
if diffOut, err := diffCmd.Output(); err == nil {
fileInfo.Diff = string(diffOut)
}
}
@@ -1115,12 +1120,9 @@ func (wt *WorkspaceTracker) buildFileTreeNode(fullPath, relPath string, info os.
// Build children
node.Children = make([]*types.FileTreeNode, 0, len(entries))
for _, entry := range entries {
// Skip hidden files and common ignore patterns
// Skip specific directories that should be ignored
name := entry.Name()
if strings.HasPrefix(name, ".") && name != "." && name != ".." {
continue
}
if name == "node_modules" || name == ".next" || name == "dist" || name == "build" {
if name == ".git" || name == "node_modules" || name == ".next" || name == "dist" || name == "build" {
continue
}