1356 lines
52 KiB
Go
1356 lines
52 KiB
Go
package lifecycle
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/kandev/kandev/internal/agent/agents"
|
|
"github.com/kandev/kandev/internal/agent/executor"
|
|
agentctlclient "github.com/kandev/kandev/internal/agent/runtime/agentctl"
|
|
"github.com/kandev/kandev/internal/agent/runtime/routingerr"
|
|
agentctltypes "github.com/kandev/kandev/internal/agentctl/types"
|
|
"github.com/kandev/kandev/internal/agentctl/types/streams"
|
|
"github.com/kandev/kandev/internal/events"
|
|
"github.com/kandev/kandev/internal/task/models"
|
|
v1 "github.com/kandev/kandev/pkg/api/v1"
|
|
)
|
|
|
|
// WasSessionInitialized reports whether the execution completed ACP session setup.
|
|
// Returns false if the execution is not found or init hasn't completed.
|
|
func (m *Manager) WasSessionInitialized(executionID string) bool {
|
|
exec, exists := m.executionStore.Get(executionID)
|
|
if !exists {
|
|
return false
|
|
}
|
|
return exec.sessionInitialized
|
|
}
|
|
|
|
// GetSessionAuthMethods returns auth methods for a session's execution.
|
|
// It first checks cached methods from agent_capabilities events. When the agent
|
|
// failed before reporting capabilities (e.g., immediate auth error), it falls
|
|
// back to static auth methods derived from the agent type.
|
|
func (m *Manager) GetSessionAuthMethods(sessionID string) []streams.AuthMethodInfo {
|
|
exec, exists := m.executionStore.GetBySessionID(sessionID)
|
|
if !exists {
|
|
return nil
|
|
}
|
|
if methods := exec.GetAuthMethods(); len(methods) > 0 {
|
|
return methods
|
|
}
|
|
return fallbackAuthMethods(exec.AgentID)
|
|
}
|
|
|
|
// fallbackAuthMethods returns static auth methods for known agent types.
|
|
// Used when the agent failed before sending agent_capabilities (e.g., auth error
|
|
// on first prompt before the agent could report its own auth methods).
|
|
func fallbackAuthMethods(agentID string) []streams.AuthMethodInfo {
|
|
switch agentID {
|
|
case "claude-acp":
|
|
return []streams.AuthMethodInfo{
|
|
{
|
|
ID: "claude-auth-login",
|
|
Name: "Anthropic Authentication",
|
|
Description: "Log in to your Anthropic account",
|
|
TerminalAuth: &streams.TerminalAuth{
|
|
Command: "claude",
|
|
Args: []string{"auth", "login"},
|
|
Label: "Log in with Claude CLI",
|
|
},
|
|
},
|
|
}
|
|
case "auggie":
|
|
return []streams.AuthMethodInfo{
|
|
{
|
|
ID: "auggie-login",
|
|
Name: "Auggie Authentication",
|
|
Description: "Log in to your Auggie account",
|
|
TerminalAuth: &streams.TerminalAuth{
|
|
Command: "auggie",
|
|
Args: []string{"login"},
|
|
Label: "Log in with Auggie CLI",
|
|
},
|
|
},
|
|
}
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// PromptAgent sends a follow-up prompt to a running agent.
|
|
// Attachments (images) are passed to the agent if provided.
|
|
// When dispatchOnly is true, returns once the prompt is accepted instead of
|
|
// waiting for the agent's turn to complete.
|
|
func (m *Manager) PromptAgent(ctx context.Context, executionID string, prompt string, attachments []v1.MessageAttachment, dispatchOnly bool) (*PromptResult, error) {
|
|
execution, exists := m.executionStore.Get(executionID)
|
|
if !exists {
|
|
return nil, fmt.Errorf("execution %q not found: %w", executionID, ErrExecutionNotFound)
|
|
}
|
|
return m.sessionManager.SendPrompt(ctx, execution, prompt, true, attachments, dispatchOnly)
|
|
}
|
|
|
|
// cancelWaitTimeout bounds how long CancelAgent waits for the in-flight SendPrompt
|
|
// to exit after the in-flight session/prompt RPC has ended (cancel acknowledged).
|
|
// Exposed as a var (not const) so tests can shorten it without fake clocks.
|
|
var cancelWaitTimeout = 10 * time.Second
|
|
|
|
// cancelEscalationTimeout bounds the post-escalation wait after we inject a synthetic
|
|
// error onto promptDoneCh to unblock a stuck SendPrompt.
|
|
var cancelEscalationTimeout = 2 * time.Second
|
|
|
|
// CancelAgent interrupts the current agent turn without terminating the process,
|
|
// allowing the user to send a new prompt.
|
|
//
|
|
// When the agent subprocess accepts the ACP cancel but never publishes a `complete`
|
|
// event (and the update stream stays open), the in-flight SendPrompt would otherwise
|
|
// block forever. After cancelWaitTimeout we escalate by signalling promptDoneCh with
|
|
// a synthetic error so SendPrompt returns and its deferred cleanup closes
|
|
// promptFinished normally. Callers receive ErrCancelEscalated to signal that local
|
|
// lifecycle state was reconciled but the agent never confirmed the cancel, and
|
|
// higher layers (Service.CancelAgent) must still reconcile DB state.
|
|
func (m *Manager) CancelAgent(ctx context.Context, executionID string) error {
|
|
execution, exists := m.executionStore.Get(executionID)
|
|
if !exists {
|
|
return fmt.Errorf("execution %q not found", executionID)
|
|
}
|
|
|
|
if execution.agentctl == nil {
|
|
return fmt.Errorf("execution %q has no agentctl client", executionID)
|
|
}
|
|
|
|
m.logger.Info("cancelling agent turn",
|
|
zap.String("execution_id", executionID),
|
|
zap.String("task_id", execution.TaskID),
|
|
zap.String("session_id", execution.SessionID))
|
|
|
|
cancelErr := execution.agentctl.Cancel(ctx)
|
|
if cancelErr != nil && !errors.Is(cancelErr, agentctlclient.ErrTurnCancelNotAcknowledged) {
|
|
m.logger.Error("failed to cancel agent turn",
|
|
zap.String("execution_id", executionID),
|
|
zap.Error(cancelErr))
|
|
return fmt.Errorf("failed to cancel agent: %w", cancelErr)
|
|
}
|
|
|
|
// Don't clear buffers or mark ready here.
|
|
// The agent will respond to the original prompt with StopReason=cancelled,
|
|
// which triggers handleCompleteEvent() to properly flush buffers and mark state.
|
|
// Clearing here would race with in-flight notifications and lose content.
|
|
|
|
execution.promptFinishedMu.Lock()
|
|
ch := execution.promptFinished
|
|
execution.promptFinishedMu.Unlock()
|
|
|
|
if ch == nil {
|
|
return nil
|
|
}
|
|
|
|
// The agent did not end the in-flight session/prompt RPC after cancel (e.g. it
|
|
// does not implement session/cancel). Escalate immediately so SendPrompt and the
|
|
// prompt gate are reconciled instead of waiting the full cancelWaitTimeout.
|
|
if errors.Is(cancelErr, agentctlclient.ErrTurnCancelNotAcknowledged) {
|
|
m.logger.Warn("agent cancel not acknowledged; escalating immediately",
|
|
zap.String("execution_id", executionID),
|
|
zap.Error(cancelErr))
|
|
return m.escalateStuckCancel(ctx, execution, ch)
|
|
}
|
|
|
|
m.logger.Info("agent cancel sent, waiting for turn completion",
|
|
zap.String("execution_id", executionID))
|
|
|
|
// Wait for the in-flight SendPrompt to finish processing the cancel completion.
|
|
// Without this, a follow-up PromptAgent races on promptDoneCh with two readers.
|
|
select {
|
|
case <-ch:
|
|
m.logger.Debug("in-flight prompt finished after cancel",
|
|
zap.String("execution_id", executionID))
|
|
return nil
|
|
case <-time.After(cancelWaitTimeout):
|
|
return m.escalateStuckCancel(ctx, execution, ch)
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
|
|
// escalateStuckCancel unblocks a SendPrompt that is stuck waiting for a completion
|
|
// event the agent will never emit. It injects a synthetic error onto promptDoneCh
|
|
// (non-blocking; the channel is buffered size 1), waits briefly for SendPrompt to
|
|
// exit and close promptFinished, and marks the execution ready so the workflow
|
|
// leaves the running state. Returns ErrCancelEscalated so callers know the ACP
|
|
// cancel did not get a clean acknowledgement.
|
|
func (m *Manager) escalateStuckCancel(ctx context.Context, execution *AgentExecution, ch <-chan struct{}) error {
|
|
m.logger.Warn("timed out waiting for in-flight prompt to finish after cancel; escalating",
|
|
zap.String("execution_id", execution.ID),
|
|
zap.String("session_id", execution.SessionID))
|
|
|
|
select {
|
|
case execution.promptDoneCh <- PromptCompletionSignal{
|
|
IsError: true,
|
|
Error: "cancel escalated: agent did not complete turn within timeout",
|
|
}:
|
|
default:
|
|
// Channel already has a pending signal; SendPrompt will pick that up instead.
|
|
}
|
|
|
|
select {
|
|
case <-ch:
|
|
m.logger.Info("in-flight prompt released after cancel escalation",
|
|
zap.String("execution_id", execution.ID))
|
|
case <-time.After(cancelEscalationTimeout):
|
|
m.logger.Warn("in-flight prompt did not release after cancel escalation",
|
|
zap.String("execution_id", execution.ID))
|
|
case <-ctx.Done():
|
|
// Fall through to MarkReady/drain below — once the synthetic signal is
|
|
// queued, the cleanup must survive the caller's context cancellation
|
|
// or the execution leaks in the Running state and the stale signal
|
|
// breaks the next PromptAgent call.
|
|
}
|
|
|
|
if err := m.MarkReady(execution.ID); err != nil {
|
|
m.logger.Warn("failed to mark execution ready after cancel escalation",
|
|
zap.String("execution_id", execution.ID),
|
|
zap.Error(err))
|
|
}
|
|
|
|
// Drain any stale signal that may have been left if the agent completed
|
|
// concurrently with the escalation timeout (Go's select is non-deterministic
|
|
// when both promptFinished and time.After are ready, so our send above may
|
|
// have landed on a channel that SendPrompt had just drained).
|
|
select {
|
|
case <-execution.promptDoneCh:
|
|
default:
|
|
}
|
|
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
return ErrCancelEscalated
|
|
}
|
|
|
|
// SetSessionMode changes the session mode for a running agent.
|
|
// Always uses the execution's current ACPSessionID rather than the caller's copy,
|
|
// which may be stale during startup/restart when the session is being re-initialized.
|
|
func (m *Manager) SetSessionMode(ctx context.Context, executionID, _ string, modeID string) error {
|
|
execution, exists := m.executionStore.Get(executionID)
|
|
if !exists {
|
|
return fmt.Errorf("execution %q not found", executionID)
|
|
}
|
|
if execution.agentctl == nil {
|
|
return fmt.Errorf("execution %q has no agentctl client", executionID)
|
|
}
|
|
if !execution.sessionInitialized || execution.ACPSessionID == "" {
|
|
return fmt.Errorf("execution %q ACP session is not ready", executionID)
|
|
}
|
|
return execution.agentctl.SetMode(ctx, execution.ACPSessionID, modeID)
|
|
}
|
|
|
|
// SetSessionModeBySessionID changes the session mode for a running agent by session ID.
|
|
func (m *Manager) SetSessionModeBySessionID(ctx context.Context, sessionID, modeID string) error {
|
|
execution, exists := m.executionStore.GetBySessionID(sessionID)
|
|
if !exists {
|
|
return fmt.Errorf("no agent running for session %q", sessionID)
|
|
}
|
|
return m.SetSessionMode(ctx, execution.ID, execution.ACPSessionID, modeID)
|
|
}
|
|
|
|
// SetSessionModel changes the session model for a running agent. ACP agents
|
|
// swap the model in-place via their advertised model-selection mechanism.
|
|
// Passthrough (TUI) agents have no protocol channel — the model is a CLI flag
|
|
// baked into the launch — so the override is persisted on the execution and
|
|
// the PTY is relaunched so the next process picks up the new --model.
|
|
func (m *Manager) SetSessionModel(ctx context.Context, executionID, modelID string) error {
|
|
execution, exists := m.executionStore.Get(executionID)
|
|
if !exists {
|
|
return fmt.Errorf("execution %q not found", executionID)
|
|
}
|
|
|
|
if execution.PassthroughProcessID != "" {
|
|
if err := m.executionStore.WithLock(executionID, func(exec *AgentExecution) {
|
|
if exec.Metadata == nil {
|
|
exec.Metadata = make(map[string]interface{})
|
|
}
|
|
exec.Metadata[MetadataKeyModelOverride] = modelID
|
|
}); err != nil {
|
|
return fmt.Errorf("failed to persist model override for execution %q: %w", executionID, err)
|
|
}
|
|
return m.RestartAgentProcess(ctx, executionID)
|
|
}
|
|
|
|
if execution.agentctl == nil {
|
|
return fmt.Errorf("execution %q has no agentctl client", executionID)
|
|
}
|
|
return execution.agentctl.SetModel(ctx, modelID)
|
|
}
|
|
|
|
// SetSessionModelBySessionID changes the session model for a running agent by session ID.
|
|
func (m *Manager) SetSessionModelBySessionID(ctx context.Context, sessionID, modelID string) error {
|
|
execution, exists := m.executionStore.GetBySessionID(sessionID)
|
|
if !exists {
|
|
return fmt.Errorf("no agent running for session %q", sessionID)
|
|
}
|
|
return m.SetSessionModel(ctx, execution.ID, modelID)
|
|
}
|
|
|
|
// SetSessionConfigOption changes an ACP session config option for a running agent.
|
|
func (m *Manager) SetSessionConfigOption(ctx context.Context, executionID, configID, value string) error {
|
|
execution, exists := m.executionStore.Get(executionID)
|
|
if !exists {
|
|
return fmt.Errorf("execution %q not found", executionID)
|
|
}
|
|
if execution.agentctl == nil {
|
|
return fmt.Errorf("execution %q has no agentctl client", executionID)
|
|
}
|
|
if !execution.sessionInitialized || execution.ACPSessionID == "" {
|
|
return fmt.Errorf("execution %q ACP session is not ready", executionID)
|
|
}
|
|
return execution.agentctl.SetConfigOption(ctx, configID, value)
|
|
}
|
|
|
|
// SetSessionConfigOptionBySessionID changes an ACP session config option by task session ID.
|
|
func (m *Manager) SetSessionConfigOptionBySessionID(ctx context.Context, sessionID, configID, value string) error {
|
|
execution, exists := m.executionStore.GetBySessionID(sessionID)
|
|
if !exists {
|
|
return fmt.Errorf("no agent running for session %q", sessionID)
|
|
}
|
|
return m.SetSessionConfigOption(ctx, execution.ID, configID, value)
|
|
}
|
|
|
|
// AuthenticateBySessionID triggers authentication for a given auth method on the agent.
|
|
func (m *Manager) AuthenticateBySessionID(ctx context.Context, sessionID, methodID string) error {
|
|
execution, exists := m.executionStore.GetBySessionID(sessionID)
|
|
if !exists {
|
|
return fmt.Errorf("no agent running for session %q", sessionID)
|
|
}
|
|
if execution.agentctl == nil {
|
|
return fmt.Errorf("execution %q has no agentctl client", execution.ID)
|
|
}
|
|
return execution.agentctl.Authenticate(ctx, methodID)
|
|
}
|
|
|
|
// reapplySessionModeAfterReset re-applies the active session permission mode
|
|
// (e.g. auto / accept-edits) to a freshly (re)initialized ACP session so the
|
|
// user's choice survives a context reset instead of silently reverting to the
|
|
// agent's default.
|
|
//
|
|
// The mode to restore is resolved from the persisted session_mode in the DB
|
|
// (the authoritative, synchronously-written source — see persistSessionMode and
|
|
// the set_session_mode action), falling back to prev (the in-memory mode state
|
|
// captured before the reset) only when no provider is wired or nothing is
|
|
// persisted. Preferring the DB avoids re-applying a stale in-memory mode when a
|
|
// set_session_mode action persisted a newer mode in the same on_enter batch
|
|
// before its agent mode event updated modeState. A nil/empty resolved mode is a
|
|
// no-op. Addresses issue #1183.
|
|
func (m *Manager) reapplySessionModeAfterReset(ctx context.Context, execution *AgentExecution, newSessionID string, prev *CachedModeState) {
|
|
if execution.agentctl == nil {
|
|
return
|
|
}
|
|
fallback := ""
|
|
var availableModes []streams.SessionModeInfo
|
|
if prev != nil {
|
|
fallback = prev.CurrentModeID
|
|
availableModes = prev.AvailableModes
|
|
}
|
|
mode := m.effectiveSessionMode(ctx, execution, fallback)
|
|
if mode == "" {
|
|
return
|
|
}
|
|
if err := execution.agentctl.SetMode(ctx, newSessionID, mode); err != nil {
|
|
m.logger.Warn("failed to re-apply session mode after context reset",
|
|
zap.String("execution_id", execution.ID),
|
|
zap.String("mode", mode),
|
|
zap.Error(err))
|
|
return
|
|
}
|
|
// Restore the cache too: the fresh session would otherwise report the agent's
|
|
// default mode, leaving modeState stale relative to what we just re-applied.
|
|
execution.SetModeState(&CachedModeState{
|
|
CurrentModeID: mode,
|
|
AvailableModes: availableModes,
|
|
})
|
|
m.logger.Info("re-applied session mode after context reset",
|
|
zap.String("execution_id", execution.ID),
|
|
zap.String("session_id", execution.SessionID),
|
|
zap.String("mode", mode))
|
|
}
|
|
|
|
// ResetAgentContext resets the agent's conversation context. For ACP agents that support
|
|
// session reset, this creates a new session on the existing connection (fast, no process restart).
|
|
// For all other agents, this falls back to RestartAgentProcess (full subprocess restart).
|
|
func (m *Manager) ResetAgentContext(ctx context.Context, executionID string) error {
|
|
execution, exists := m.executionStore.Get(executionID)
|
|
if !exists {
|
|
return fmt.Errorf("execution %q not found: %w", executionID, ErrExecutionNotFound)
|
|
}
|
|
|
|
// Passthrough agents always need full restart
|
|
if execution.PassthroughProcessID != "" {
|
|
return m.RestartAgentProcess(ctx, executionID)
|
|
}
|
|
|
|
if execution.agentctl == nil {
|
|
return fmt.Errorf("execution %q has no agentctl client", executionID)
|
|
}
|
|
|
|
// Capture the active session mode before the reset so it can be re-applied to
|
|
// the fresh ACP session (issue #1183). The agent's new session starts at its
|
|
// default mode, so without this the user's chosen mode is lost.
|
|
prevMode := execution.GetModeState()
|
|
|
|
// Resolve agent config and MCP servers for session reset
|
|
agentConfig, err := m.getAgentConfigForExecution(execution)
|
|
if err != nil {
|
|
m.logger.Info("cannot resolve agent config for session reset, falling back to process restart",
|
|
zap.String("execution_id", executionID), zap.Error(err))
|
|
return m.RestartAgentProcess(ctx, executionID)
|
|
}
|
|
|
|
mcpServers, err := m.resolveMcpServers(ctx, execution, agentConfig)
|
|
if err != nil {
|
|
m.logger.Warn("cannot resolve MCP servers for session reset, falling back to process restart",
|
|
zap.String("execution_id", executionID), zap.Error(err))
|
|
return m.RestartAgentProcess(ctx, executionID)
|
|
}
|
|
|
|
// Try session-level reset (only ACP adapters support this)
|
|
newSessionID, err := execution.agentctl.ResetSession(ctx, execution.WorkspacePath, mcpServers)
|
|
if err != nil {
|
|
m.logger.Info("session reset not supported, falling back to process restart",
|
|
zap.String("execution_id", executionID), zap.Error(err))
|
|
return m.RestartAgentProcess(ctx, executionID)
|
|
}
|
|
|
|
// Success — update execution state without restarting process
|
|
_ = m.executionStore.WithLock(executionID, func(exec *AgentExecution) {
|
|
exec.ACPSessionID = newSessionID
|
|
exec.Status = v1.AgentStatusReady
|
|
exec.needsResumeContext = false
|
|
exec.resumeContextInjected = false
|
|
|
|
exec.messageMu.Lock()
|
|
exec.messageBuffer.Reset()
|
|
exec.thinkingBuffer.Reset()
|
|
exec.currentMessageID = ""
|
|
exec.currentThinkingID = ""
|
|
exec.messageMu.Unlock()
|
|
|
|
// Drain any stale prompt completion signal
|
|
select {
|
|
case <-exec.promptDoneCh:
|
|
default:
|
|
}
|
|
})
|
|
|
|
// Restore the user's session permission mode onto the fresh ACP session.
|
|
m.reapplySessionModeAfterReset(ctx, execution, newSessionID, prevMode)
|
|
|
|
m.logger.Info("agent context reset via session (no process restart)",
|
|
zap.String("execution_id", executionID),
|
|
zap.String("session_id", execution.SessionID),
|
|
zap.String("new_acp_session_id", newSessionID))
|
|
|
|
m.eventPublisher.PublishAgentEvent(ctx, events.AgentContextReset, execution)
|
|
// Boot-equivalent signal: a fresh ACP session is alive but no turn has run yet.
|
|
// Use AgentBootReady so the orchestrator routes this to handleAgentBootReady
|
|
// (idle/WAITING transition) rather than handleAgentReady (turn-end transition,
|
|
// which would fire on_turn_complete against the current step — the original
|
|
// boot-vs-turn ambiguity bug).
|
|
m.eventPublisher.PublishAgentEvent(ctx, events.AgentBootReady, execution)
|
|
return nil
|
|
}
|
|
|
|
// ErrNoExecutionForSession is returned when no live execution is tracked for a session,
|
|
// typically because the agent process has crashed, exited, or the session state is stuck
|
|
// (for example after a backend restart that did not re-register the execution).
|
|
//
|
|
// Callers should treat this as a "there is nothing to cancel/stop" signal and still reconcile
|
|
// the session's state in the database, otherwise the session will appear stuck as RUNNING
|
|
// with no agent to drive it.
|
|
var ErrNoExecutionForSession = errors.New("no execution for session")
|
|
|
|
// ErrCancelEscalated is returned by CancelAgent when the agent subprocess accepted
|
|
// the ACP cancel but did not publish a completion event before the timeout. The
|
|
// lifecycle manager has locally unblocked the in-flight SendPrompt and marked the
|
|
// execution ready, but the agent never acknowledged the cancel. Callers should
|
|
// still reconcile session-level state (e.g. transition the task session to
|
|
// WAITING_FOR_INPUT) — the user's intent was unambiguous.
|
|
var ErrCancelEscalated = errors.New("cancel escalated: agent did not acknowledge within timeout")
|
|
|
|
// CancelAgentBySessionID cancels the current agent turn for a specific session.
|
|
// Returns ErrNoExecutionForSession (wrapped) when no execution is tracked for the session.
|
|
//
|
|
// Passthrough sessions don't speak ACP — Ctrl-C (0x03) written to PTY stdin is
|
|
// the only stop signal a TUI CLI understands. A write failure is non-fatal:
|
|
// the caller still reconciles DB state so the UI unsticks even if the PTY is
|
|
// already gone.
|
|
func (m *Manager) CancelAgentBySessionID(ctx context.Context, sessionID string) error {
|
|
execution, exists := m.executionStore.GetBySessionID(sessionID)
|
|
if !exists {
|
|
return fmt.Errorf("session %q: %w", sessionID, ErrNoExecutionForSession)
|
|
}
|
|
|
|
if execution.PassthroughProcessID != "" {
|
|
if err := m.WritePassthroughStdin(ctx, sessionID, "\x03"); err != nil {
|
|
m.logger.Warn("failed to write Ctrl-C to passthrough stdin",
|
|
zap.String("session_id", sessionID),
|
|
zap.Error(err))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
return m.CancelAgent(ctx, execution.ID)
|
|
}
|
|
|
|
// StopAgent stops an agent execution
|
|
func (m *Manager) StopAgent(ctx context.Context, executionID string, force bool) error {
|
|
return m.StopAgentWithReason(ctx, executionID, "", force)
|
|
}
|
|
|
|
// StopAgentWithReason stops an agent execution and passes a semantic reason to runtime teardown.
|
|
func (m *Manager) StopAgentWithReason(ctx context.Context, executionID string, reason string, force bool) error {
|
|
execution, exists := m.executionStore.Get(executionID)
|
|
if !exists {
|
|
return fmt.Errorf("execution %q not found", executionID)
|
|
}
|
|
|
|
m.logger.Info("stopping agent",
|
|
zap.String("execution_id", executionID),
|
|
zap.String("reason", reason),
|
|
zap.Bool("force", force),
|
|
zap.Stringer("runtime", execution.RuntimeName))
|
|
|
|
// Try to gracefully stop via agentctl first, then always close connections
|
|
agentStopFailed := false
|
|
if execution.agentctl != nil {
|
|
if !force {
|
|
if err := execution.agentctl.Stop(ctx); err != nil {
|
|
agentStopFailed = true
|
|
// During shutdown the instance may already be stopping through
|
|
// another lifecycle path, so a failed HTTP call is expected.
|
|
if m.IsShuttingDown() {
|
|
m.logger.Debug("failed to stop agent via agentctl",
|
|
zap.String("execution_id", executionID),
|
|
zap.Error(err))
|
|
} else {
|
|
m.logger.Warn("failed to stop agent via agentctl",
|
|
zap.String("execution_id", executionID),
|
|
zap.Error(err))
|
|
}
|
|
}
|
|
}
|
|
execution.agentctl.Close()
|
|
}
|
|
|
|
// Stop the agent execution via the runtime that created it
|
|
m.stopAgentViaBackend(ctx, executionID, execution, reason, force, agentStopFailed)
|
|
|
|
// Update execution status and remove from tracking
|
|
_ = m.executionStore.WithLock(executionID, func(exec *AgentExecution) {
|
|
exec.Status = v1.AgentStatusStopped
|
|
now := time.Now()
|
|
exec.FinishedAt = &now
|
|
})
|
|
|
|
// End session trace span
|
|
execution.EndSessionSpan()
|
|
|
|
m.RemoveExecution(executionID)
|
|
m.clearRemoteStatus(execution.SessionID)
|
|
|
|
m.logger.Info("agent stopped and removed from tracking",
|
|
zap.String("execution_id", executionID),
|
|
zap.String("task_id", execution.TaskID))
|
|
|
|
// Publish stopped event
|
|
m.eventPublisher.PublishAgentEvent(ctx, events.AgentStopped, execution)
|
|
|
|
return nil
|
|
}
|
|
|
|
// StopBySessionID stops the agent for a specific session
|
|
func (m *Manager) StopBySessionID(ctx context.Context, sessionID string, force bool) error {
|
|
execution, exists := m.executionStore.GetBySessionID(sessionID)
|
|
if !exists {
|
|
return fmt.Errorf("no agent running for session %q", sessionID)
|
|
}
|
|
|
|
return m.StopAgent(ctx, execution.ID, force)
|
|
}
|
|
|
|
// RestartAgentProcess stops the agent subprocess and starts a fresh one, clearing the agent's
|
|
// conversation context. For ACP agents this restarts via agentctl with a new ACP session.
|
|
// For passthrough (TUI) agents this kills the PTY process and relaunches without --resume.
|
|
func (m *Manager) RestartAgentProcess(ctx context.Context, executionID string) error {
|
|
execution, exists := m.executionStore.Get(executionID)
|
|
if !exists {
|
|
return fmt.Errorf("execution %q not found: %w", executionID, ErrExecutionNotFound)
|
|
}
|
|
|
|
// Passthrough agents: kill PTY and relaunch fresh (no --resume).
|
|
if execution.PassthroughProcessID != "" {
|
|
return m.restartPassthroughProcess(ctx, execution)
|
|
}
|
|
|
|
if execution.agentctl == nil {
|
|
return fmt.Errorf("execution %q has no agentctl client", executionID)
|
|
}
|
|
|
|
m.logger.Info("restarting agent process for context reset",
|
|
zap.String("execution_id", executionID),
|
|
zap.String("task_id", execution.TaskID),
|
|
zap.String("session_id", execution.SessionID))
|
|
|
|
// Capture the active session mode before the restart so it can be re-applied to
|
|
// the fresh ACP session (issue #1183). Capture now, before the streams reconnect
|
|
// and the restarted agent reports its default mode (which would overwrite the cache).
|
|
prevMode := execution.GetModeState()
|
|
|
|
// Resolve agent config early — needed for both command rebuild and ACP session init
|
|
agentConfig, err := m.getAgentConfigForExecution(execution)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get agent config for restart: %w", err)
|
|
}
|
|
|
|
// 1. Close WebSocket streams (updates + workspace). Use per-stream Close
|
|
// methods rather than client.Close — the latter is a terminal drain
|
|
// barrier that flips the client into a closed state and would block
|
|
// every StreamUpdates/StreamWorkspace call that this same restart path
|
|
// makes a few lines below.
|
|
execution.agentctl.CloseUpdatesStream()
|
|
execution.agentctl.CloseWorkspaceStream()
|
|
|
|
// 2. Stop the agent subprocess via agentctl (keeps agentctl server alive)
|
|
if err := execution.agentctl.Stop(ctx); err != nil {
|
|
m.logger.Warn("failed to stop agent subprocess during restart",
|
|
zap.String("execution_id", executionID),
|
|
zap.Error(err))
|
|
// Continue — the process may already be stopped
|
|
}
|
|
|
|
// 3. Rebuild agent command without resume flags and reset execution state
|
|
freshCmd, freshContinueCmd := m.buildFreshAgentCommand(ctx, execution, agentConfig)
|
|
_ = m.executionStore.WithLock(executionID, func(exec *AgentExecution) {
|
|
exec.ACPSessionID = ""
|
|
exec.Status = v1.AgentStatusStarting
|
|
exec.ErrorMessage = ""
|
|
exec.needsResumeContext = false
|
|
exec.resumeContextInjected = false
|
|
exec.sessionInitialized = false
|
|
exec.AgentCommand = freshCmd
|
|
exec.ContinueCommand = freshContinueCmd
|
|
|
|
exec.messageMu.Lock()
|
|
exec.messageBuffer.Reset()
|
|
exec.thinkingBuffer.Reset()
|
|
exec.currentMessageID = ""
|
|
exec.currentThinkingID = ""
|
|
exec.messageMu.Unlock()
|
|
|
|
// Drain any stale prompt completion signal
|
|
select {
|
|
case <-exec.promptDoneCh:
|
|
default:
|
|
}
|
|
})
|
|
|
|
// 4. Wait for agentctl to be ready (it should still be running)
|
|
if err := execution.agentctl.WaitForReady(ctx, 30*time.Second); err != nil {
|
|
m.updateExecutionError(executionID, "agentctl not ready after restart: "+err.Error())
|
|
return fmt.Errorf("agentctl not ready after restart: %w", err)
|
|
}
|
|
|
|
// 5. Reconfigure and start new agent subprocess
|
|
approvalPolicy, _ := m.resolveApprovalPolicyAndDisplayName(ctx, execution)
|
|
if _, err := m.configureAndStartAgent(ctx, execution, approvalPolicy); err != nil {
|
|
m.updateExecutionError(executionID, "failed to restart agent: "+err.Error())
|
|
return fmt.Errorf("failed to restart agent: %w", err)
|
|
}
|
|
|
|
// 6. Wait for agent process to initialize
|
|
if err := execution.agentctl.WaitForReady(ctx, 10*time.Second); err != nil {
|
|
m.logger.Warn("agent process slow to initialize after restart, continuing",
|
|
zap.String("execution_id", executionID),
|
|
zap.Error(err))
|
|
}
|
|
|
|
mcpServers, err := m.resolveMcpServers(ctx, execution, agentConfig)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to resolve MCP config for restart: %w", err)
|
|
}
|
|
|
|
if err := m.initializeACPSessionForRestart(ctx, execution, agentConfig, mcpServers); err != nil {
|
|
m.updateExecutionError(executionID, "failed to initialize ACP session after restart: "+err.Error())
|
|
return fmt.Errorf("failed to initialize ACP session after restart: %w", err)
|
|
}
|
|
|
|
// Restore the user's session permission mode onto the fresh ACP session.
|
|
m.reapplySessionModeAfterReset(ctx, execution, execution.ACPSessionID, prevMode)
|
|
|
|
m.logger.Info("agent process restarted with fresh context",
|
|
zap.String("execution_id", executionID),
|
|
zap.String("session_id", execution.SessionID),
|
|
zap.String("new_acp_session_id", execution.ACPSessionID))
|
|
|
|
m.eventPublisher.PublishAgentEvent(ctx, events.AgentContextReset, execution)
|
|
return nil
|
|
}
|
|
|
|
// initializeACPSessionForRestart connects streams and creates a new ACP session without
|
|
// sending an initial prompt. The caller (workflow processOnEnter) handles prompting separately.
|
|
func (m *Manager) initializeACPSessionForRestart(
|
|
ctx context.Context,
|
|
execution *AgentExecution,
|
|
agentConfig agents.Agent,
|
|
mcpServers []agentctltypes.McpServer,
|
|
) error {
|
|
// Connect WebSocket streams
|
|
if m.streamManager != nil {
|
|
updatesReady := make(chan struct{})
|
|
m.streamManager.ConnectAll(execution, updatesReady)
|
|
|
|
select {
|
|
case <-updatesReady:
|
|
m.logger.Debug("updates stream ready after restart")
|
|
case <-time.After(10 * time.Second):
|
|
return fmt.Errorf("timeout waiting for agent stream to connect after restart")
|
|
}
|
|
}
|
|
|
|
// Initialize ACP session (always session/new since ACPSessionID was cleared)
|
|
result, err := m.sessionManager.InitializeSession(
|
|
ctx,
|
|
execution.agentctl,
|
|
agentConfig,
|
|
"", // empty — force session/new
|
|
execution.WorkspacePath,
|
|
mcpServers,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("ACP session initialization failed: %w", err)
|
|
}
|
|
|
|
execution.ACPSessionID = result.SessionID
|
|
|
|
if m.sessionManager.eventPublisher != nil {
|
|
m.sessionManager.eventPublisher.PublishACPSessionCreated(execution, result.SessionID)
|
|
}
|
|
|
|
// Mark execution as ready. This is a *boot* signal — initializeACPSessionForRestart
|
|
// is the post-restart init path and no turn has run yet, so AgentBootReady (not
|
|
// AgentReady) is what subscribers want to route on.
|
|
m.executionStore.UpdateStatus(execution.ID, v1.AgentStatusReady)
|
|
m.eventPublisher.PublishAgentEvent(ctx, events.AgentBootReady, execution)
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetExecution returns an agent execution by ID.
|
|
//
|
|
// Returns (execution, true) if found, or (nil, false) if not found.
|
|
// The returned execution pointer should not be modified directly - use the Manager's
|
|
// methods to update execution state (MarkReady, MarkCompleted, UpdateStatus).
|
|
//
|
|
// Thread-safe: Can be called concurrently from multiple goroutines.
|
|
func (m *Manager) GetExecution(executionID string) (*AgentExecution, bool) {
|
|
return m.executionStore.Get(executionID)
|
|
}
|
|
|
|
// GetExecutionBySessionID returns the agent execution for a session from the in-memory store only.
|
|
//
|
|
// Returns (execution, true) if found, or (nil, false) if not found.
|
|
// A session can have at most one active execution at a time. If a session exists
|
|
// but has no active execution, this returns (nil, false).
|
|
//
|
|
// For workspace-oriented operations that need restart recovery,
|
|
// use GetOrEnsureExecution instead.
|
|
//
|
|
// Thread-safe: Can be called concurrently from multiple goroutines.
|
|
func (m *Manager) GetExecutionBySessionID(sessionID string) (*AgentExecution, bool) {
|
|
return m.executionStore.GetBySessionID(sessionID)
|
|
}
|
|
|
|
// ResolveTaskEnvironmentID returns the task environment ID for a session.
|
|
// User shell resources must be environment-scoped; missing mappings are
|
|
// lifecycle errors and must not be converted into session-scoped shell state.
|
|
func (m *Manager) ResolveTaskEnvironmentID(ctx context.Context, sessionID string) (string, error) {
|
|
if sessionID == "" {
|
|
return "", fmt.Errorf("session_id is required")
|
|
}
|
|
if exec, ok := m.executionStore.GetBySessionID(sessionID); ok {
|
|
if exec.TaskEnvironmentID != "" {
|
|
return exec.TaskEnvironmentID, nil
|
|
}
|
|
return "", fmt.Errorf("session %s has no task environment ID", sessionID)
|
|
}
|
|
if m.workspaceInfoProvider == nil {
|
|
return "", fmt.Errorf("workspace info provider not configured")
|
|
}
|
|
info, err := m.workspaceInfoProvider.GetWorkspaceInfoForSession(ctx, "", sessionID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to resolve task environment for session %s: %w", sessionID, err)
|
|
}
|
|
if info == nil || info.TaskEnvironmentID == "" {
|
|
return "", fmt.Errorf("session %s has no task environment ID", sessionID)
|
|
}
|
|
return info.TaskEnvironmentID, nil
|
|
}
|
|
|
|
// IsRemoteSession checks whether a session is associated with a remote executor
|
|
// (e.g., sprites). It first checks the in-memory execution store, then falls back
|
|
// to the database via WorkspaceInfoProvider. This is useful when the execution
|
|
// hasn't been recreated yet after a backend restart.
|
|
func (m *Manager) IsRemoteSession(ctx context.Context, sessionID string) bool {
|
|
// Check in-memory execution first (fast path).
|
|
if execution, exists := m.executionStore.GetBySessionID(sessionID); exists {
|
|
if execution.RuntimeName == executor.NameSprites {
|
|
return true
|
|
}
|
|
if execution.Metadata != nil {
|
|
if isRemote, ok := execution.Metadata[MetadataKeyIsRemote].(bool); ok && isRemote {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Fall back to database records (post-restart, execution not yet recreated).
|
|
if m.workspaceInfoProvider == nil {
|
|
return false
|
|
}
|
|
info, err := m.workspaceInfoProvider.GetWorkspaceInfoForSession(ctx, "", sessionID)
|
|
if err != nil || info == nil {
|
|
return false
|
|
}
|
|
if models.IsRemoteExecutorType(models.ExecutorType(info.ExecutorType)) {
|
|
return true
|
|
}
|
|
// Backwards compatibility: old records may only have RuntimeName set.
|
|
return info.RuntimeName == executor.NameSprites || info.RuntimeName == executor.NameRemoteDocker
|
|
}
|
|
|
|
// ShouldUseContainerShell checks whether a session's shell should run inside a container/sandbox
|
|
// (via agentctl) rather than on the host. This is true for Docker, Sprites, and remote executors.
|
|
// It first checks the in-memory execution store, then falls back to the database.
|
|
func (m *Manager) ShouldUseContainerShell(ctx context.Context, sessionID string) bool {
|
|
// Check in-memory execution first (fast path).
|
|
if execution, exists := m.executionStore.GetBySessionID(sessionID); exists {
|
|
// Docker and Sprites executors run shells inside the container/sandbox
|
|
if execution.RuntimeName == executor.NameDocker ||
|
|
execution.RuntimeName == executor.NameSprites {
|
|
return true
|
|
}
|
|
if execution.Metadata != nil {
|
|
if isRemote, ok := execution.Metadata[MetadataKeyIsRemote].(bool); ok && isRemote {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Fall back to database records (post-restart, execution not yet recreated).
|
|
if m.workspaceInfoProvider == nil {
|
|
return false
|
|
}
|
|
info, err := m.workspaceInfoProvider.GetWorkspaceInfoForSession(ctx, "", sessionID)
|
|
if err != nil || info == nil {
|
|
return false
|
|
}
|
|
if models.IsContainerizedExecutorType(models.ExecutorType(info.ExecutorType)) {
|
|
return true
|
|
}
|
|
// Backwards compatibility: old records may only have RuntimeName set.
|
|
return info.RuntimeName == executor.NameDocker ||
|
|
info.RuntimeName == executor.NameSprites ||
|
|
info.RuntimeName == executor.NameRemoteDocker
|
|
}
|
|
|
|
// GetAvailableCommandsForSession returns the available slash commands for a session.
|
|
// Returns nil if the session doesn't exist or has no commands stored.
|
|
//
|
|
// Thread-safe: Can be called concurrently from multiple goroutines.
|
|
func (m *Manager) GetAvailableCommandsForSession(sessionID string) []streams.AvailableCommand {
|
|
execution, exists := m.executionStore.GetBySessionID(sessionID)
|
|
if !exists {
|
|
return nil
|
|
}
|
|
return execution.GetAvailableCommands()
|
|
}
|
|
|
|
// GetModeStateForSession returns the cached session mode state.
|
|
// Returns nil if the session has no execution or no mode state cached.
|
|
func (m *Manager) GetModeStateForSession(sessionID string) *CachedModeState {
|
|
execution, exists := m.executionStore.GetBySessionID(sessionID)
|
|
if !exists {
|
|
return nil
|
|
}
|
|
return execution.GetModeState()
|
|
}
|
|
|
|
// GetModelStateForSession returns the cached session model state.
|
|
// Returns nil if the session has no execution or no model state cached.
|
|
func (m *Manager) GetModelStateForSession(sessionID string) *CachedModelState {
|
|
execution, exists := m.executionStore.GetBySessionID(sessionID)
|
|
if !exists {
|
|
return nil
|
|
}
|
|
return execution.GetModelState()
|
|
}
|
|
|
|
// ListExecutions returns all currently tracked agent executions.
|
|
//
|
|
// Returns a snapshot of all executions in memory at the time of call. The returned slice
|
|
// contains pointers to execution objects that may be modified by other goroutines after
|
|
// this method returns. Do not modify execution state directly - use Manager methods instead.
|
|
//
|
|
// The list includes executions in all states:
|
|
// - Starting (process launching, agentctl initializing)
|
|
// - Running (actively processing prompts)
|
|
// - Ready (waiting for user input)
|
|
// - Completed/Failed (finished but not yet removed)
|
|
//
|
|
// Thread-safe: Can be called concurrently. Returns a new slice on each call.
|
|
//
|
|
// Typical usage: Status endpoints, debugging, cleanup loops.
|
|
func (m *Manager) ListExecutions() []*AgentExecution {
|
|
return m.executionStore.List()
|
|
}
|
|
|
|
// IsAgentRunningForSession checks if an agent process is running or starting for a session.
|
|
//
|
|
// For passthrough sessions (direct PTY mode), it checks whether the PTY process is alive
|
|
// in the InteractiveRunner. For ACP sessions, it probes agentctl's status endpoint.
|
|
//
|
|
// Returns true if:
|
|
// - Passthrough process is alive in the InteractiveRunner
|
|
// - Agent status is "running" (actively processing prompts)
|
|
// - Agent status is "starting" (process launched but not yet ready)
|
|
//
|
|
// Returns false if:
|
|
// - No execution exists for this session
|
|
// - Passthrough process ID is set but process is not alive
|
|
// - agentctl client is not available
|
|
// - Status check fails (network/timeout error)
|
|
// - Agent is in any other state (stopped, failed, etc.)
|
|
func (m *Manager) IsAgentRunningForSession(ctx context.Context, sessionID string) bool {
|
|
// First check if we have an execution tracked for this session
|
|
execution, exists := m.GetExecutionBySessionID(sessionID)
|
|
if !exists {
|
|
return false
|
|
}
|
|
|
|
// Passthrough sessions run as direct PTY processes via InteractiveRunner,
|
|
// bypassing agentctl's ACP protocol. Check the process directly.
|
|
if execution.PassthroughProcessID != "" {
|
|
if runner := m.GetInteractiveRunner(); runner != nil {
|
|
return runner.IsProcessReadyOrPending(execution.PassthroughProcessID)
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Probe agentctl status to verify the agent process is running
|
|
if execution.agentctl == nil {
|
|
return false
|
|
}
|
|
|
|
status, err := execution.agentctl.GetStatus(ctx)
|
|
if err != nil {
|
|
m.logger.Debug("failed to get agentctl status",
|
|
zap.String("session_id", sessionID),
|
|
zap.Error(err))
|
|
return false
|
|
}
|
|
|
|
return status.IsAgentRunning()
|
|
}
|
|
|
|
// IsAgentReadyForPrompt reports whether the session can accept an ACP prompt
|
|
// immediately. A process can be "running" while its update stream is still
|
|
// reconnecting after resume; PromptAgent needs the stream-backed request path.
|
|
func (m *Manager) IsAgentReadyForPrompt(ctx context.Context, sessionID string) bool {
|
|
execution, exists := m.GetExecutionBySessionID(sessionID)
|
|
if !exists {
|
|
return false
|
|
}
|
|
|
|
if execution.PassthroughProcessID != "" || execution.IsPassthrough {
|
|
return m.IsAgentRunningForSession(ctx, sessionID)
|
|
}
|
|
|
|
if execution.Status != v1.AgentStatusReady || execution.agentctl == nil {
|
|
return false
|
|
}
|
|
if !execution.sessionInitialized || execution.ACPSessionID == "" {
|
|
return false
|
|
}
|
|
|
|
return execution.agentctl.HasAgentStream()
|
|
}
|
|
|
|
// UpdateStatus updates the status of an execution
|
|
func (m *Manager) UpdateStatus(executionID string, status v1.AgentStatus) error {
|
|
if err := m.executionStore.WithLock(executionID, func(execution *AgentExecution) {
|
|
execution.Status = status
|
|
}); err != nil {
|
|
if errors.Is(err, ErrExecutionNotFound) {
|
|
return fmt.Errorf("execution %q not found", executionID)
|
|
}
|
|
return err
|
|
}
|
|
|
|
m.logger.Debug("updated execution status",
|
|
zap.String("execution_id", executionID),
|
|
zap.String("status", string(status)))
|
|
|
|
return nil
|
|
}
|
|
|
|
// MarkReady marks an execution as ready for follow-up prompts AFTER A TURN.
|
|
// Use MarkBootReady instead when the agent has just initialized and hasn't yet
|
|
// processed a turn — orchestrator subscribers rely on the distinction.
|
|
//
|
|
// This transitions the execution to the "ready" state, indicating the agent has finished
|
|
// processing the current prompt and is waiting for user input. Called when:
|
|
// - Agent finishes processing a prompt (via stream completion event)
|
|
// - User cancels an agent turn (to allow new prompts)
|
|
//
|
|
// State Machine Transitions:
|
|
//
|
|
// Running -> Ready (after prompt completion)
|
|
// Any -> Ready (after cancel)
|
|
//
|
|
// Publishes events.AgentReady. Returns error if execution not found.
|
|
func (m *Manager) MarkReady(executionID string) error {
|
|
return m.markReadyEvent(executionID, events.AgentReady)
|
|
}
|
|
|
|
// MarkBootReady marks a freshly-initialized execution as ready for its first
|
|
// prompt. Distinct from MarkReady so the orchestrator can disambiguate boot
|
|
// signals from turn-end signals without race-prone flag tracking. Use this
|
|
// from session/init paths and post-context-reset; use MarkReady from the
|
|
// turn-completion path.
|
|
//
|
|
// State Machine Transitions:
|
|
//
|
|
// Starting -> Ready (after initialization)
|
|
//
|
|
// Publishes events.AgentBootReady. Returns error if execution not found.
|
|
func (m *Manager) MarkBootReady(executionID string) error {
|
|
return m.markReadyEvent(executionID, events.AgentBootReady)
|
|
}
|
|
|
|
// markReadyEvent is the shared body of MarkReady / MarkBootReady — both flip
|
|
// the execution to the Ready status and publish their respective event type.
|
|
func (m *Manager) markReadyEvent(executionID, eventType string) error {
|
|
execution, exists := m.executionStore.Get(executionID)
|
|
if !exists {
|
|
return fmt.Errorf("execution %q not found", executionID)
|
|
}
|
|
|
|
// Skip if already ready (prevents duplicate events). This guard is shared
|
|
// across MarkReady and MarkBootReady — once Ready is set, neither type fires
|
|
// again until the next status transition (Running on prompt, Stopped on stop).
|
|
if execution.Status == v1.AgentStatusReady {
|
|
return nil
|
|
}
|
|
|
|
m.executionStore.UpdateStatus(executionID, v1.AgentStatusReady)
|
|
|
|
m.logger.Info("execution ready",
|
|
zap.String("execution_id", executionID),
|
|
zap.String("event_type", eventType))
|
|
|
|
m.eventPublisher.PublishAgentEvent(context.Background(), eventType, execution)
|
|
return nil
|
|
}
|
|
|
|
// MarkCompleted marks an execution as completed or failed.
|
|
//
|
|
// This is called when the agent process terminates, either successfully or with an error.
|
|
// The final status is determined by exit code and error message:
|
|
//
|
|
// - exitCode == 0 && errorMessage == "" → AgentStatusCompleted (success)
|
|
// - Otherwise → AgentStatusFailed (failure)
|
|
//
|
|
// Parameters:
|
|
// - executionID: The execution to mark as completed
|
|
// - exitCode: Process exit code (0 = success, non-zero = failure)
|
|
// - errorMessage: Human-readable error description (empty string if no error)
|
|
//
|
|
// State Machine:
|
|
//
|
|
// This is a terminal state transition - no further state changes are expected after this.
|
|
// Typical flow: Starting -> Running -> Ready -> ... -> Completed/Failed
|
|
//
|
|
// Publishes either AgentCompleted or AgentFailed event depending on final status.
|
|
//
|
|
// Returns error if execution not found.
|
|
func (m *Manager) MarkCompleted(executionID string, exitCode int, errorMessage string) error {
|
|
execution, exists := m.executionStore.Get(executionID)
|
|
if !exists {
|
|
return fmt.Errorf("execution %q not found", executionID)
|
|
}
|
|
|
|
// Guard against duplicate completion (e.g. ACP prompt error + process exit error).
|
|
// MarkCompleted is a terminal transition — once in Completed/Failed, skip re-publishing.
|
|
if execution.Status == v1.AgentStatusCompleted || execution.Status == v1.AgentStatusFailed {
|
|
m.logger.Warn("ignoring duplicate MarkCompleted for already-terminal execution",
|
|
zap.String("execution_id", executionID),
|
|
zap.String("current_status", string(execution.Status)),
|
|
zap.Int("exit_code", exitCode))
|
|
return nil
|
|
}
|
|
|
|
_ = m.executionStore.WithLock(executionID, func(exec *AgentExecution) {
|
|
now := time.Now()
|
|
exec.FinishedAt = &now
|
|
exec.ExitCode = &exitCode
|
|
exec.ErrorMessage = errorMessage
|
|
|
|
if exitCode == 0 && errorMessage == "" {
|
|
exec.Status = v1.AgentStatusCompleted
|
|
} else {
|
|
exec.Status = v1.AgentStatusFailed
|
|
}
|
|
})
|
|
|
|
// End session trace span
|
|
execution.EndSessionSpan()
|
|
|
|
m.logger.Info("execution completed",
|
|
zap.String("execution_id", executionID),
|
|
zap.Int("exit_code", exitCode),
|
|
zap.String("status", string(execution.Status)))
|
|
|
|
// Publish completion event
|
|
eventType := events.AgentCompleted
|
|
if execution.Status == v1.AgentStatusFailed {
|
|
eventType = events.AgentFailed
|
|
m.classifyAndMaybeRemediate(execution, exitCode, errorMessage)
|
|
}
|
|
m.eventPublisher.PublishAgentEvent(context.Background(), eventType, execution)
|
|
|
|
return nil
|
|
}
|
|
|
|
// classifyAndMaybeRemediate runs routingerr.Classify at the failure
|
|
// boundary, logs the structured result, and - for codes that carry a
|
|
// safe remediation (currently CodeNpxCacheCorrupted) - fires a
|
|
// best-effort cleanup goroutine so the next launch attempt (Office
|
|
// scheduler retry or Kanban "Resume") finds a clean environment.
|
|
//
|
|
// The remediation hook is injectable via Manager.remediateNpxCache;
|
|
// production wiring points it at routingerr.RemediateNpxCache. Tests
|
|
// stub it to avoid touching the real filesystem.
|
|
func (m *Manager) classifyAndMaybeRemediate(execution *AgentExecution, exitCode int, errorMessage string) {
|
|
phase := routingerr.PhaseSessionInit
|
|
if execution.sessionInitialized {
|
|
phase = routingerr.PhasePromptSend
|
|
}
|
|
var exitPtr *int
|
|
if exitCode != 0 {
|
|
ec := exitCode
|
|
exitPtr = &ec
|
|
}
|
|
e := routingerr.Classify(routingerr.Input{
|
|
Phase: phase,
|
|
ProviderID: execution.AgentID,
|
|
ExitCode: exitPtr,
|
|
Stderr: errorMessage,
|
|
})
|
|
if e == nil {
|
|
return
|
|
}
|
|
m.logger.Info("agent failure classified for provider routing",
|
|
zap.String("execution_id", execution.ID),
|
|
zap.String("task_id", execution.TaskID),
|
|
zap.String("session_id", execution.SessionID),
|
|
zap.String("provider_id", execution.AgentID),
|
|
zap.String("phase", string(e.Phase)),
|
|
zap.String("routing_code", string(e.Code)),
|
|
zap.String("confidence", string(e.Confidence)),
|
|
zap.String("classifier_rule", e.ClassifierRule),
|
|
zap.Bool("fallback_allowed", e.FallbackAllowed),
|
|
zap.Bool("auto_retryable", e.AutoRetryable),
|
|
zap.Bool("user_action", e.UserAction),
|
|
zap.String("remediation_path", e.RemediationPath))
|
|
|
|
if e.Code != routingerr.CodeNpxCacheCorrupted || e.RemediationPath == "" {
|
|
return
|
|
}
|
|
remediate := m.remediateNpxCache
|
|
path := e.RemediationPath
|
|
execID := execution.ID
|
|
zapLog := m.logger.Zap().With(
|
|
zap.String("execution_id", execID),
|
|
zap.String("path", path),
|
|
)
|
|
go func() {
|
|
if err := remediate(path, zapLog); err != nil {
|
|
m.logger.Warn("npx cache remediation failed",
|
|
zap.String("execution_id", execID),
|
|
zap.String("path", path),
|
|
zap.Error(err))
|
|
}
|
|
}()
|
|
}
|
|
|
|
// RespondToPermission sends a response to an agent's permission request.
|
|
//
|
|
// When an agent requests permission (e.g., to run a bash command, modify files, etc.),
|
|
// it pauses execution and waits for user approval. This method sends the user's response.
|
|
//
|
|
// Parameters:
|
|
// - executionID: The agent execution waiting for permission
|
|
// - pendingID: Unique ID of the permission request (from permission request event)
|
|
// - optionID: The user-selected option ID (from the permission request's options array)
|
|
// - cancelled: If true, indicates user cancelled/rejected the permission request.
|
|
// When cancelled=true, optionID is ignored.
|
|
//
|
|
// Response Semantics:
|
|
// - cancelled=false, optionID="approve" → User approved the action
|
|
// - cancelled=false, optionID="deny" → User explicitly denied the action
|
|
// - cancelled=true, optionID="" → User cancelled/closed the dialog
|
|
//
|
|
// After receiving the response, the agent will either:
|
|
// - Continue executing (if approved)
|
|
// - Skip the action and report failure (if denied/cancelled)
|
|
//
|
|
// Timeout: 30 seconds for agentctl to acknowledge the response.
|
|
//
|
|
// Returns error if execution not found, agentctl unavailable, or communication fails.
|
|
func (m *Manager) RespondToPermission(executionID, pendingID, optionID string, cancelled bool) error {
|
|
execution, exists := m.executionStore.Get(executionID)
|
|
if !exists {
|
|
return fmt.Errorf("agent execution not found: %s", executionID)
|
|
}
|
|
|
|
if execution.agentctl == nil {
|
|
return fmt.Errorf("agent execution has no agentctl client: %s", executionID)
|
|
}
|
|
|
|
m.logger.Info("responding to permission request",
|
|
zap.String("execution_id", executionID),
|
|
zap.String("pending_id", pendingID),
|
|
zap.String("option_id", optionID),
|
|
zap.Bool("cancelled", cancelled))
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
return execution.agentctl.RespondToPermission(ctx, pendingID, optionID, cancelled)
|
|
}
|
|
|
|
// RespondToPermissionBySessionID sends a response to a permission request using session ID.
|
|
//
|
|
// Convenience method that looks up the execution by session ID and delegates to RespondToPermission.
|
|
// See RespondToPermission for parameter semantics and behavior.
|
|
func (m *Manager) RespondToPermissionBySessionID(sessionID, pendingID, optionID string, cancelled bool) error {
|
|
execution, exists := m.executionStore.GetBySessionID(sessionID)
|
|
if !exists {
|
|
return fmt.Errorf("no agent execution found for session: %s", sessionID)
|
|
}
|
|
|
|
return m.RespondToPermission(execution.ID, pendingID, optionID, cancelled)
|
|
}
|
|
|
|
// stopAgentViaBackend stops the agent execution via the runtime that created it.
|
|
func (m *Manager) stopAgentViaBackend(ctx context.Context, executionID string, execution *AgentExecution, reason string, force bool, agentStopFailed bool) {
|
|
if execution.RuntimeName == "" || m.executorRegistry == nil {
|
|
return
|
|
}
|
|
rt, err := m.executorRegistry.GetBackend(execution.RuntimeName)
|
|
if err != nil {
|
|
m.logger.Warn("failed to get runtime for stopping execution",
|
|
zap.String("execution_id", executionID),
|
|
zap.Stringer("runtime", execution.RuntimeName),
|
|
zap.Error(err))
|
|
return
|
|
}
|
|
m.stopPassthroughProcess(ctx, executionID, execution, rt)
|
|
runtimeInstance := &ExecutorInstance{
|
|
InstanceID: execution.ID,
|
|
TaskID: execution.TaskID,
|
|
ContainerID: execution.ContainerID,
|
|
StandaloneInstanceID: execution.standaloneInstanceID,
|
|
StandalonePort: execution.standalonePort,
|
|
Metadata: execution.Metadata,
|
|
StopReason: reason,
|
|
AgentStopFailed: agentStopFailed,
|
|
}
|
|
if err := rt.StopInstance(ctx, runtimeInstance, force); err != nil {
|
|
// During shutdown the runtime instance may already be stopping or
|
|
// absent. Only surface this at WARN outside shutdown.
|
|
if m.IsShuttingDown() {
|
|
m.logger.Debug("failed to stop runtime instance, continuing with cleanup",
|
|
zap.String("execution_id", executionID),
|
|
zap.Error(err))
|
|
} else {
|
|
m.logger.Warn("failed to stop runtime instance, continuing with cleanup",
|
|
zap.String("execution_id", executionID),
|
|
zap.Error(err))
|
|
}
|
|
}
|
|
}
|
|
|
|
// stopPassthroughProcess stops the passthrough interactive process if one is running.
|
|
func (m *Manager) stopPassthroughProcess(ctx context.Context, executionID string, execution *AgentExecution, rt ExecutorBackend) {
|
|
if execution.PassthroughProcessID == "" {
|
|
return
|
|
}
|
|
interactiveRunner := rt.GetInteractiveRunner()
|
|
if interactiveRunner == nil {
|
|
return
|
|
}
|
|
if err := interactiveRunner.Stop(ctx, execution.PassthroughProcessID); err != nil {
|
|
m.logger.Warn("failed to stop passthrough process",
|
|
zap.String("execution_id", executionID),
|
|
zap.String("process_id", execution.PassthroughProcessID),
|
|
zap.Error(err))
|
|
return
|
|
}
|
|
m.logger.Info("passthrough process stopped",
|
|
zap.String("execution_id", executionID),
|
|
zap.String("process_id", execution.PassthroughProcessID))
|
|
}
|
|
|
|
// buildFreshAgentCommand rebuilds the agent command without resume flags by going through
|
|
// the standard BuildCommandString pipeline with an empty SessionID. This works for all
|
|
// agent types because each agent's BuildCommand respects SessionID="" to skip resume flags.
|
|
func (m *Manager) buildFreshAgentCommand(ctx context.Context, execution *AgentExecution, agentConfig agents.Agent) (initial, continueCmd string) {
|
|
var profileInfo *AgentProfileInfo
|
|
if execution.AgentProfileID != "" && m.profileResolver != nil {
|
|
pi, err := m.profileResolver.ResolveProfile(ctx, execution.AgentProfileID)
|
|
if err == nil {
|
|
profileInfo = pi
|
|
}
|
|
}
|
|
|
|
model := ""
|
|
autoApprove := false
|
|
permissionValues := make(map[string]bool)
|
|
if profileInfo != nil {
|
|
model = profileInfo.Model
|
|
autoApprove = profileInfo.AutoApprove
|
|
permissionValues[agents.PermissionKeyAutoApprove] = profileInfo.AutoApprove
|
|
permissionValues["allow_indexing"] = profileInfo.AllowIndexing
|
|
permissionValues["dangerously_skip_permissions"] = profileInfo.DangerouslySkipPermissions
|
|
}
|
|
if override, ok := execution.Metadata[MetadataKeyModelOverride].(string); ok && override != "" {
|
|
model = override
|
|
}
|
|
|
|
opts := agents.CommandOptions{
|
|
Model: model,
|
|
SessionID: "", // Fresh start — no resume flags
|
|
AutoApprove: autoApprove,
|
|
PermissionValues: permissionValues,
|
|
// Runtime is "standalone" / "docker" / "sprites" — MockAgent
|
|
// reads this to pick a bare name (container PATH lookup) vs.
|
|
// an absolute host path.
|
|
Runtime: execution.RuntimeName,
|
|
}
|
|
return m.commandBuilder.BuildCommandString(agentConfig, opts),
|
|
m.commandBuilder.BuildContinueCommandString(agentConfig, opts)
|
|
}
|