Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions apps/backend/internal/agent/runtime/lifecycle/manager_execution.go
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,24 @@ func (m *Manager) GetExecutionIDForSession(_ context.Context, sessionID string)
return "", fmt.Errorf("%w: %s", ErrNoExecutionForSession, sessionID)
}

// GetACPSessionIDForSession returns the ACP conversation currently owned by a
// live execution. The orchestrator uses this optional accessor after a context
// reset to persist the new conversation immediately, instead of depending on
// an asynchronous session-created event arriving before a backend restart.
func (m *Manager) GetACPSessionIDForSession(sessionID string) (string, bool) {
execution, exists := m.executionStore.GetBySessionID(sessionID)
if !exists || execution == nil {
return "", false
}
var acpSessionID string
if err := m.executionStore.WithRLock(execution.ID, func(exec *AgentExecution) {
acpSessionID = exec.ACPSessionID
}); err != nil || acpSessionID == "" {
return "", false
}
return acpSessionID, true
}

// IsAgentCommandConfigured reports whether an execution has been promoted from
// workspace-only infrastructure to an agent execution ready to start.
func (m *Manager) IsAgentCommandConfigured(executionID string) bool {
Expand Down
33 changes: 33 additions & 0 deletions apps/backend/internal/orchestrator/event_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,22 @@ func (s *Service) handleACPSessionCreated(ctx context.Context, data watcher.ACPS
// session/load vs session/new in session.go — agents without native resume (e.g.,
// Claude Code) use the token for their own --resume CLI flag instead.
func (s *Service) storeResumeToken(ctx context.Context, taskID, sessionID, expectedExecID, acpSessionID, lastMessageUUID string) {
// The lifecycle manager updates its in-memory ACP session ID before it
// publishes reset/start events. Events from the previous ACP session can
// still be queued after that point, so reject those events before the
// execution-level CAS. The execution ID alone does not identify an ACP
// session generation because context resets keep the same execution.
if currentACPSessionID := s.currentACPSessionID(sessionID); currentACPSessionID != "" &&
acpSessionID != "" && acpSessionID != currentACPSessionID {
s.logger.Info("dropping resume token from stale ACP session generation",
zap.String("task_id", taskID),
zap.String("session_id", sessionID),
zap.String("expected_exec_id", expectedExecID),
zap.String("resume_token", acpSessionID),
zap.String("current_resume_token", currentACPSessionID))
return
}

err := s.repo.UpdateResumeToken(ctx, sessionID, expectedExecID, acpSessionID, lastMessageUUID)
switch {
case err == nil:
Expand Down Expand Up @@ -104,6 +120,23 @@ func (s *Service) storeResumeToken(ctx context.Context, taskID, sessionID, expec
}
}

// currentACPSessionID returns the lifecycle manager's current ACP session ID
// when the concrete manager exposes it. The optional seam keeps the generic
// AgentManagerClient contract unchanged for remote clients and test doubles.
func (s *Service) currentACPSessionID(sessionID string) string {
provider, ok := s.agentManager.(interface {
GetACPSessionIDForSession(string) (string, bool)
})
if !ok {
return ""
}
acpSessionID, ok := provider.GetACPSessionIDForSession(sessionID)
if !ok {
return ""
}
return acpSessionID
}

// persistACPSessionID mirrors the agent's ACP session id into the session's
// "acp" metadata map. Best-effort: resume correctness never depends on this
// copy — it exists so the id survives executors_running cleanup for consumers
Expand Down
14 changes: 9 additions & 5 deletions apps/backend/internal/orchestrator/event_handlers_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -1466,20 +1466,24 @@ func (s *Service) wasResumeAttempt(ctx context.Context, sessionID string) bool {
}

// clearResumeToken removes the resume token from the executor running record so
// the next agent start won't use --resume. It is reserved for explicit
// user-initiated fresh-start recovery; ordinary ACP startup failures retain the
// token so the session can be retried.
// the next agent start won't use --resume. Callers use this for explicit fresh
// starts and after a successful context reset; ordinary ACP startup failures
// retain the token so the session can be retried.
//
// Unconditional clear: passes expectedExecID="" so the narrow update is not
// CAS-guarded — clearing a token is always intentional regardless of which
// execution is currently registered.
func (s *Service) clearResumeToken(ctx context.Context, sessionID string) {
func (s *Service) clearResumeToken(ctx context.Context, sessionID string) error {
err := s.repo.UpdateResumeToken(ctx, sessionID, "", "", "")
if err != nil && !errors.Is(err, models.ErrExecutorRunningNotFound) {
if errors.Is(err, models.ErrExecutorRunningNotFound) {
return nil
}
if err != nil {
s.logger.Error("failed to clear resume token",
zap.String("session_id", sessionID),
zap.Error(err))
}
return err
}

// handleRecoverableFailure handles agent failures by keeping the session recoverable.
Expand Down
10 changes: 10 additions & 0 deletions apps/backend/internal/orchestrator/event_handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,8 @@ type mockAgentManager struct {
repoForExecutionLookup interface {
GetExecutorRunningBySessionID(ctx context.Context, sessionID string) (*models.ExecutorRunning, error)
}
// Optional current ACP session lookup used by reset-token generation tests.
getACPSessionIDForSessionFunc func(string) (string, bool)

// CancelAgent tracking. cancelAgentCalls counts every invocation. If
// cancelAgentBlock is non-nil, CancelAgent blocks on it before returning;
Expand Down Expand Up @@ -664,6 +666,14 @@ func (m *mockAgentManager) GetExecutionIDForSession(ctx context.Context, session
}
return "", fmt.Errorf("no execution found")
}

func (m *mockAgentManager) GetACPSessionIDForSession(sessionID string) (string, bool) {
if m.getACPSessionIDForSessionFunc == nil {
return "", false
}
return m.getACPSessionIDForSessionFunc(sessionID)
}

func (m *mockAgentManager) GetGitLog(ctx context.Context, sessionID, baseCommit string, limit int, targetBranch string) (*client.GitLogResult, error) {
if m.getGitLogFunc != nil {
return m.getGitLogFunc(ctx, sessionID, baseCommit, limit, targetBranch)
Expand Down
59 changes: 53 additions & 6 deletions apps/backend/internal/orchestrator/event_handlers_workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -2947,10 +2947,34 @@ func (s *Service) resetAgentContext(ctx context.Context, taskID string, session
return true
}

releaseLifecycleLock := s.acquireSessionLifecycleLock(sessionID)
defer releaseLifecycleLock()
s.setSessionResetInProgress(sessionID, true)
defer s.setSessionResetInProgress(sessionID, false)

executionID, err := s.agentManager.GetExecutionIDForSession(ctx, sessionID)
if err != nil || executionID == "" {
s.logger.Debug("no agent execution for context reset, skipping",
zap.String("session_id", sessionID))
// No in-memory execution exists yet — most commonly a lazily-resumed
// session whose process has not been relaunched since the last run.
// The resume path (applyRunningRecordToResumeRequest) reads the ACP
// resume token straight from the executors_running row, bypassing any
// in-memory execution lookup entirely, so leaving that token in place
// here would let the next lazy launch reconnect to the pre-reset
// conversation and silently skip the reset. Clear the same persisted
// state the live-execution path clears below so the reset survives
// until the agent's first turn regardless of when the process starts.
s.logger.Debug("no live agent execution for context reset, clearing persisted resume state",
zap.String("session_id", sessionID),
zap.String("step_name", stepName))
if err := s.clearResumeToken(ctx, sessionID); err != nil {
s.logger.Error("failed to clear lazy resume token before context reset",
zap.String("task_id", taskID),
zap.String("session_id", sessionID),
zap.String("step_name", stepName),
zap.Error(err))
return false
}
s.clearPersistedResetState(ctx, sessionID, session)
return true
}

Expand All @@ -2960,9 +2984,6 @@ func (s *Service) resetAgentContext(ctx context.Context, taskID string, session
zap.String("step_name", stepName),
zap.String("agent_execution_id", executionID))

s.setSessionResetInProgress(sessionID, true)
defer s.setSessionResetInProgress(sessionID, false)

if err := s.agentManager.ResetAgentContext(ctx, executionID); err != nil {
s.logger.Error("failed to reset agent context",
zap.String("task_id", taskID),
Expand All @@ -2972,6 +2993,33 @@ func (s *Service) resetAgentContext(ctx context.Context, taskID string, session
return false
}

// Clear the old resume token only after the provider reset succeeds. This
// keeps a valid recovery token when the runtime reset fails. A fresh ACP
// session event can race this clear, so persist the lifecycle manager's
// current session ID again below after the clear.
if err := s.clearResumeToken(ctx, sessionID); err != nil {
s.logger.Error("failed to clear resume token after context reset",
zap.String("task_id", taskID),
zap.String("session_id", sessionID),
zap.String("step_name", stepName),
zap.Error(err))
return false
}
if acpSessionID := s.currentACPSessionID(sessionID); acpSessionID != "" {
s.storeResumeToken(ctx, taskID, sessionID, executionID, acpSessionID, "")
}

// Clear the remaining persisted state (ACP session metadata, context window)
// after the provider reset succeeds. The token is handled explicitly above.
s.clearPersistedResetState(ctx, sessionID, session)
return true
}

// clearPersistedResetState clears the durable, DB-backed session state that a
// later lazy resume would otherwise pick back up: the stored ACP session ID
// in session metadata and the persisted context window. The resume token is
// cleared explicitly by resetAgentContext so a reset failure can retain it.
func (s *Service) clearPersistedResetState(ctx context.Context, sessionID string, session *models.TaskSession) {
// Clear the stored ACP session ID using json_set to avoid clobbering other keys.
if updateErr := s.repo.SetSessionMetadataKey(ctx, sessionID, "acp_session_id", ""); updateErr != nil {
s.logger.Warn("failed to clear ACP session ID from session metadata",
Expand All @@ -2988,7 +3036,6 @@ func (s *Service) resetAgentContext(ctx context.Context, taskID string, session
// its cache after the provider reset succeeds and must not receive stale data
// back through the final processOnEnter state event.
clearInMemoryContextWindow(session)
return true
}

// resolveSessionMCPSupport checks if the agent for a session supports MCP.
Expand Down
Loading
Loading