Files
kandev/apps/backend/internal/agentctl/server/process/manager.go
T
2026-08-21 19:28:21 +01:00

2777 lines
90 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package process manages the agent subprocess lifecycle
package process
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/kandev/kandev/internal/agentctl/server/adapter"
"github.com/kandev/kandev/internal/agentctl/server/config"
"github.com/kandev/kandev/internal/agentctl/server/shell"
"github.com/kandev/kandev/internal/agentctl/server/utility"
"github.com/kandev/kandev/internal/agentctl/types"
"github.com/kandev/kandev/internal/agentctl/types/streams"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/gitconfigenv"
tools "github.com/kandev/kandev/internal/tools/installer"
"go.uber.org/zap"
)
// Status represents the agent process status
type Status string
const (
StatusStopped Status = "stopped"
StatusStarting Status = "starting"
StatusRunning Status = "running"
StatusPaused Status = "paused"
StatusStopping Status = "stopping"
StatusError Status = "error"
)
// errorWrapper wraps an error so it can be stored in atomic.Value (which cannot store nil)
type errorWrapper struct {
err error
}
// PendingPermission represents a permission request waiting for user response
type PendingPermission struct {
ID string
Request *adapter.PermissionRequest
ResponseCh chan *adapter.PermissionResponse
CreatedAt time.Time
}
// PermissionNotification is sent when the agent requests permission
type PermissionNotification struct {
PendingID string `json:"pending_id"`
SessionID string `json:"session_id"`
ToolCallID string `json:"tool_call_id"`
Title string `json:"title"`
Options []adapter.PermissionOption `json:"options"`
ActionType string `json:"action_type,omitempty"`
ActionDetails map[string]interface{} `json:"action_details,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// defaultStderrBufferSize is the number of recent stderr lines to keep for error context
const defaultStderrBufferSize = 50
const processKillRequiredExitGrace = 250 * time.Millisecond
// processDefaultExitGrace is the no-deadline upper bound for graceful adapters
// after adapter/stdin close. It is intentionally longer than the kill-required
// fast path so ACP-style agents can flush state, while still bounding callers
// that pass context.Background().
const processDefaultExitGrace = 5 * time.Second
const processGroupTerminateGrace = 2 * time.Second
const processGroupPollInterval = 50 * time.Millisecond
// Manager manages the agent subprocess
type Manager struct {
cfg *config.InstanceConfig
logger *logger.Logger
// Process state
cmd *exec.Cmd
processLifecycle processLifecycleHandle
processLifecycleMu sync.Mutex
stdin io.WriteCloser
stdout io.ReadCloser
stderr io.ReadCloser
status atomic.Value // Status
exitCode atomic.Int32
exitErr atomic.Value // error
// Stderr buffering for error context
stderrBuffer []string
stderrMu sync.RWMutex
stderrConsumer adapter.StderrLineConsumer
stderrSanitizer adapter.StderrLineSanitizer
// Workspace tracker for git status and file changes
workspaceTracker *WorkspaceTracker
// repoTrackers holds per-repository trackers for multi-repo task roots.
// Each tracker stamps RepositoryName onto its emitted events and shares
// subscriber channels with the root via the Manager fan-out.
// Empty for single-repo workspaces.
//
// Mutated post-launch by RescanRepositories (multi-branch add) while other
// goroutines (gateway subscribe/unsubscribe, poll-mode updates) iterate
// the slice — every read and write must go through repoTrackersMu.
// workspaceTracker is also guarded by the same lock because rescan can
// swap it when transitioning single→multi mode.
repoTrackers []*WorkspaceTracker
repoTrackersMu sync.RWMutex
// workspaceSourceRoots is the canonical durable-source allowlist used both
// by workspace operations and repository-child discovery. It is guarded by
// repoTrackersMu so a rebind snapshots its proposed policy before creating
// replacement trackers.
workspaceSourceRoots []string
// rescanMu serializes RescanRepositories calls so two concurrent
// rescans can't both observe an empty tracker set and double-bootstrap
// (or both append duplicate trackers for the same new child). The
// per-field repoTrackersMu still allows concurrent subscribe/unsubscribe
// readers while a rescan is in flight.
rescanMu sync.Mutex
// workspacePollMode is the most recent mode the gateway pushed for this
// workspace. Trackers created after that point — rescan, rebind or
// reconcile adding a repository to a workspace that is already focused —
// inherit it, because no further push arrives unless the session's own
// mode changes.
//
// Guarded by repoTrackersMu rather than a mutex of its own: the mode and
// the tracker set have to move together. A separate lock let a tracker read
// the mode, then get registered after a concurrent push had already
// snapshotted the set — adopting a stale mode with its grace timer disarmed,
// with nothing left to correct it.
workspacePollMode PollMode
workspacePollModeSet bool
// workspaceTrackersBySubpath caches per-subpath trackers for multi-repo
// task roots. Key is the cleaned subpath (relative to cfg.WorkDir). The
// root tracker lives in workspaceTracker above.
workspaceTrackersBySubpath map[string]*WorkspaceTracker
workspaceTrackersMu sync.Mutex
// baseBranchesMu guards mutations to cfg.BaseBranches so the
// UpdateBaseBranches writer doesn't race with the rescan-path and
// lazy-subpath readers that look up per-repo overrides via
// lookupBaseBranch. Two existing mutexes already cover the trackers
// themselves (repoTrackersMu, workspaceTrackersMu) but each guards a
// different field — without this dedicated lock the writer could
// publish a new map under repoTrackersMu while a reader walked the
// same map under workspaceTrackersMu.
baseBranchesMu sync.RWMutex
// streamSubscribers tracks every workspace-stream subscriber attached
// via SubscribeWorkspaceStream so RescanRepositories can wire new
// per-repo trackers into the same channels without re-subscription. The
// gateway only subscribes once per session; without this list, trackers
// added post-launch (multi-branch transition) would emit events that
// never reach the UI.
streamSubscribers map[types.WorkspaceStreamSubscriber]struct{}
streamSubscribersMu sync.Mutex
// Script/process runner (dev server, setup, cleanup, custom)
processRunner *ProcessRunner
// Embedded shell session (auto-created when agent starts)
shell *shell.Session
// Per-terminal shell sessions for remote executor support
shellMgr *shell.Manager
// Protocol adapter for agent communication
adapter adapter.AgentAdapter
adapterCfg *adapter.Config
// Agent event notifications (protocol-agnostic)
updatesCh chan adapter.AgentEvent
// Pending permission requests waiting for user response
pendingPermissions map[string]*PendingPermission
permissionMu sync.RWMutex
// VS Code server manager (lazy-initialized on demand)
vscode *VscodeManager
vscodeMu sync.Mutex
// Git operator for git operations (lazy-initialized)
gitOperator *GitOperator
gitOperatorMu sync.Mutex
// gitOperatorsBySubpath caches per-subpath operators for multi-repo task
// roots. Key is the cleaned subpath (relative to cfg.WorkDir); empty key
// is reserved for the root operator and lives in gitOperator above.
gitOperatorsBySubpath map[string]*GitOperator
// Final command string (full command with all adapter args)
finalCommand string
// Synchronization
mu sync.RWMutex
wg sync.WaitGroup
stopCh chan struct{}
doneCh chan struct{}
startMu sync.Mutex
admissionMu sync.Mutex
admissionCount int
admissionDrained chan struct{}
stopping bool
lifetimeCtx context.Context
lifetimeCancel context.CancelFunc
mainReapPending atomic.Bool
// stopChClosed guards close(stopCh), which is the only part of teardown
// that is not naturally idempotent. It is reset wherever stopCh itself is
// created so the flag always describes the current channel — a Start that
// fails before reaching that point must not leave the flag describing a
// channel that no longer exists.
stopChClosed atomic.Bool
groupAliveFn func(int) bool
terminateGroupFn func(int) error
killGroupFn func(int) error
waitGroupExitFn func(context.Context, int) bool
managerWaitFn func(context.Context, <-chan struct{}, time.Duration) bool
}
// ErrManagerStopping indicates that process admission is closed for teardown.
var ErrManagerStopping = errors.New("process manager is stopping")
func (m *Manager) admitStart() (func(), error) {
m.admissionMu.Lock()
if m.stopping {
m.admissionMu.Unlock()
return nil, ErrManagerStopping
}
if m.admissionCount == 0 {
m.admissionDrained = make(chan struct{})
}
m.admissionCount++
m.admissionMu.Unlock()
var once sync.Once
return func() {
once.Do(func() {
m.admissionMu.Lock()
m.admissionCount--
if m.admissionCount == 0 {
close(m.admissionDrained)
}
m.admissionMu.Unlock()
})
}, nil
}
// CloseAdmission rejects new process owners without waiting for in-flight
// handlers. Instance teardown calls it before shutting down HTTP.
func (m *Manager) CloseAdmission() {
m.admissionMu.Lock()
m.stopping = true
lifetimeCancel := m.lifetimeCancel
m.admissionMu.Unlock()
if lifetimeCancel != nil {
lifetimeCancel()
}
if m.processRunner != nil {
m.processRunner.BeginStop()
}
if m.shellMgr != nil {
m.shellMgr.BeginStop()
}
}
// BeginOwnedOperation admits instance-scoped work and returns a context that
// is canceled when either the caller ends or instance teardown begins. The
// release function must be called so StopForTeardown can finish draining.
func (m *Manager) BeginOwnedOperation(parent context.Context) (context.Context, func(), error) {
releaseAdmission, err := m.admitStart()
if err != nil {
return nil, nil, err
}
operationCtx, cancel := context.WithCancel(parent)
stopLifetimeCancel := context.AfterFunc(m.lifetimeCtx, cancel)
var once sync.Once
release := func() {
once.Do(func() {
stopLifetimeCancel()
cancel()
releaseAdmission()
})
}
return operationCtx, release, nil
}
// WaitForAdmission waits for starts admitted before CloseAdmission to finish.
func (m *Manager) WaitForAdmission(ctx context.Context) error {
m.admissionMu.Lock()
if m.admissionCount == 0 {
m.admissionMu.Unlock()
return nil
}
drained := m.admissionDrained
m.admissionMu.Unlock()
select {
case <-drained:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// BeginStop closes process admission and drains starts already in flight.
func (m *Manager) BeginStop() {
m.CloseAdmission()
_ = m.WaitForAdmission(context.Background())
}
// NewManager creates a new process manager
func NewManager(cfg *config.InstanceConfig, log *logger.Logger) *Manager {
cfg.WorkDir = resolveExistingWorkDir(cfg.WorkDir, log.WithFields(zap.String("component", "process-manager")))
lifetimeCtx, lifetimeCancel := context.WithCancel(context.Background())
m := &Manager{
cfg: cfg,
logger: log.WithFields(zap.String("component", "process-manager")),
updatesCh: make(chan adapter.AgentEvent, 100),
pendingPermissions: make(map[string]*PendingPermission),
lifetimeCtx: lifetimeCtx,
lifetimeCancel: lifetimeCancel,
workspaceSourceRoots: canonicalWorkspaceSourceRoots(cfg.WorkspaceSourceRoots),
}
// Build the root plus any immediate sibling repositories and recursively
// declared initialized submodules. The root remains a real empty-named
// scope when it is itself a Git repository; a bare task root keeps the
// existing multi-repository behavior.
m.workspaceTracker, m.repoTrackers = m.buildWorkspaceTrackerGraph(
lifetimeCtx,
cfg.WorkDir,
m.workspaceSourceRoots,
false,
false,
)
m.processRunner = NewProcessRunner(m.workspaceTracker, log, cfg.ProcessBufferMaxBytes)
m.shellMgr = shell.NewManager(cfg.WorkDir, log)
m.status.Store(StatusStopped)
m.exitCode.Store(-1)
return m
}
// getBaseBranches returns a snapshot of cfg.BaseBranches under the
// dedicated baseBranchesMu so callers (rescan, lazy-subpath, the
// UpdateBaseBranches re-stamp loop) read a consistent map even when a
// concurrent UpdateBaseBranches writer is publishing a replacement.
func (m *Manager) getBaseBranches() map[string]string {
m.baseBranchesMu.RLock()
defer m.baseBranchesMu.RUnlock()
if m.cfg == nil || m.cfg.BaseBranches == nil {
return nil
}
out := make(map[string]string, len(m.cfg.BaseBranches))
for k, v := range m.cfg.BaseBranches {
out[k] = v
}
return out
}
// setBaseBranches replaces cfg.BaseBranches under baseBranchesMu so the
// write is serialized with every getBaseBranches reader. UpdateBaseBranches
// uses this to publish the new map after sanitizing it at the HTTP edge.
func (m *Manager) setBaseBranches(branches map[string]string) {
m.baseBranchesMu.Lock()
defer m.baseBranchesMu.Unlock()
if m.cfg == nil {
return
}
m.cfg.BaseBranches = branches
}
// SetWorkspaceSourceRoots atomically refreshes the canonical durable-source
// allowlist used by workspace file operations. Roots are set on every active
// tracker because rebind/rescan can swap the root tracker live.
func (m *Manager) SetWorkspaceSourceRoots(roots []string) {
canonical := canonicalWorkspaceSourceRoots(roots)
m.rescanMu.Lock()
defer m.rescanMu.Unlock()
m.repoTrackersMu.RLock()
trackers := append([]*WorkspaceTracker{m.workspaceTracker}, m.repoTrackers...)
m.repoTrackersMu.RUnlock()
m.repoTrackersMu.Lock()
m.workspaceSourceRoots = canonical
m.cfg.WorkspaceSourceRoots = append([]string(nil), canonical...)
m.repoTrackersMu.Unlock()
for _, tracker := range trackers {
if tracker != nil {
tracker.SetAllowedSourceRoots(canonical)
}
}
}
func (m *Manager) currentWorkspaceSourceRoots() []string {
m.repoTrackersMu.RLock()
defer m.repoTrackersMu.RUnlock()
return append([]string(nil), m.workspaceSourceRoots...)
}
// lookupBaseBranch reads the task's recorded base branch for a given
// repository name from the per-instance map. The empty key "" addresses the
// single-repo / root tracker. Falls back to the empty-key entry when the
// per-repo entry is missing — preserves single-repo behavior for tasks
// that record only one base branch under the legacy unkeyed slot.
//
// Each value is re-sanitised through SanitizeGitRef before it leaves the
// function. The map was sanitised at the HTTP boundary and again on
// SetBaseBranch, but static analysis (CodeQL `go/command-injection`)
// loses the sanitised state across map writes and field stores —
// transforming the value at every read point keeps the source→sink path
// covered no matter which entry point the analyser walks.
func lookupBaseBranch(branches map[string]string, repoName string) string {
if len(branches) == 0 {
return ""
}
if v, ok := branches[repoName]; ok && v != "" {
return SanitizeGitRef(v)
}
if repoName != "" {
if v, ok := branches[""]; ok && v != "" {
return SanitizeGitRef(v)
}
}
return ""
}
// repositorySubdir is one git-repo child of a multi-repo task root.
type repositorySubdir struct {
name string // directory basename (used as RepositoryName on emitted events)
path string // absolute path to the repo subdir
}
// scanRepositorySubdirs returns the immediate child directories of workDir
// that are themselves git repositories or worktrees. Returns an empty slice
// when workDir doesn't exist, isn't readable, or contains zero git children.
// Used to detect multi-repo task roots at Manager construction.
func scanRepositorySubdirs(workDir string, allowedSourceRoots []string) []repositorySubdir {
if workDir == "" {
return nil
}
entries, err := os.ReadDir(workDir)
if err != nil {
return nil
}
var out []repositorySubdir
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), ".") {
continue
}
candidate := filepath.Join(workDir, entry.Name())
linkInfo, err := os.Lstat(candidate)
if err != nil {
continue
}
repositoryPath := candidate
if linkInfo.Mode()&os.ModeSymlink != 0 {
resolved, err := filepath.EvalSymlinks(candidate)
if err != nil || !pathWithinSourceRoots(resolved, allowedSourceRoots) {
continue
}
repositoryPath = resolved
}
// Directory links are how a Local task exposes durable folder and
// repository attachments. DirEntry.IsDir is false for a Unix symlink;
// Stat follows only a link already proven under a registered source root.
// codeql[go/path-injection] Candidate is a child of workDir; links pass containment above.
info, err := os.Stat(repositoryPath)
if err != nil || !info.IsDir() {
continue
}
if resolveGitIndexPath(repositoryPath) == "" {
continue
}
out = append(out, repositorySubdir{name: entry.Name(), path: repositoryPath})
}
return out
}
func canonicalWorkspaceSourceRoots(roots []string) []string {
canonical := make([]string, 0, len(roots))
seen := make(map[string]struct{}, len(roots))
for _, root := range roots {
resolved, err := filepath.EvalSymlinks(filepath.Clean(root))
if err != nil {
continue
}
info, err := os.Stat(resolved)
if err != nil || !info.IsDir() {
continue
}
if _, ok := seen[resolved]; ok {
continue
}
seen[resolved] = struct{}{}
canonical = append(canonical, resolved)
}
return canonical
}
func pathWithinSourceRoots(path string, roots []string) bool {
for _, root := range roots {
rel, err := filepath.Rel(root, path)
if err == nil && !pathEscapesRoot(rel) {
return true
}
}
return false
}
// Status returns the current process status
func (m *Manager) Status() Status {
return m.status.Load().(Status)
}
// ExitCode returns the exit code (-1 if not exited)
func (m *Manager) ExitCode() int {
return int(m.exitCode.Load())
}
// ExitError returns the exit error if any
func (m *Manager) ExitError() error {
if v := m.exitErr.Load(); v != nil {
if w, ok := v.(errorWrapper); ok {
return w.err
}
}
return nil
}
// GetWorkspaceTracker returns the workspace tracker for git status and file monitoring
func (m *Manager) GetWorkspaceTracker() *WorkspaceTracker {
m.repoTrackersMu.RLock()
defer m.repoTrackersMu.RUnlock()
return m.workspaceTracker
}
// snapshotTrackers returns the current root + per-repo trackers under
// repoTrackersMu so callers can iterate without holding the lock. Concurrent
// rescan writes can't observably mutate either while a snapshot is in flight.
func (m *Manager) snapshotTrackers() (*WorkspaceTracker, []*WorkspaceTracker) {
m.repoTrackersMu.RLock()
defer m.repoTrackersMu.RUnlock()
repos := make([]*WorkspaceTracker, len(m.repoTrackers))
copy(repos, m.repoTrackers)
return m.workspaceTracker, repos
}
// StartAllWorkspaceTrackers starts root + per-repo trackers (idempotent) so file-change events fire in passthrough mode.
func (m *Manager) StartAllWorkspaceTrackers(ctx context.Context) {
root, trackers := m.snapshotTrackers()
if root != nil {
root.Start(ctx)
}
for _, t := range trackers {
t.Start(ctx)
}
}
// stopWorkspaceTrackers stops root + per-repo trackers (idempotent via sync.Once).
func (m *Manager) stopWorkspaceTrackers() {
root, trackers := m.snapshotTrackers()
if root != nil {
root.Stop()
}
for _, t := range trackers {
t.Stop()
}
}
// SubscribeWorkspaceStream creates a single workspace stream subscriber and
// fans it out across the root tracker plus every per-repo tracker, so the
// caller receives events from all repositories on one channel. Use
// UnsubscribeWorkspaceStream to detach and close.
//
// For single-repo workspaces this is equivalent to
// GetWorkspaceTracker().SubscribeWorkspaceStream() — the per-repo list is
// empty and only the root tracker fires events.
func (m *Manager) SubscribeWorkspaceStream() types.WorkspaceStreamSubscriber {
sub := make(types.WorkspaceStreamSubscriber, 100)
m.streamSubscribersMu.Lock()
if m.streamSubscribers == nil {
m.streamSubscribers = make(map[types.WorkspaceStreamSubscriber]struct{})
}
m.streamSubscribers[sub] = struct{}{}
m.streamSubscribersMu.Unlock()
root, trackers := m.snapshotTrackers()
root.AttachWorkspaceStreamSubscriber(sub)
for _, t := range trackers {
t.AttachWorkspaceStreamSubscriber(sub)
}
return sub
}
// UnsubscribeWorkspaceStream detaches the subscriber from every tracker and
// closes the channel exactly once.
func (m *Manager) UnsubscribeWorkspaceStream(sub types.WorkspaceStreamSubscriber) {
m.streamSubscribersMu.Lock()
delete(m.streamSubscribers, sub)
m.streamSubscribersMu.Unlock()
root, trackers := m.snapshotTrackers()
root.DetachWorkspaceStreamSubscriber(sub)
for _, t := range trackers {
t.DetachWorkspaceStreamSubscriber(sub)
}
close(sub)
}
// GetWorkspaceTrackerFor returns a workspace tracker scoped to a sub-directory
// of the workspace. Used by multi-repo task roots where each repository lives
// at {WorkDir}/{subpath}. Empty subpath returns the root tracker.
//
// Per-subpath trackers are created lazily on first request and cached. They
// are NOT started (no polling goroutines) — the multi-repo path uses them
// only for synchronous git-status queries via GetCurrentGitStatus, which
// falls through to getGitStatus when no cache is present. This keeps the
// per-repo cost cheap while preserving the long-running polling behavior of
// the root tracker for the agent's primary workspace.
func (m *Manager) GetWorkspaceTrackerFor(subpath string) (*WorkspaceTracker, error) {
cleaned, full, err := m.resolveSubpath(subpath)
if err != nil {
return nil, err
}
if cleaned == "" {
return m.workspaceTracker, nil
}
if tracker := m.findRepositoryTracker(cleaned); tracker != nil {
return tracker, nil
}
m.workspaceTrackersMu.Lock()
defer m.workspaceTrackersMu.Unlock()
if m.workspaceTrackersBySubpath == nil {
m.workspaceTrackersBySubpath = make(map[string]*WorkspaceTracker)
}
if t, ok := m.workspaceTrackersBySubpath[cleaned]; ok {
return t, nil
}
t := NewWorkspaceTracker(full, m.logger)
t.SetBaseBranch(lookupBaseBranch(m.getBaseBranches(), cleaned))
m.workspaceTrackersBySubpath[cleaned] = t
return t, nil
}
// UpdateBaseBranches replaces the per-repo base-branch map and re-stamps every
// active tracker with the new value for its repositoryName, then triggers a
// non-blocking RefreshGitStatus on each so the UI sees the new
// BaseCommit/Ahead/Behind without waiting for the next poll tick. Idempotent
// for unchanged values (RefreshGitStatus is cheap — it's the same call the
// frontend already makes after stage/unstage).
//
// Newly-spawned trackers (rescan path, lazy subpath lookup) read the updated
// map via lookupBaseBranch — no second push needed for them.
func (m *Manager) UpdateBaseBranches(ctx context.Context, branches map[string]string) {
m.setBaseBranches(branches)
m.repoTrackersMu.RLock()
root := m.workspaceTracker
trackers := make([]*WorkspaceTracker, len(m.repoTrackers))
copy(trackers, m.repoTrackers)
m.repoTrackersMu.RUnlock()
m.workspaceTrackersMu.Lock()
bySubpath := make(map[string]*WorkspaceTracker, len(m.workspaceTrackersBySubpath))
for k, v := range m.workspaceTrackersBySubpath {
bySubpath[k] = v
}
m.workspaceTrackersMu.Unlock()
// Stamp the new baseBranch on each tracker synchronously so the field is
// visible to the next poll, but kick the RefreshGitStatus probes
// (which can each spawn 3–5 git subprocesses) onto a background
// goroutine. The HTTP handler that called us doesn't need to block on
// per-tracker git work; the picker UI re-fetches via the existing WS
// stream once Refresh emits a new GitStatusUpdate. Detach from the
// caller's ctx so an HTTP request cancel after the field stores
// can't strand half the trackers without their refresh.
if root != nil {
root.SetBaseBranchIfNotSubmodule(lookupBaseBranch(branches, root.RepositoryName()))
}
for _, t := range trackers {
t.SetBaseBranchIfNotSubmodule(lookupBaseBranch(branches, t.RepositoryName()))
}
for subpath, t := range bySubpath {
t.SetBaseBranchIfNotSubmodule(lookupBaseBranch(branches, subpath))
}
go m.refreshTrackersDetached(root, trackers, bySubpath)
}
// refreshTrackersDetached runs RefreshGitStatus on every supplied tracker
// using a background context. Spawned as a goroutine by UpdateBaseBranches
// so the per-tracker git subprocesses don't block the HTTP handler that
// drove the picker-save.
func (m *Manager) refreshTrackersDetached(root *WorkspaceTracker, trackers []*WorkspaceTracker, bySubpath map[string]*WorkspaceTracker) {
ctx := context.Background()
if root != nil {
root.RefreshGitStatus(ctx)
}
for _, t := range trackers {
t.RefreshGitStatus(ctx)
}
for _, t := range bySubpath {
t.RefreshGitStatus(ctx)
}
}
// StartProcess runs a script/process with isolated stdout/stderr.
func (m *Manager) StartProcess(ctx context.Context, req StartProcessRequest) (*ProcessInfo, error) {
release, err := m.admitStart()
if err != nil {
return nil, err
}
defer release()
if m.processRunner == nil {
return nil, fmt.Errorf("process runner not available")
}
effectiveReq, err := m.buildProcessRequest(req)
if err != nil {
return nil, fmt.Errorf("prepare process environment: %w", err)
}
return m.processRunner.Start(ctx, effectiveReq)
}
// StartPipedProcess starts a directly executed process whose stdio is bridged
// by agentctl while preserving the manager's admission and teardown guarantees.
func (m *Manager) StartPipedProcess(req PipedStartRequest) (*PipedProcess, error) {
release, err := m.admitStart()
if err != nil {
return nil, err
}
defer release()
if m.processRunner == nil {
return nil, fmt.Errorf("process runner not available")
}
effectiveReq, err := m.buildPipedProcessRequest(req)
if err != nil {
return nil, fmt.Errorf("prepare process environment: %w", err)
}
return m.processRunner.StartPiped(effectiveReq)
}
// StopProcess stops a running process by ID.
func (m *Manager) StopProcess(ctx context.Context, req StopProcessRequest) error {
if m.processRunner == nil {
return fmt.Errorf("process runner not available")
}
return m.processRunner.Stop(ctx, req)
}
// GetProcess returns a process by ID.
func (m *Manager) GetProcess(id string, includeOutput bool) (*ProcessInfo, bool) {
if m.processRunner == nil {
return nil, false
}
return m.processRunner.Get(id, includeOutput)
}
// ListProcesses returns processes for a session (or all if sessionID empty).
func (m *Manager) ListProcesses(sessionID string) []ProcessInfo {
if m.processRunner == nil {
return nil
}
return m.processRunner.List(sessionID)
}
// RepoSubpaths returns the subpath name (relative to cfg.WorkDir) for every
// per-repo tracker discovered at construction time. Empty for single-repo
// workspaces. Used by callers that want to fan an op out across repos.
func (m *Manager) RepoSubpaths() []string {
_, trackers := m.snapshotTrackers()
out := make([]string, 0, len(trackers))
for _, t := range trackers {
if t.repositoryName != "" {
out = append(out, t.repositoryName)
}
}
sort.Strings(out)
return out
}
// RepositoryScopes returns the stable scope order used by unpinned Git API
// fan-out. The empty scope is included only when the workspace root tracker
// owns a real Git repository; a bare multi-repository task root therefore
// retains its legacy named-only response.
func (m *Manager) RepositoryScopes() []string {
root, trackers := m.snapshotTrackers()
scopes := make([]string, 0, len(trackers)+1)
if root != nil && root.gitIndexPath != "" {
scopes = append(scopes, "")
}
for _, tracker := range trackers {
if tracker != nil && tracker.repositoryName != "" {
scopes = append(scopes, tracker.repositoryName)
}
}
if root != nil && root.gitIndexPath != "" {
sort.Strings(scopes[1:])
} else {
sort.Strings(scopes)
}
return scopes
}
func (m *Manager) findRepositoryTracker(repositoryName string) *WorkspaceTracker {
_, trackers := m.snapshotTrackers()
for _, tracker := range trackers {
if tracker != nil && tracker.repositoryName == repositoryName {
return tracker
}
}
return nil
}
// newTrackerForRepo builds a per-repo tracker that inherits the workspace's
// current poll mode. Used for every tracker created after launch — rescan and
// lazy subpath creation — because the gateway only pushes on a session mode
// transition: a repository added to a workspace that is already focused would
// otherwise sit at its construction default and be demoted by the grace timer
// 60s later, while the user is looking at it.
func (m *Manager) newTrackerForRepo(path, repositoryName string) *WorkspaceTracker {
return NewWorkspaceTrackerForRepo(path, repositoryName, m.logger)
}
// applyWorkspacePollModeLocked gives newly built trackers the last mode the
// gateway pushed, if it ever pushed one. SetPollMode also disarms a tracker's
// grace demotion, which is correct: the gateway has spoken for this workspace,
// so the fallback must not override it. With no prior push the trackers keep
// their construction default and the grace timer stays in charge.
//
// Callers must hold repoTrackersMu for writing, and must call this in the same
// critical section that publishes the trackers. Reading the mode and
// registering the tracker have to be one step: otherwise a SetWorkspacePollMode
// running in between stores a new mode and fans it out to a tracker set that
// does not include this one yet, so the tracker adopts the stale mode — and
// because SetPollMode marks it as pushed and disarms the grace timer, nothing
// ever corrects it. It would poll at the wrong cadence for the life of the
// process, which is the exact failure the grace timer exists to prevent.
func (m *Manager) applyWorkspacePollModeLocked(trackers ...*WorkspaceTracker) {
if !m.workspacePollModeSet {
return
}
for _, t := range trackers {
if t != nil {
t.SetPollMode(m.workspacePollMode)
}
}
}
// SetWorkspacePollMode propagates a poll-mode change to the root tracker and
// every per-repo tracker, then forces a RefreshGitStatus on each non-paused
// tracker so a fresh snapshot reaches every subscriber. Without the refresh,
// monitorTick only pushes on detected change — multi-repo workspaces would
// leave the per-repo state map sparse (one repo present, the other missing)
// after a focus event, since the agent's initial pushes happen at boot and
// no replay path exists for clients that subscribe later.
func (m *Manager) SetWorkspacePollMode(ctx context.Context, mode PollMode) {
// Reject before storing, not just before applying. Every tracker already
// ignores an invalid mode, so an invalid push is harmless to the trackers
// that exist — but recording it would overwrite the last valid one, and
// trackers created afterwards inherit what is stored. A single malformed
// push would leave every later tracker on its construction default, and
// applyWorkspacePollModeLocked could not tell that apart from a workspace
// the gateway has genuinely never spoken for.
if !mode.IsValid() {
m.logger.Warn("ignoring invalid workspace poll mode", zap.String("mode", string(mode)))
return
}
// Storing the mode and fanning it out happen under one write lock, the same
// one the rescan paths hold while they publish new trackers. That ordering
// is what makes the two safe against each other: a tracker is either already
// registered and gets this push, or it is registered afterwards and reads
// this mode when it does. Anything in between would leave it on a stale mode
// with its grace timer disarmed — see applyWorkspacePollModeLocked.
//
// SetPollMode is a mutex update plus a non-blocking wake-up, so holding the
// lock across the fan-out costs nothing. RefreshGitStatus is the slow part
// and stays outside, in the goroutine below.
m.repoTrackersMu.Lock()
m.workspacePollMode = mode
m.workspacePollModeSet = true
root := m.workspaceTracker
trackers := make([]*WorkspaceTracker, len(m.repoTrackers))
copy(trackers, m.repoTrackers)
if root != nil {
root.SetPollMode(mode)
}
for _, t := range trackers {
t.SetPollMode(mode)
}
m.repoTrackersMu.Unlock()
if mode == PollModePaused {
return
}
// Refresh in background — RefreshGitStatus blocks on git commands which
// can take seconds on large repos; the HTTP caller shouldn't wait.
go func() {
if root != nil {
root.RefreshGitStatus(ctx)
}
for _, t := range trackers {
t.RefreshGitStatus(ctx)
}
}()
}
// GitOperator returns the git operator for git operations against the
// workspace root. Lazy-initialized.
func (m *Manager) GitOperator() *GitOperator {
m.gitOperatorMu.Lock()
defer m.gitOperatorMu.Unlock()
if m.gitOperator == nil {
m.gitOperator = NewGitOperator(m.cfg.WorkDir, m.logger, m.workspaceTracker)
m.gitOperator.setEnvironmentProvider(m.gitEnvironment)
if binding, ok := m.cfg.RemoteContributions[""]; ok {
m.gitOperator.setRemoteContribution(&binding)
}
if destination, ok := m.cfg.ContributionDestinations[""]; ok {
m.gitOperator.setContributionDestination(&destination)
}
}
return m.gitOperator
}
// GitOperatorFor returns a git operator scoped to a sub-directory of the
// workspace. Used by multi-repo task roots where each repository lives at
// {WorkDir}/{subpath}. Empty subpath returns the root operator.
//
// The subpath is validated to prevent path-traversal: it must be a clean
// relative path with no parent-references and must resolve to an existing
// directory inside cfg.WorkDir.
func (m *Manager) GitOperatorFor(subpath string) (*GitOperator, error) {
cleaned, full, err := m.resolveSubpath(subpath)
if err != nil {
return nil, err
}
if cleaned == "" {
return m.GitOperator(), nil
}
m.gitOperatorMu.Lock()
defer m.gitOperatorMu.Unlock()
if m.gitOperatorsBySubpath == nil {
m.gitOperatorsBySubpath = make(map[string]*GitOperator)
}
if op, ok := m.gitOperatorsBySubpath[cleaned]; ok {
return op, nil
}
tracker := m.findRepositoryTracker(cleaned)
if tracker == nil {
tracker = m.GetWorkspaceTracker()
}
op := NewGitOperatorForRepo(full, cleaned, m.logger, tracker)
op.setEnvironmentProvider(m.gitEnvironment)
if binding, ok := m.cfg.RemoteContributions[cleaned]; ok {
op.setRemoteContribution(&binding)
}
if destination, ok := m.cfg.ContributionDestinations[cleaned]; ok {
op.setContributionDestination(&destination)
}
m.gitOperatorsBySubpath[cleaned] = op
return op, nil
}
func (m *Manager) gitEnvironment() []string {
m.startMu.Lock()
defer m.startMu.Unlock()
return append([]string(nil), m.cfg.AgentEnv...)
}
// resolveSubpath normalises and validates a repo subpath relative to
// cfg.WorkDir. Returns ("", "", nil) for the root (empty/"."); otherwise
// returns the cleaned relative path and the absolute full path.
//
// Rejects: parent-references, absolute paths, paths containing "..",
// and paths that don't resolve to an existing directory.
func (m *Manager) resolveSubpath(subpath string) (string, string, error) {
subpath = strings.TrimSpace(subpath)
if subpath == "" || subpath == "." {
return "", m.cfg.WorkDir, nil
}
cleaned := filepath.Clean(subpath)
if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "..") {
return "", "", fmt.Errorf("invalid repo subpath: %q", subpath)
}
if filepath.IsAbs(cleaned) {
return "", "", fmt.Errorf("repo subpath must be relative: %q", subpath)
}
for _, part := range strings.Split(cleaned, string(filepath.Separator)) {
if part == ".." {
return "", "", fmt.Errorf("repo subpath escapes workspace: %q", subpath)
}
}
// Repository scope names are also used as API keys and Git paths. Keep
// those keys slash-separated on every platform; filepath.Clean uses the
// native separator, which made Windows lookups miss the trackers built
// from Git's slash-separated gitlink paths and create an unanchored lazy
// tracker instead.
scope := filepath.ToSlash(cleaned)
full := filepath.Join(m.cfg.WorkDir, filepath.FromSlash(scope))
info, err := os.Stat(full)
if err != nil {
return "", "", fmt.Errorf("repo subpath not found: %w", err)
}
if !info.IsDir() {
return "", "", fmt.Errorf("repo subpath is not a directory: %q", subpath)
}
return scope, full, nil
}
// WorkDir returns the absolute workspace root for this agentctl instance.
// Handlers that bypass WorkspaceTracker (e.g. the batched copy-files endpoint
// that writes into a per-repo subdir directly) need the resolved root.
func (m *Manager) WorkDir() string {
return m.cfg.WorkDir
}
// ResolveRepoSubdir returns the absolute filesystem directory for an
// optional repo subpath under the workspace. Same validation as the
// internal resolveSubpath helper (rejects traversal, requires existence).
// Empty subpath returns the workspace root.
func (m *Manager) ResolveRepoSubdir(subpath string) (string, error) {
_, full, err := m.resolveSubpath(subpath)
return full, err
}
// JoinRepoPath validates a repo subpath and returns the workspace-relative
// path obtained by joining `subpath` and `path`. Empty `subpath` returns
// `path` unchanged (single-repo workspaces). Used by file content / update
// handlers to scope a per-repo path under the right repository directory
// before delegating to the workspace tracker.
func (m *Manager) JoinRepoPath(subpath, path string) (string, error) {
cleaned, _, err := m.resolveSubpath(subpath)
if err != nil {
return "", err
}
if cleaned == "" {
return path, nil
}
return filepath.Join(cleaned, path), nil
}
// Start starts the agent process
func (m *Manager) Start(ctx context.Context) error {
m.startMu.Lock()
defer m.startMu.Unlock()
release, err := m.admitStart()
if err != nil {
return err
}
defer release()
if m.Status() == StatusRunning || m.Status() == StatusStarting {
return fmt.Errorf("agent is already running")
}
// A previous lifecycle may still be live: an agent that exited on its own
// never ran teardown, and Start is reachable without an intervening Stop.
m.finishPreviousLifecycle(ctx)
m.logger.Info("starting agent process",
zap.String("protocol", string(m.cfg.Protocol)),
zap.Strings("args", m.cfg.AgentArgs),
zap.String("workdir", m.cfg.WorkDir),
zap.Int("mcp_servers", len(m.cfg.McpServers)))
m.status.Store(StatusStarting)
m.exitCode.Store(-1)
m.exitErr.Store(errorWrapper{err: nil})
if err := config.ValidateCommandArgs(m.cfg.AgentArgs); err != nil {
m.status.Store(StatusError)
return err
}
// Build adapter config and create protocol adapter
if err := m.buildAdapterConfig(); err != nil {
m.status.Store(StatusError)
return err
}
// One-shot adapters manage their own subprocess per prompt.
// Skip process creation — the adapter spawns processes in Prompt().
if oneShotAdapter, ok := m.adapter.(adapter.OneShotAdapter); ok && oneShotAdapter.IsOneShot() {
return m.startOneShot()
}
// Assemble final command (does not start the process yet)
if err := m.buildFinalCommand(); err != nil {
m.status.Store(StatusError)
return err
}
// Set up stdin/stdout/stderr pipes (must happen before process starts)
if err := m.startProcessPipes(); err != nil {
m.status.Store(StatusError)
return err
}
// Start the subprocess now that pipes are connected
if err := m.cmd.Start(); err != nil {
m.status.Store(StatusError)
return formatAgentStartError(err, m.cfg.AgentEnv)
}
processLifecycle, err := installProcessLifecycle(m.cmd)
if err != nil {
reapErr := killAndWaitStartedCommand(m.cmd)
m.status.Store(StatusError)
return errors.Join(fmt.Errorf("failed to install agent process lifecycle: %w", err), reapErr)
}
m.processLifecycle = processLifecycle
m.stopCh = make(chan struct{})
m.doneCh = make(chan struct{})
m.stopChClosed.Store(false)
// Connect adapter to the process stdin/stdout pipes
if err := m.adapter.Connect(m.stdin, m.stdout); err != nil {
reapErr, owned := m.reapMainProcessLifecycle()
switch {
case !owned:
reapErr = killAndWaitStartedCommand(m.cmd)
case reapErr != nil:
fallbackErr := killAndWaitStartedCommand(m.cmd)
reapErr = errors.Join(reapErr, fallbackErr)
default:
reapErr = killAndWaitStartedCommand(m.cmd)
}
m.status.Store(StatusError)
return errors.Join(fmt.Errorf("failed to connect adapter: %w", err), reapErr)
}
// Start stderr reader and exit waiter
m.wg.Add(2)
go m.readStderr()
go m.waitForExit()
// Forward adapter updates to our channel
m.wg.Add(1)
go m.forwardUpdates(m.adapter, m.stopCh)
// Start workspace tracker with background context (not tied to HTTP request)
root, trackers := m.snapshotTrackers()
root.Start(context.Background())
for _, t := range trackers {
t.Start(context.Background())
}
// Auto-create shell session if enabled
m.startAgentShell()
m.status.Store(StatusRunning)
m.logger.Info("agent process started", zap.Int("pid", m.cmd.Process.Pid))
return nil
}
// startOneShot initialises a one-shot adapter without spawning a long-lived subprocess.
// The adapter manages its own per-prompt subprocess lifecycle internally.
func (m *Manager) startOneShot() error {
if m.adapterCfg != nil && m.adapterCfg.OneShotConfig != nil {
m.adapterCfg.OneShotConfig.Env = m.cfg.AgentEnv
}
m.stopCh = make(chan struct{})
m.doneCh = make(chan struct{})
m.stopChClosed.Store(false)
// Forward adapter updates to our channel
m.wg.Add(1)
go m.forwardUpdates(m.adapter, m.stopCh)
// Start workspace tracker with background context (not tied to HTTP request)
root, trackers := m.snapshotTrackers()
root.Start(context.Background())
for _, t := range trackers {
t.Start(context.Background())
}
// Auto-create shell session if enabled
m.startAgentShell()
m.status.Store(StatusRunning)
m.logger.Info("one-shot adapter started (no persistent subprocess)")
return nil
}
// buildAdapterConfig constructs the adapter configuration and initialises the
// protocol adapter, including merging any adapter-provided environment variables.
func (m *Manager) buildAdapterConfig() error {
mcpServers := make([]adapter.McpServerConfig, len(m.cfg.McpServers))
for i, mcp := range m.cfg.McpServers {
mcpServers[i] = adapter.McpServerConfig{
Name: mcp.Name,
URL: mcp.URL,
Type: mcp.Type,
Command: mcp.Command,
Args: mcp.Args,
Env: mcp.Env,
Headers: mcp.Headers,
}
}
m.adapterCfg = &adapter.Config{
WorkDir: m.cfg.WorkDir,
AutoApprove: m.cfg.AutoApprovePermissions,
ApprovalPolicy: m.cfg.ApprovalPolicy,
McpServers: mcpServers,
AgentID: m.cfg.AgentType, // From registry (e.g., "auggie", "amp", "claude-code")
AssumeMcpSse: m.cfg.AssumeMcpSse,
AssumeMcpHttp: m.cfg.AssumeMcpHttp,
RequiresProcessKill: m.cfg.RequiresProcessKill,
NotificationQueueCapacity: m.cfg.NotificationQueueCapacity,
}
// Configure one-shot mode when a continue command is provided.
// One-shot adapters (e.g., Amp) spawn a new subprocess per prompt.
continueArgs := m.cfg.ContinueArgs
if continueArgs == nil && m.cfg.ContinueCommand != "" {
continueArgs = config.ParseCommand(m.cfg.ContinueCommand)
}
if len(continueArgs) > 0 {
m.adapterCfg.OneShotConfig = &adapter.OneShotConfig{
InitialArgs: m.cfg.AgentArgs,
ContinueArgs: continueArgs,
Env: m.cfg.AgentEnv,
WorkDir: m.cfg.WorkDir,
}
}
for i, srv := range mcpServers {
m.logger.Debug("MCP server config",
zap.Int("index", i),
zap.String("name", srv.Name),
zap.String("url", srv.URL),
zap.String("type", srv.Type),
zap.String("command", srv.Command))
}
if err := m.createAdapter(); err != nil {
return fmt.Errorf("failed to create adapter: %w", err)
}
// Merge adapter-provided environment variables into the subprocess environment
adapterEnv, err := m.adapter.PrepareEnvironment()
if err != nil {
m.logger.Warn("failed to prepare protocol environment", zap.Error(err))
}
for k, v := range adapterEnv {
m.cfg.AgentEnv = append(m.cfg.AgentEnv, fmt.Sprintf("%s=%s", k, v))
}
// Strip agent-declared environment variables from the final child process
// environment entirely (not just set to empty). Applied after adapter env
// merge so that adapter-injected values are also stripped. Some programs
// distinguish unset from empty string — see RuntimeConfig.StripEnv docs.
for _, key := range m.cfg.StripEnv {
m.cfg.AgentEnv = utility.RemoveEnvEntry(m.cfg.AgentEnv, key)
}
if m.adapterCfg.OneShotConfig != nil {
m.adapterCfg.OneShotConfig.Env = m.cfg.AgentEnv
}
return nil
}
// buildFinalCommand assembles the full command args and creates the exec.Cmd.
// The process group is set so child processes can be killed together.
func (m *Manager) buildFinalCommand() error {
extraArgs := m.adapter.PrepareCommandArgs()
cmdArgs := make([]string, 0, len(m.cfg.AgentArgs)-1+len(extraArgs))
cmdArgs = append(cmdArgs, m.cfg.AgentArgs[1:]...)
cmdArgs = append(cmdArgs, extraArgs...)
m.finalCommand = strings.Join(append([]string{m.cfg.AgentArgs[0]}, cmdArgs...), " ")
m.logger.Debug("final agent command",
zap.String("binary", m.cfg.AgentArgs[0]),
zap.Strings("args", cmdArgs),
zap.Int("extra_args_count", len(extraArgs)))
// NOTE: We intentionally don't use exec.CommandContext here because we don't want
// the HTTP request context to kill the agent process when the request completes.
m.cmd = exec.Command(m.cfg.AgentArgs[0], cmdArgs...)
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).
setAgentProcGroup(m.cmd)
envBytes, largestEnv := summarizeEnvBytes(m.cfg.AgentEnv, 3)
m.logger.Info("agent command prepared",
zap.Strings("args", m.cfg.AgentArgs),
zap.Strings("extra_args", extraArgs),
zap.String("workdir", m.cfg.WorkDir),
zap.Int("env_count", len(m.cfg.AgentEnv)),
zap.Int("env_bytes", envBytes),
zap.String("largest_env_keys", formatEnvEntrySizes(largestEnv)))
return nil
}
type envEntrySize struct {
key string
bytes int
}
func formatAgentStartError(err error, env []string) error {
if isArgumentListTooLong(err) {
total, largest := summarizeEnvBytes(env, 3)
return fmt.Errorf(
"failed to start agent: environment/arguments too large; env_bytes=%d largest_env_keys=%s: %w",
total, formatEnvEntrySizes(largest), err,
)
}
return fmt.Errorf("failed to start agent: %w", err)
}
func isArgumentListTooLong(err error) bool {
if errors.Is(err, syscall.E2BIG) {
return true
}
return strings.Contains(strings.ToLower(err.Error()), "argument list too long")
}
func summarizeEnvBytes(env []string, limit int) (int, []envEntrySize) {
entries := make([]envEntrySize, 0, len(env))
total := 0
for _, item := range env {
size := len(item) + 1
total += size
key := item
if idx := strings.IndexByte(item, '='); idx >= 0 {
key = item[:idx]
}
entries = append(entries, envEntrySize{key: key, bytes: size})
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].bytes == entries[j].bytes {
return entries[i].key < entries[j].key
}
return entries[i].bytes > entries[j].bytes
})
if limit > 0 && len(entries) > limit {
entries = entries[:limit]
}
return total, entries
}
func formatEnvEntrySizes(entries []envEntrySize) string {
if len(entries) == 0 {
return "[]"
}
parts := make([]string, 0, len(entries))
for _, entry := range entries {
parts = append(parts, fmt.Sprintf("%s:%d", entry.key, entry.bytes))
}
return "[" + strings.Join(parts, ",") + "]"
}
func upsertEnvValue(env []string, key, value string) []string {
prefix := key + "="
next := make([]string, 0, len(env)+1)
replaced := false
for _, item := range env {
if strings.HasPrefix(item, prefix) {
if !replaced {
next = append(next, prefix+value)
replaced = true
}
continue
}
next = append(next, item)
}
if !replaced {
next = append(next, prefix+value)
}
return next
}
// startProcessPipes creates stdin, stdout, and stderr pipes for the agent subprocess.
// The pipes must be created before the process starts.
func (m *Manager) startProcessPipes() error {
var err error
m.stdin, err = m.cmd.StdinPipe()
if err != nil {
return fmt.Errorf("failed to create stdin pipe: %w", err)
}
m.stdout, err = m.cmd.StdoutPipe()
if err != nil {
_ = m.stdin.Close()
return fmt.Errorf("failed to create stdout pipe: %w", err)
}
m.stderr, err = m.cmd.StderrPipe()
if err != nil {
_ = m.stdin.Close()
_ = m.stdout.Close()
return fmt.Errorf("failed to create stderr pipe: %w", err)
}
return nil
}
// startAgentShell auto-creates a shell session when ShellEnabled is configured.
// Failure is non-fatal: the agent can still work without a shell session.
func (m *Manager) startAgentShell() {
if !m.cfg.ShellEnabled {
return
}
// Start calls this while holding startMu. Copy AgentEnv directly instead of
// calling agentEnvSnapshot, which would attempt to acquire the same mutex.
shellCfg, err := m.buildShellConfigWithAgentEnv(append([]string(nil), m.cfg.AgentEnv...))
if err != nil {
m.logger.Warn("failed to prepare shell environment", zap.Error(err))
return
}
shellSession, err := shell.NewSession(shellCfg, m.logger)
if err != nil {
m.logger.Warn("failed to create shell session", zap.Error(err))
return
}
m.shell = shellSession
m.logger.Info("shell session auto-created")
}
// buildShellConfig creates the embedded shell configuration with the same
// effective instance environment used by agent and terminal processes.
func (m *Manager) buildShellConfig() (shell.Config, error) {
return m.buildShellConfigWithAgentEnv(m.agentEnvSnapshot())
}
func (m *Manager) buildShellConfigWithAgentEnv(agentEnv []string) (shell.Config, error) {
shellCfg := shell.DefaultConfig(m.cfg.WorkDir)
shellCfg.ShellCommand = preferredShellCommand(agentEnv)
var err error
shellCfg.Env, err = mergeAgentEnvIntoShellConfigWithError(agentEnv, nil)
if err != nil {
return shell.Config{}, err
}
return shellCfg, nil
}
// buildProcessRequest applies the instance environment to a task-scoped
// process request. Explicit request values remain authoritative.
func (m *Manager) buildProcessRequest(req StartProcessRequest) (StartProcessRequest, error) {
var err error
req.Env, err = mergeAgentEnvIntoShellConfigWithError(m.agentEnvSnapshot(), req.Env)
return req, err
}
func (m *Manager) buildPipedProcessRequest(req PipedStartRequest) (PipedStartRequest, error) {
var err error
req.Env, err = mergeAgentEnvIntoShellConfigWithError(m.agentEnvSnapshot(), req.Env)
return req, err
}
func preferredShellCommand(env []string) string {
if value := lookupEnvValue(env, "AGENTCTL_SHELL_COMMAND"); value != "" {
return value
}
return lookupEnvValue(env, "SHELL")
}
func lookupEnvValue(env []string, key string) string {
prefix := key + "="
for _, entry := range env {
if strings.HasPrefix(entry, prefix) {
return strings.TrimPrefix(entry, prefix)
}
}
return ""
}
// Configure sets the agent command and optional environment variables.
// This must be called before Start() if the instance was created without a command.
// continueCommand is optional — when set, the adapter uses it for one-shot follow-up prompts.
func (m *Manager) Configure(command string, agentArgs []string, agentArgsPresent bool, env map[string]string, approvalPolicy, continueCommand string, continueArgs []string, continueArgsPresent bool) error {
m.startMu.Lock()
defer m.startMu.Unlock()
if m.Status() == StatusRunning || m.Status() == StatusStarting {
return fmt.Errorf("cannot configure while agent is running")
}
args := agentArgs
if !agentArgsPresent {
args = config.ParseCommand(command)
}
if err := config.ValidateCommandArgs(args); err != nil {
return err
}
if continueArgsPresent {
if err := config.ValidateCommandArgs(continueArgs); err != nil {
return fmt.Errorf("invalid continue command: %w", err)
}
} else if continueCommand != "" {
continueArgs = config.ParseCommand(continueCommand)
if err := config.ValidateCommandArgs(continueArgs); err != nil {
return fmt.Errorf("invalid continue command: %w", err)
}
}
m.cfg.AgentCommand = command
m.cfg.AgentArgs = args
// Set approval policy if provided (for Codex)
if approvalPolicy != "" {
m.cfg.ApprovalPolicy = approvalPolicy
}
// Store continue command for one-shot adapters (e.g., Amp)
if continueArgsPresent {
m.cfg.ContinueCommand = continueCommand
m.cfg.ContinueArgs = continueArgs
} else if continueCommand != "" {
m.cfg.ContinueCommand = continueCommand
m.cfg.ContinueArgs = continueArgs
}
// Merge additional env vars
if len(env) > 0 {
for k, v := range env {
m.cfg.AgentEnv = append(m.cfg.AgentEnv, fmt.Sprintf("%s=%s", k, v))
}
}
m.logger.Info("agent configured",
zap.String("command", command),
zap.Strings("args", args),
zap.String("approval_policy", m.cfg.ApprovalPolicy),
zap.String("continue_command", continueCommand),
zap.Int("env_count", len(env)))
return nil
}
// createAdapter creates the appropriate protocol adapter based on configuration.
// This should be called before starting the process so PrepareEnvironment can run.
func (m *Manager) createAdapter() error {
protocol := m.cfg.Protocol
if protocol == "" {
return fmt.Errorf("protocol not specified in configuration")
}
m.logger.Debug("creating adapter", zap.String("protocol", string(protocol)))
adpt, err := adapter.NewAdapter(protocol, m.adapterCfg, m.logger)
if err != nil {
return fmt.Errorf("failed to create adapter: %w", err)
}
m.adapter = adpt
// Set stderr provider for adapters that support it (Codex, StreamJSON)
if setter, ok := m.adapter.(adapter.StderrProviderSetter); ok {
setter.SetStderrProvider(m)
}
if consumer, ok := m.adapter.(adapter.StderrLineConsumer); ok {
m.stderrConsumer = consumer
} else {
m.stderrConsumer = nil
}
if sanitizer, ok := m.adapter.(adapter.StderrLineSanitizer); ok {
m.stderrSanitizer = sanitizer
} else {
m.stderrSanitizer = nil
}
// Set the permission handler
m.adapter.SetPermissionHandler(m.handlePermissionRequest)
return nil
}
// forwardUpdates forwards updates from the adapter to the manager's channel
func (m *Manager) forwardUpdates(agentAdapter adapter.AgentAdapter, stopCh <-chan struct{}) {
defer m.wg.Done()
updatesCh := agentAdapter.Updates()
for {
select {
case update, ok := <-updatesCh:
if !ok {
return
}
select {
case m.updatesCh <- update:
case <-stopCh:
return
}
case <-stopCh:
return
}
}
}
// GetUpdates returns the channel for agent event notifications
func (m *Manager) GetUpdates() <-chan adapter.AgentEvent {
return m.updatesCh
}
// SendErrorEvent sends an error event on the updates channel so the
// lifecycle manager (and ultimately the UI) learns about the failure.
func (m *Manager) SendErrorEvent(errorMessage string, promptGeneration uint64) {
m.SendErrorEventWithProviderError(errorMessage, promptGeneration, nil)
}
// SendErrorEventWithProviderError sends an error event with optional safe
// provider details. The details are already normalized by the adapter and are
// never populated from the manager's raw stderr ring.
func (m *Manager) SendErrorEventWithProviderError(
errorMessage string,
promptGeneration uint64,
providerError *streams.ProviderError,
) {
select {
case m.updatesCh <- adapter.AgentEvent{
Type: adapter.EventTypeError,
Error: errorMessage,
PromptGeneration: promptGeneration,
ProviderError: providerError,
}:
default:
m.logger.Warn("updates channel full, could not send error event")
}
}
// PublishMCPAttachment forwards safe MCP attachment evidence through the
// existing agent update stream. Diagnostics are best effort: an overloaded
// consumer must never delay the agent process or create a second stream.
func (m *Manager) PublishMCPAttachment(evidence streams.MCPAttachmentEvidence) {
select {
case m.updatesCh <- adapter.AgentEvent{
Type: streams.EventTypeMCPAttachment,
MCPAttachment: &evidence,
}:
default:
m.logger.Warn("updates channel full, dropping MCP attachment evidence",
zap.String("attempt_id", evidence.AttemptID),
zap.String("server_name", evidence.ServerName),
zap.String("kind", string(evidence.Kind)))
}
}
// PublishMCPAttachmentAttempt starts an attachment-evidence timeline through
// the existing non-blocking agent update stream.
func (m *Manager) PublishMCPAttachmentAttempt(attempt streams.MCPAttachmentAttempt) {
select {
case m.updatesCh <- adapter.AgentEvent{
Type: streams.EventTypeMCPAttachment,
MCPAttachmentAttempt: &attempt,
}:
default:
m.logger.Warn("updates channel full, dropping MCP attachment attempt",
zap.String("attempt_id", attempt.AttemptID))
}
}
// GetAdapter returns the protocol adapter
func (m *Manager) GetAdapter() adapter.AgentAdapter {
m.mu.RLock()
defer m.mu.RUnlock()
return m.adapter
}
// GetSessionID returns the current session ID from the adapter.
// The adapter is the single source of truth for session ID.
func (m *Manager) GetSessionID() string {
m.mu.RLock()
a := m.adapter
m.mu.RUnlock()
if a != nil {
return a.GetSessionID()
}
return ""
}
// Stop stops the agent process
func (m *Manager) Stop(ctx context.Context) error {
return m.stop(ctx)
}
// StopForTeardown permanently closes process admission and drains prior owners
// before stopping every process owned by the manager.
func (m *Manager) StopForTeardown(ctx context.Context) error {
m.CloseAdmission()
if err := m.WaitForAdmission(ctx); err != nil {
return fmt.Errorf("wait for process admission to drain: %w", err)
}
return m.stop(ctx)
}
func (m *Manager) stop(ctx context.Context) error {
m.mu.Lock()
defer m.mu.Unlock()
// Stop intentionally serializes the full teardown under m.mu. The shutdown
// path closes adapters, stdin, shell sessions, and process groups as one
// lifecycle transition; concurrent readers may briefly block while process
// group reaping finishes.
// Stop trackers before the status guard: passthrough never calls Start() so the early return below would otherwise leak them.
m.stopWorkspaceTrackers()
status := m.Status()
if status == StatusStopped || status == StatusStopping {
m.logger.Info("Stop called but already stopped/stopping",
zap.String("status", string(status)),
zap.Int("pid", m.agentPID()))
if status == StatusStopped {
// The agent process reaches StatusStopped on its own whenever the
// child exits without an explicit Stop (waitForExit stores it). The
// adapter and stopCh were never torn down in that case, so do it
// here — otherwise the adapter's update worker and forwardUpdates
// stay alive for the lifetime of the manager. The call is a no-op
// after a normal stop, in which case behaviour is unchanged.
tornDown := m.closeAdapterAndStdin()
if err := m.stopShellAndProcesses(ctx); err != nil {
return err
}
switch {
case m.mainReapPending.Load():
if err := m.waitForProcessExit(ctx); err != nil {
return err
}
m.mainReapPending.Store(false)
case tornDown:
m.drainAfterLateTeardown(ctx)
}
return nil
}
return nil
}
m.logger.Info("stopping agent process - START",
zap.Int("pid", m.agentPID()),
zap.String("protocol", m.agentProtocol()))
m.logger.Debug("agent process stop requested",
zap.Int("pid", m.agentPID()),
zap.String("protocol", m.agentProtocol()))
m.status.Store(StatusStopping)
auxiliaryStopErr := m.stopShellAndProcesses(ctx)
m.closeAdapterAndStdin()
m.killProcessGroupIfRequired()
mainStopErr := m.waitForProcessExit(ctx)
m.status.Store(StatusStopped)
m.logger.Info("stopping agent process - COMPLETE")
if stopErr := errors.Join(auxiliaryStopErr, mainStopErr); stopErr != nil {
m.mainReapPending.Store(mainStopErr != nil)
return stopErr
}
m.mainReapPending.Store(false)
return nil
}
func (m *Manager) agentPID() int {
if m.cmd == nil || m.cmd.Process == nil {
return 0
}
return m.cmd.Process.Pid
}
func (m *Manager) agentProtocol() string {
if m.cfg == nil {
return ""
}
return string(m.cfg.Protocol)
}
// stopShellAndProcesses stops the shell session, VS Code, and workspace processes.
func (m *Manager) stopShellAndProcesses(ctx context.Context) error {
var errs []error
// Stop VS Code server if running
m.logger.Debug("stopping vscode server")
if err := m.StopVscode(ctx); err != nil {
m.logger.Debug("failed to stop vscode server", zap.Error(err))
errs = append(errs, err)
}
m.logger.Debug("vscode server stopped")
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))
errs = append(errs, err)
} else {
m.shell = nil
}
}
m.logger.Debug("shell session stopped")
m.logger.Debug("stopping terminal shells")
if m.shellMgr != nil {
if err := m.shellMgr.StopAll(); err != nil {
m.logger.Debug("failed to stop terminal shells", zap.Error(err))
errs = append(errs, err)
}
}
m.logger.Debug("terminal shells 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.StopAllAndWait(ctx); err != nil {
m.logger.Debug("failed to stop workspace processes", zap.Error(err))
errs = append(errs, err)
}
}
m.logger.Debug("workspace processes stopped")
return errors.Join(errs...)
}
// closeAdapterAndStdin closes the protocol adapter, the stop channel, and stdin.
// Adapter.Close and stdin.Close are idempotent, so they run unconditionally —
// that matters after a Start that failed once an adapter had already been
// created. close(stopCh) is not idempotent and is guarded instead.
//
// It reports whether this call closed stopCh, i.e. whether goroutines were just
// released and still need draining.
func (m *Manager) closeAdapterAndStdin() bool {
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")
m.logger.Debug("closing stop channel")
closedStopCh := false
if m.stopCh != nil && m.stopChClosed.CompareAndSwap(false, true) {
close(m.stopCh)
closedStopCh = true
}
m.logger.Debug("stop channel closed", zap.Bool("closed_now", closedStopCh))
// 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")
return closedStopCh
}
// finishPreviousLifecycle completes a teardown the previous lifecycle never ran
// before Start replaces stopCh, doneCh and the adapter.
//
// Start accepts StatusStopped and StatusError, so it can be reached after the
// agent process exited on its own without an intervening Stop. In that case the
// previous forwardUpdates is still waiting on the old stopCh and the previous
// adapter's update worker is still live; reassigning the fields would strand
// both permanently.
func (m *Manager) finishPreviousLifecycle(ctx context.Context) {
m.mu.Lock()
defer m.mu.Unlock()
if !m.closeAdapterAndStdin() {
return
}
m.logger.Info("draining previous agent lifecycle before restart")
m.drainAfterLateTeardown(ctx)
}
// drainAfterLateTeardown waits for the manager's goroutines to finish after a
// teardown that ran later than the process exit. Unlike waitForProcessExit it
// never signals a process group: the manager has already reported StatusStopped,
// so m.cmd may hold a stale PID it no longer owns.
func (m *Manager) drainAfterLateTeardown(ctx context.Context) {
done := make(chan struct{})
go func() {
m.wg.Wait()
close(done)
}()
if m.waitForManagerDone(ctx, done, m.processExitGrace(ctx)) {
return
}
m.logger.Warn("manager goroutines still running after late adapter teardown")
}
// killProcessGroupIfRequired immediately kills the entire process group for
// adapters (such as OpenCode) that are known not to exit when stdin is closed.
// Other adapters still get process-group cleanup in waitForProcessExit after
// their graceful stdin-close path has had a chance to finish.
func (m *Manager) killProcessGroupIfRequired() {
if m.adapter == nil || !m.adapter.RequiresProcessKill() {
return
}
if m.cmd == nil || m.cmd.Process == nil {
return
}
// We kill the process group to ensure all child processes are killed too.
// This is important because OpenCode spawns: npx -> sh -> node -> opencode binary
pid := m.cmd.Process.Pid
m.logger.Debug("agent process group SIGKILL requested",
zap.Int("pgid", pid),
zap.String("reason", "adapter_requires_process_kill"))
if err := m.killProcessGroup(pid); err != nil {
m.logger.Debug("failed to kill process group, trying single process", zap.Error(err))
m.logger.Debug("agent process SIGKILL requested",
zap.Int("pid", pid),
zap.String("reason", "process_group_kill_failed"))
if err := m.cmd.Process.Kill(); err != nil {
m.logger.Warn("failed to kill process", zap.Error(err))
}
}
}
// waitForProcessExit waits for all goroutines to finish, force-killing on context timeout.
//
// On timeout the entire process group is killed (not just the leader) so that
// child processes — most importantly MCP servers spawned by the agent — don't
// re-parent to init and leak. setAgentProcGroup at command-build time puts the
// agent in its own pgid; here we deliver SIGKILL to that pgid. If the command
// leader exits before its descendants do, we still terminate the remaining
// process group before reporting shutdown complete. Falls back to a
// single-process kill only if the process-group call fails.
func (m *Manager) waitForProcessExit(ctx context.Context) error {
m.logger.Debug("waiting for process to exit")
done := make(chan struct{})
go func() {
m.wg.Wait()
close(done)
}()
pid := 0
if m.cmd != nil && m.cmd.Process != nil {
pid = m.cmd.Process.Pid
}
exitGrace := m.processExitGrace(ctx)
if m.waitForManagerDone(ctx, done, exitGrace) {
m.logger.Info("agent process stopped gracefully")
return m.reapRemainingProcessGroup(ctx, pid)
}
if pid == 0 {
return fmt.Errorf("agent process goroutines were not reaped")
}
if ctx.Err() != nil {
m.logger.Warn("force killing agent process group", zap.Int("pgid", pid))
return m.forceKillProcessGroupAndWait(done, pid)
}
m.logger.Warn("agent process did not exit after graceful wait; terminating process group",
zap.Int("pgid", pid),
zap.Duration("grace", exitGrace))
m.logger.Debug("agent process group SIGTERM requested",
zap.Int("pgid", pid),
zap.String("reason", "graceful_wait_expired"),
zap.Duration("grace", exitGrace))
if err := m.terminateProcessGroup(pid); err != nil {
if !isProcessGroupMissing(err) {
m.logger.Warn("failed to terminate agent process group, force killing",
zap.Int("pgid", pid),
zap.Error(err))
return m.forceKillProcessGroupAndWait(done, pid)
}
return m.verifyMainReaped(done, pid)
}
if m.waitForManagerDone(ctx, done, processGroupTerminateGrace) {
m.logger.Info("agent process stopped after process group termination",
zap.Int("pgid", pid))
return m.reapRemainingProcessGroup(ctx, pid)
}
m.logger.Warn("agent process did not stop after termination; force killing process group",
zap.Int("pgid", pid))
return m.forceKillProcessGroupAndWait(done, pid)
}
// processExitGrace returns the initial graceful wait before process-group
// termination. Adapters that declare RequiresProcessKill are known not to exit
// from stdin close, so they keep the short legacy wait. Normal adapters get the
// caller's remaining deadline during backend shutdown, or processDefaultExitGrace
// when the caller did not provide one.
func (m *Manager) processExitGrace(ctx context.Context) time.Duration {
if m.adapter != nil && m.adapter.RequiresProcessKill() {
return processKillRequiredExitGrace
}
if deadline, ok := ctx.Deadline(); ok {
if remaining := time.Until(deadline); remaining > 0 {
return remaining
}
return 0
}
return processDefaultExitGrace
}
func waitForManagerDone(ctx context.Context, done <-chan struct{}, timeout time.Duration) bool {
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-done:
return true
case <-ctx.Done():
return false
case <-timer.C:
return false
}
}
func (m *Manager) reapRemainingProcessGroup(ctx context.Context, pid int) error {
if err, owned := m.reapMainProcessLifecycle(); owned {
return err
}
if pid == 0 || !m.processGroupAlive(pid) {
return nil
}
m.logger.Warn("agent process group still alive after leader exit; terminating",
zap.Int("pgid", pid))
m.logger.Debug("agent process group SIGTERM requested",
zap.Int("pgid", pid),
zap.String("reason", "leader_exited_with_live_descendants"))
if err := m.terminateProcessGroup(pid); err != nil {
if isProcessGroupMissing(err) {
return nil
}
m.logger.Warn("failed to terminate agent process group, force killing",
zap.Int("pgid", pid),
zap.Error(err))
return m.forceKillProcessGroupAndWait(nil, pid)
}
waitCtx, cancel := context.WithTimeout(ctx, processGroupTerminateGrace)
defer cancel()
if m.waitForProcessGroupExit(waitCtx, pid) {
m.logger.Info("agent process group stopped after termination",
zap.Int("pgid", pid))
return nil
}
m.logger.Warn("agent process group did not stop after termination; force killing",
zap.Int("pgid", pid))
return m.forceKillProcessGroupAndWait(nil, pid)
}
func (m *Manager) reapMainProcessLifecycle() (error, bool) {
m.processLifecycleMu.Lock()
defer m.processLifecycleMu.Unlock()
if !ownsProcessLifecycle(m.processLifecycle) {
return nil, false
}
if err := reapProcessLifecycle(m.processLifecycle); err != nil {
return err, true
}
m.processLifecycle = processLifecycleHandle{}
return nil, true
}
func (m *Manager) forceKillProcessGroup(pid int) {
m.logger.Debug("agent process group SIGKILL requested",
zap.Int("pgid", pid),
zap.String("reason", "force_kill"))
if err := m.killProcessGroup(pid); err != nil {
if isProcessGroupMissing(err) {
return
}
if m.cmd != nil && m.cmd.Process != nil {
m.logger.Warn("failed to kill agent process group, falling back to single-process kill",
zap.Error(err))
m.logger.Debug("agent process SIGKILL requested",
zap.Int("pid", m.cmd.Process.Pid),
zap.String("reason", "process_group_kill_failed"))
if err := m.cmd.Process.Kill(); err != nil {
m.logger.Warn("failed to kill agent process", zap.Error(err))
}
return
}
m.logger.Warn("failed to kill agent process group", zap.Error(err))
}
}
func (m *Manager) forceKillProcessGroupAndWait(done <-chan struct{}, pid int) error {
m.forceKillProcessGroup(pid)
waitCtx, cancel := context.WithTimeout(context.Background(), processGroupTerminateGrace)
defer cancel()
managerDone := done == nil || m.waitForManagerDone(waitCtx, done, processGroupTerminateGrace)
groupDone := m.waitForProcessGroupExit(waitCtx, pid)
if managerDone && groupDone {
m.logger.Info("agent process stopped after force kill",
zap.Int("pgid", pid))
return nil
}
var errs []error
if !managerDone {
errs = append(errs, fmt.Errorf("agent process goroutines were not reaped after force kill"))
}
if !groupDone {
errs = append(errs, fmt.Errorf("agent process group %d remains alive after force kill", pid))
}
return errors.Join(errs...)
}
func (m *Manager) verifyMainReaped(done <-chan struct{}, pid int) error {
if done != nil {
select {
case <-done:
default:
return fmt.Errorf("agent process goroutines were not reaped")
}
}
if pid != 0 && m.processGroupAlive(pid) {
return fmt.Errorf("agent process group %d remains alive", pid)
}
return nil
}
func (m *Manager) processGroupAlive(pid int) bool {
if m.groupAliveFn != nil {
return m.groupAliveFn(pid)
}
return processGroupAlive(pid)
}
func (m *Manager) waitForManagerDone(ctx context.Context, done <-chan struct{}, timeout time.Duration) bool {
if m.managerWaitFn != nil {
return m.managerWaitFn(ctx, done, timeout)
}
return waitForManagerDone(ctx, done, timeout)
}
func (m *Manager) terminateProcessGroup(pid int) error {
if m.terminateGroupFn != nil {
return m.terminateGroupFn(pid)
}
return terminateProcessGroup(pid)
}
func (m *Manager) killProcessGroup(pid int) error {
if m.killGroupFn != nil {
return m.killGroupFn(pid)
}
return killProcessGroup(pid)
}
func (m *Manager) waitForProcessGroupExit(ctx context.Context, pid int) bool {
if m.waitGroupExitFn != nil {
return m.waitGroupExitFn(ctx, pid)
}
if !m.processGroupAlive(pid) {
return true
}
ticker := time.NewTicker(processGroupPollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return !m.processGroupAlive(pid)
case <-ticker.C:
if !m.processGroupAlive(pid) {
return true
}
}
}
}
func waitForProcessGroupExit(ctx context.Context, pid int) bool {
if !processGroupAlive(pid) {
return true
}
ticker := time.NewTicker(processGroupPollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return !processGroupAlive(pid)
case <-ticker.C:
if !processGroupAlive(pid) {
return true
}
}
}
}
// readStderr reads and logs stderr from the agent
func (m *Manager) readStderr() {
defer m.wg.Done()
scanner := bufio.NewScanner(m.stderr)
for scanner.Scan() {
rawLine := stripANSI(scanner.Text())
if m.stderrConsumer != nil {
// Protocol-specific consumers inspect the line in memory. Their
// normalized output is projected separately before any generic
// logging, buffering, or process-exit event can expose it.
m.stderrConsumer.ConsumeStderrLine(rawLine)
}
line, keep := rawLine, true
if m.stderrSanitizer != nil {
line, keep = m.stderrSanitizer.SanitizeStderrLine(rawLine)
}
if !keep || line == "" {
continue
}
m.logger.Debug("agent stderr", zap.String("line", line))
// Buffer only the safe projection for error context.
m.appendStderr(line)
}
if err := scanner.Err(); err != nil {
m.logger.Debug("stderr reader error", zap.Error(err))
}
}
// ansiEscapeRegex matches ANSI escape sequences
var ansiEscapeRegex = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)
// stripANSI removes ANSI escape codes from a string
func stripANSI(s string) string {
return ansiEscapeRegex.ReplaceAllString(s, "")
}
// appendStderr adds a line to the stderr ring buffer
func (m *Manager) appendStderr(line string) {
m.stderrMu.Lock()
defer m.stderrMu.Unlock()
// Strip ANSI escape codes for cleaner display
cleanLine := stripANSI(line)
if len(m.stderrBuffer) >= defaultStderrBufferSize {
// Ring buffer: drop oldest line
m.stderrBuffer = m.stderrBuffer[1:]
}
m.stderrBuffer = append(m.stderrBuffer, cleanLine)
}
// GetRecentStderr returns a copy of the recent stderr lines
func (m *Manager) GetRecentStderr() []string {
m.stderrMu.RLock()
defer m.stderrMu.RUnlock()
result := make([]string, len(m.stderrBuffer))
copy(result, m.stderrBuffer)
return result
}
// ClearStderrBuffer clears the stderr buffer (e.g., after successful operation)
func (m *Manager) ClearStderrBuffer() {
m.stderrMu.Lock()
defer m.stderrMu.Unlock()
m.stderrBuffer = nil
}
// waitForExit waits for the process to exit
func (m *Manager) waitForExit() {
defer m.wg.Done()
defer close(m.doneCh)
pid := m.agentPID()
err := m.cmd.Wait()
intentionalStop := m.Status() == StatusStopping
switch {
case intentionalStop:
m.exitCode.Store(0)
m.logger.Info("agent process exited during intentional stop")
case err != nil:
m.exitErr.Store(errorWrapper{err: err})
exitCode := -1
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
m.exitCode.Store(int32(exitCode))
}
// Include recent stderr for better error diagnostics
recentStderr := m.GetRecentStderr()
m.logger.Error("agent process exited with error",
zap.Error(err),
zap.Int("exit_code", exitCode),
zap.Strings("recent_stderr", recentStderr))
// Send error event to the updates channel so UI can display it
errorMsg := fmt.Sprintf("Agent process exited with code %d", exitCode)
if len(recentStderr) > 0 {
errorMsg = fmt.Sprintf("%s: %s", errorMsg, strings.Join(recentStderr, "; "))
}
select {
case m.updatesCh <- adapter.AgentEvent{
Type: adapter.EventTypeError,
Error: errorMsg,
Data: map[string]any{
"exit_code": exitCode,
"recent_stderr": recentStderr,
},
}:
default:
m.logger.Warn("updates channel full, could not send exit error event")
}
default:
m.exitCode.Store(0)
m.logger.Info("agent process exited successfully")
}
if !intentionalStop {
if err := m.reapRemainingProcessGroup(context.Background(), pid); err != nil {
m.mainReapPending.Store(true)
m.logger.Warn("agent process group reap remains pending", zap.Error(err))
} else {
m.mainReapPending.Store(false)
}
}
m.status.Store(StatusStopped)
}
// GetFinalCommand returns the full command string that was used to start the agent process,
// including all adapter-added arguments (sandbox mode, MCP flags, etc.).
func (m *Manager) GetFinalCommand() string {
return m.finalCommand
}
// GetProcessInfo returns information about the process
func (m *Manager) GetProcessInfo() map[string]interface{} {
info := map[string]interface{}{
"status": string(m.Status()),
"exit_code": m.ExitCode(),
}
if m.cmd != nil && m.cmd.Process != nil {
info["pid"] = m.cmd.Process.Pid
}
if err := m.ExitError(); err != nil {
info["exit_error"] = err.Error()
}
return info
}
// handlePermissionRequest handles permission requests from the agent
// It stores the pending request and waits for a response from the backend
func (m *Manager) handlePermissionRequest(ctx context.Context, req *adapter.PermissionRequest) (*adapter.PermissionResponse, error) {
// Use the adapter-provided pending ID if available, otherwise generate one.
// This ensures the ID sent to the frontend matches the one used for response lookup.
// For OpenCode (per_xxx) and Claude Code (requestID), the adapter passes its ID
// so we use the same ID throughout the permission flow.
pendingID := req.PendingID
if pendingID == "" {
pendingID = fmt.Sprintf("%s-%s-%d", req.SessionID, req.ToolCallID, time.Now().UnixNano())
}
m.logger.Info("handling permission request",
zap.String("pending_id", pendingID),
zap.String("session_id", req.SessionID),
zap.String("tool_call_id", req.ToolCallID),
zap.String("title", req.Title),
zap.Bool("auto_approve", m.cfg.AutoApprovePermissions))
// If auto-approve is enabled, immediately approve with the first "allow" option
if m.cfg.AutoApprovePermissions {
return m.autoApprovePermission(req)
}
// Create pending permission with response channel
pending := &PendingPermission{
ID: pendingID,
Request: req,
ResponseCh: make(chan *adapter.PermissionResponse, 1),
CreatedAt: time.Now(),
}
// Store pending permission
m.permissionMu.Lock()
m.pendingPermissions[pendingID] = pending
m.permissionMu.Unlock()
// Clean up when done
defer func() {
m.permissionMu.Lock()
delete(m.pendingPermissions, pendingID)
m.permissionMu.Unlock()
}()
// Send notification to backend via updates channel
m.sendPermissionNotification(pending)
// Wait for response indefinitely - user may close and reopen the page
select {
case resp := <-pending.ResponseCh:
m.logger.Info("received permission response",
zap.String("pending_id", pendingID),
zap.String("option_id", resp.OptionID),
zap.Bool("cancelled", resp.Cancelled))
if resp.Cancelled {
m.sendPermissionCancelledNotification(pending)
}
return resp, nil
case <-ctx.Done():
m.logger.Warn("permission request context cancelled",
zap.String("pending_id", pendingID))
// Send cancellation notification so the backend can update the permission message status
m.sendPermissionCancelledNotification(pending)
return &adapter.PermissionResponse{Cancelled: true}, nil
}
}
// autoApprovePermission automatically approves a permission request
// by selecting the first "allow" option, or the first option if no allow option exists
func (m *Manager) autoApprovePermission(req *adapter.PermissionRequest) (*adapter.PermissionResponse, error) {
if len(req.Options) == 0 {
m.logger.Warn("no options available for auto-approve, cancelling")
return &adapter.PermissionResponse{Cancelled: true}, nil
}
// Find the first "allow" option
var selectedOption *adapter.PermissionOption
for i := range req.Options {
opt := &req.Options[i]
if opt.Kind == "allow_once" || opt.Kind == "allow_always" {
selectedOption = opt
break
}
}
// If no allow option, use the first option
if selectedOption == nil {
selectedOption = &req.Options[0]
}
m.logger.Info("auto-approving permission request",
zap.String("option_id", selectedOption.OptionID),
zap.String("option_name", selectedOption.Name),
zap.String("kind", string(selectedOption.Kind)))
return &adapter.PermissionResponse{
OptionID: selectedOption.OptionID,
}, nil
}
// sendPermissionNotification sends a permission request notification through the updates channel.
// Uses a blocking send with timeout to ensure delivery. If delivery fails within 5 seconds,
// auto-cancels the permission so the agent doesn't hang waiting for a response.
func (m *Manager) sendPermissionNotification(pending *PendingPermission) {
// Convert options to streams.PermissionOption (types.PermissionOption is an alias)
options := make([]streams.PermissionOption, len(pending.Request.Options))
copy(options, pending.Request.Options)
event := adapter.AgentEvent{
Type: adapter.EventTypePermissionRequest,
SessionID: pending.Request.SessionID,
ToolCallID: pending.Request.ToolCallID,
PendingID: pending.ID,
PermissionTitle: pending.Request.Title,
PermissionOptions: options,
ActionType: pending.Request.ActionType,
ActionDetails: pending.Request.ActionDetails,
}
m.logger.Info("sending permission notification via updates channel",
zap.String("pending_id", pending.ID),
zap.String("title", pending.Request.Title),
zap.String("action_type", pending.Request.ActionType))
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()
select {
case m.updatesCh <- event:
// Sent successfully
case <-timer.C:
m.logger.Error("failed to deliver permission notification, auto-cancelling",
zap.String("pending_id", pending.ID))
select {
case pending.ResponseCh <- &adapter.PermissionResponse{Cancelled: true}:
default:
}
}
}
// sendPermissionCancelledNotification sends a notification that a permission request was cancelled.
// This happens when the context is cancelled (e.g., agent completes or user stops the task)
// before the user responds to the permission request.
func (m *Manager) sendPermissionCancelledNotification(pending *PendingPermission) {
event := adapter.AgentEvent{
Type: adapter.EventTypePermissionCancelled,
SessionID: pending.Request.SessionID,
PendingID: pending.ID,
}
m.logger.Info("sending permission cancelled notification",
zap.String("pending_id", pending.ID),
zap.String("session_id", pending.Request.SessionID))
select {
case m.updatesCh <- event:
// Sent successfully
default:
m.logger.Warn("updates channel full, dropping permission cancelled notification",
zap.String("pending_id", pending.ID))
}
}
// RespondToPermission responds to a pending permission request
func (m *Manager) RespondToPermission(pendingID string, optionID string, cancelled bool) error {
m.permissionMu.RLock()
pending, ok := m.pendingPermissions[pendingID]
m.permissionMu.RUnlock()
if !ok {
return fmt.Errorf("pending permission not found: %s", pendingID)
}
m.logger.Info("responding to permission request",
zap.String("pending_id", pendingID),
zap.String("option_id", optionID),
zap.Bool("cancelled", cancelled))
// Send response (non-blocking since channel is buffered)
select {
case pending.ResponseCh <- &adapter.PermissionResponse{
OptionID: optionID,
Cancelled: cancelled,
}:
return nil
default:
return fmt.Errorf("response channel full for pending permission: %s", pendingID)
}
}
// CancelPendingPermissions cancels all pending permission requests.
// Called before sending a new prompt so the agent isn't blocked waiting for responses.
func (m *Manager) CancelPendingPermissions() {
m.permissionMu.RLock()
pending := make([]*PendingPermission, 0, len(m.pendingPermissions))
for _, p := range m.pendingPermissions {
pending = append(pending, p)
}
m.permissionMu.RUnlock()
for _, p := range pending {
m.logger.Info("cancelling pending permission before new prompt",
zap.String("pending_id", p.ID))
select {
case p.ResponseCh <- &adapter.PermissionResponse{Cancelled: true}:
default:
}
}
}
// Shell returns the embedded shell session, or nil if not available
func (m *Manager) Shell() *shell.Session {
m.mu.RLock()
defer m.mu.RUnlock()
return m.shell
}
// ShellManager returns the per-terminal shell session manager.
func (m *Manager) ShellManager() *shell.Manager {
return m.shellMgr
}
// StartShell creates and starts the shell session independently of the agent process.
// This is used in passthrough mode where the agent runs directly via InteractiveRunner
// but we still need shell access for the workspace.
// Returns nil if shell is already started or if ShellEnabled is false.
func (m *Manager) StartShell() error {
release, err := m.admitStart()
if err != nil {
return err
}
defer release()
m.mu.Lock()
defer m.mu.Unlock()
// Already running
if m.shell != nil {
return nil
}
// Shell disabled
if !m.cfg.ShellEnabled {
return nil
}
shellCfg, err := m.buildShellConfig()
if err != nil {
return fmt.Errorf("prepare shell environment: %w", err)
}
shellSession, err := shell.NewSession(shellCfg, m.logger)
if err != nil {
return fmt.Errorf("failed to create shell session: %w", err)
}
m.shell = shellSession
m.logger.Info("shell session started independently")
return nil
}
// StartTerminalShell creates a managed per-terminal shell. The shell inherits the
// instance's agent environment (executor-profile env vars, credentials, KANDEV_*
// metadata) so a terminal opened on a remote workspace sees the same variables
// the agent subprocess does. Caller-supplied cfg.Env still wins.
func (m *Manager) StartTerminalShell(terminalID string, cfg shell.Config) (*shell.Session, error) {
release, err := m.admitStart()
if err != nil {
return nil, err
}
defer release()
cfg.Env, err = mergeAgentEnvIntoShellConfigWithError(m.agentEnvSnapshot(), cfg.Env)
if err != nil {
return nil, fmt.Errorf("prepare terminal shell environment: %w", err)
}
return m.shellMgr.Start(terminalID, cfg)
}
// agentEnvSnapshot returns a copy of the instance agent environment under the
// start lock, so a concurrent Configure appending to AgentEnv can't race.
func (m *Manager) agentEnvSnapshot() []string {
m.startMu.Lock()
defer m.startMu.Unlock()
return append([]string(nil), m.cfg.AgentEnv...)
}
// mergeAgentEnvIntoShellConfig folds the instance agent env into the shell's
// override map. Explicit overrides take precedence over the inherited value.
func mergeAgentEnvIntoShellConfig(agentEnv []string, overrides map[string]string) map[string]string {
merged, err := mergeAgentEnvIntoShellConfigWithError(agentEnv, overrides)
if err != nil {
return overrides
}
return merged
}
func mergeAgentEnvIntoShellConfigWithError(agentEnv []string, overrides map[string]string) (map[string]string, error) {
if len(agentEnv) == 0 && len(overrides) == 0 {
return overrides, nil
}
base := make(map[string]string, len(agentEnv))
for _, entry := range agentEnv {
eq := strings.IndexByte(entry, '=')
if eq <= 0 {
continue
}
base[entry[:eq]] = entry[eq+1:]
}
return gitconfigenv.Merge(base, overrides)
}
// StartVscode starts the code-server process on a random OS-assigned port.
// The VS Code server runs independently of the agent process.
// Start is non-blocking — the caller should poll VscodeInfo() for status updates.
func (m *Manager) StartVscode(_ context.Context, theme string) error {
release, err := m.admitStart()
if err != nil {
return err
}
defer release()
return m.startVscode(theme)
}
func (m *Manager) startVscode(theme string) error {
m.vscodeMu.Lock()
defer m.vscodeMu.Unlock()
if m.vscode != nil {
info := m.vscode.Info()
if info.Status == VscodeStatusRunning || info.Status == VscodeStatusStarting || info.Status == VscodeStatusInstalling {
return nil
}
if m.vscode.HasUnreapedOwnership() {
return fmt.Errorf("previous code-server process cleanup is incomplete")
}
}
command := m.cfg.VscodeCommand
if command == "" {
command = "code-server"
}
strategy := codeServerInstallStrategy(m.logger)
m.vscode = NewVscodeManager(command, m.cfg.WorkDir, theme, strategy, m.logger)
m.vscode.Start()
return nil
}
// codeServerInstallStrategy returns a tarball strategy that auto-installs code-server.
func codeServerInstallStrategy(log *logger.Logger) tools.Strategy {
home, err := os.UserHomeDir()
if err != nil {
home = "."
}
installDir := filepath.Join(home, ".kandev", "tools", "code-server")
return tools.NewGithubTarballStrategy(installDir, "code-server", tools.GithubTarballConfig{
Owner: "coder",
Repo: "code-server",
Version: "4.96.4",
AssetPattern: "code-server-{version}-{os}-{arch}.tar.gz",
BinaryPath: "code-server-{version}-{os}-{arch}/bin/code-server",
Targets: map[string]string{
"darwin/arm64": "macos-arm64",
"darwin/amd64": "macos-amd64",
"linux/amd64": "linux-amd64",
"linux/arm64": "linux-arm64",
},
}, log)
}
// StopVscode stops the code-server process if running.
func (m *Manager) StopVscode(ctx context.Context) error {
m.vscodeMu.Lock()
defer m.vscodeMu.Unlock()
if m.vscode == nil {
return nil
}
err := m.vscode.Stop(ctx)
if err == nil {
m.vscode = nil
}
return err
}
// VscodeInfo returns the current VS Code server status.
func (m *Manager) VscodeInfo() VscodeInfo {
m.vscodeMu.Lock()
defer m.vscodeMu.Unlock()
if m.vscode == nil {
return VscodeInfo{Status: VscodeStatusStopped}
}
return m.vscode.Info()
}
// VscodeOpenFile opens a file in the running VS Code instance via the Remote CLI.
// If code-server is not running, it auto-starts it and waits for readiness.
//
// Note: there is a benign race between the needsStart check and WaitForRunning —
// another goroutine could stop vscode in between. WaitForRunning handles this
// correctly by returning an error for stopped/error states.
func (m *Manager) VscodeOpenFile(ctx context.Context, path string, line, col int) error {
release, err := m.admitStart()
if err != nil {
return err
}
defer release()
m.vscodeMu.Lock()
needsStart := m.vscode == nil
if !needsStart {
info := m.vscode.Info()
needsStart = info.Status == VscodeStatusStopped || info.Status == VscodeStatusError
}
m.vscodeMu.Unlock()
if needsStart {
m.logger.Info("auto-starting code-server for open-file request")
if err := m.startVscode(""); err != nil {
return err
}
}
m.vscodeMu.Lock()
vscode := m.vscode
m.vscodeMu.Unlock()
if vscode == nil {
return fmt.Errorf("failed to initialize code-server")
}
if err := vscode.WaitForRunning(ctx); err != nil {
return fmt.Errorf("code-server not ready: %w", err)
}
return vscode.OpenFile(ctx, path, line, col)
}