Files
kandev/apps/backend/internal/orchestrator/event_handlers_agent.go
T
2026-08-24 11:09:03 +00:00

2291 lines
92 KiB
Go

package orchestrator
import (
"context"
"errors"
"fmt"
"time"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
"github.com/kandev/kandev/internal/agent/runtime/routingerr"
"github.com/kandev/kandev/internal/agentctl/types/streams"
"github.com/kandev/kandev/internal/entityrefs"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/orchestrator/messagequeue"
"github.com/kandev/kandev/internal/orchestrator/watcher"
"github.com/kandev/kandev/internal/task/models"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
const queueStatusMaxField = "max"
type reservedPromptCallbackContextKey struct{}
// agentReadyDetachedContext ignores transient event-delivery cancellation but
// preserves shutdown cancellation for service-owned deferred callbacks.
func agentReadyDetachedContext(ctx context.Context) context.Context {
if owned, _ := ctx.Value(reservedPromptCallbackContextKey{}).(bool); owned {
return ctx
}
return context.WithoutCancel(ctx)
}
func reservedPromptCallbackContext(
ownerCtx context.Context,
retryCtx context.Context,
) (context.Context, context.CancelFunc) {
callbackCtx, cancel := context.WithCancel(retryCtx)
stopOwnerCancellation := context.AfterFunc(ownerCtx, cancel)
callbackCtx = context.WithValue(callbackCtx, reservedPromptCallbackContextKey{}, true)
return callbackCtx, func() {
stopOwnerCancellation()
cancel()
}
}
// handleAgentRunning handles agent running events (user sent input in passthrough mode)
// This is called when the user sends input to the agent, indicating a new turn started.
func (s *Service) handleAgentRunning(ctx context.Context, data watcher.AgentEventData) {
if data.SessionID == "" {
s.logger.Warn("missing session_id for agent running event",
zap.String("task_id", data.TaskID))
return
}
// agent.running fires whenever the agent process starts running — including
// the boot of a silent resume after a backend restart (session/new fallback
// for agents without native resume, or a session/load reconnect), where no
// turn is actually in flight. ACP sessions drive RUNNING from the
// prompt-dispatch path (PromptTask / dispatchPromptAsync) and stream
// tool/message events, so reacting to the boot signal here would only flicker
// a settled WAITING_FOR_INPUT task into the Running bucket during resume.
// Passthrough sessions have no PromptTask, so agent.running IS their
// turn-start signal: handle on_turn_start and move the session to RUNNING.
if !s.agentManager.IsPassthroughSession(ctx, data.SessionID) {
return
}
lock, release := s.acquireCancelInFlightGuard(data.SessionID)
defer release()
lock.Lock()
defer lock.Unlock()
if err := s.waitForCancellationWithGuard(ctx, data.SessionID, lock.Unlock, lock.Lock); err != nil {
s.logger.Debug("ignoring agent running event after cancellation wait was interrupted",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.Error(err))
return
}
session, err := s.repo.GetTaskSession(ctx, data.SessionID)
if err != nil {
s.logger.Warn("failed to load session for agent running",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.Error(err))
return
}
if isTerminalSessionState(session.State) {
s.logger.Debug("ignoring agent running event for terminal session",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.String("session_state", string(session.State)))
return
}
s.processOnTurnStartViaEngine(ctx, data.TaskID, session)
// Move session to running and task to in progress.
s.setSessionRunning(ctx, data.TaskID, data.SessionID, session)
}
// publishQueueStatusEvent publishes a queue status changed event for the given session
func (s *Service) publishQueueStatusEvent(ctx context.Context, sessionID string) {
if s.eventBus == nil || s.messageQueue == nil {
return
}
queueStatus := s.messageQueue.GetStatus(ctx, sessionID)
eventData := map[string]interface{}{
metaKeySessionID: sessionID,
"entries": queueStatus.Entries,
"count": queueStatus.Count,
queueStatusMaxField: queueStatus.Max,
"auto_run": queueStatus.AutoRun,
"merge_enabled": queueStatus.MergeEnabled,
}
if taskID, err := s.SessionTaskID(ctx, sessionID); err != nil {
s.logger.Warn("resolve session task for queue status event",
zap.String("session_id", sessionID),
zap.Error(err))
} else if taskID != "" {
eventData["task_id"] = taskID
}
s.logger.Debug("publishing queue status changed event",
zap.String("session_id", sessionID),
zap.Int("count", queueStatus.Count))
_ = s.eventBus.Publish(ctx, events.MessageQueueStatusChanged, bus.NewEvent(
events.MessageQueueStatusChanged,
"orchestrator",
eventData,
))
}
// publishTaskQueueStatusEvent publishes a task-scoped queue status change so
// the status-summary projector can recompute queued_prompt_count. Used after
// lifecycle purges (archive/delete) and session delete when a single session
// snapshot is unavailable or insufficient. Payload requires task_id; session
// fields are optional.
func (s *Service) publishTaskQueueStatusEvent(ctx context.Context, taskID, sessionID string) {
if s.eventBus == nil || taskID == "" {
return
}
// Lifecycle purge notifies after Archive/Delete commit on the request ctx.
// Detach so a client disconnect cannot cancel projector recount (which would
// leave queued_prompt_count stale on every live sidebar).
ctx = context.WithoutCancel(ctx)
eventData := map[string]interface{}{
"task_id": taskID,
}
if sessionID != "" && s.messageQueue != nil {
queueStatus := s.messageQueue.GetStatus(ctx, sessionID)
eventData[metaKeySessionID] = sessionID
eventData["entries"] = queueStatus.Entries
eventData["count"] = queueStatus.Count
eventData[queueStatusMaxField] = queueStatus.Max
eventData["auto_run"] = queueStatus.AutoRun
eventData["merge_enabled"] = queueStatus.MergeEnabled
}
s.logger.Debug("publishing task queue status changed event",
zap.String("task_id", taskID),
zap.String("session_id", sessionID))
_ = s.eventBus.Publish(ctx, events.MessageQueueStatusChanged, bus.NewEvent(
events.MessageQueueStatusChanged,
"orchestrator",
eventData,
))
}
// requeueMessage re-enqueues a message that could not be delivered, publishing a queue status event on success.
// Preserves the original Metadata (e.g. sender_task_id from message_task_kandev)
// so attribution survives transient failures + retries.
//
// User messages go through RequeueAtHead (FIFO-preserving) so a
// supersede→requeue cycle does not strand the original entry behind any
// new arrival — the busy-session starvation bug fix. Lifecycle messages
// keep their tail-requeue semantics (requeueLifecycleMessage) because
// they are gated by a generation token that already gives them
// priority; mixing the two paths would risk merging durable
// reservations with transient user-state.
func (s *Service) requeueMessage(ctx context.Context, queuedMsg *messagequeue.QueuedMessage, queuedBy string) {
coalesceKey := messageCoalesceKey(queuedMsg)
if queuedMsg.QueuedBy != "" && coalesceKey != "" {
queuedBy = queuedMsg.QueuedBy
}
if isLifecycleAutomationOrigin(queuedMsg.Metadata["origin"]) {
s.requeueLifecycleMessage(ctx, queuedMsg, queuedBy, coalesceKey)
return
}
if err := s.messageQueue.RequeueAtHead(ctx, queuedMsg); err != nil {
s.logger.Error("failed to requeue message at head",
zap.String("session_id", queuedMsg.SessionID),
zap.String("task_id", queuedMsg.TaskID),
zap.String("queue_id", queuedMsg.ID),
zap.String("queued_by", queuedBy),
zap.Error(err))
return
}
s.logger.Info("message requeued at head (FIFO preserved)",
zap.String("session_id", queuedMsg.SessionID),
zap.String("task_id", queuedMsg.TaskID),
zap.String("queue_id", queuedMsg.ID),
zap.String("queued_by", queuedBy))
s.publishQueueStatusEvent(ctx, queuedMsg.SessionID)
}
func (s *Service) requeueLifecycleMessage(ctx context.Context, queuedMsg *messagequeue.QueuedMessage, queuedBy, coalesceKey string) bool {
requeuedMsg, replaced, accepted, err := s.messageQueue.RequeueLifecycleMessageWithCoalesceKey(
ctx, queuedMsg.SessionID, queuedMsg.TaskID, queuedMsg.Content, queuedMsg.Model,
queuedBy, queuedMsg.PlanMode, queuedMsg.Attachments, queuedMsg.Metadata, coalesceKey, true,
)
if err != nil {
s.logger.Error("failed to requeue lifecycle message",
zap.String("session_id", queuedMsg.SessionID),
zap.String("task_id", queuedMsg.TaskID),
zap.String("queue_id", queuedMsg.ID),
zap.String("queued_by", queuedBy),
zap.Error(err))
return false
}
if !accepted {
s.acknowledgeLifecycleQueueEntry(ctx, queuedMsg.SessionID, queuedMsg)
s.logger.Info("discarding lifecycle retry for inactive task",
zap.String("session_id", queuedMsg.SessionID),
zap.String("task_id", queuedMsg.TaskID),
zap.String("queue_id", queuedMsg.ID))
return false
}
s.logger.Info("lifecycle message requeued",
zap.String("session_id", queuedMsg.SessionID),
zap.String("task_id", queuedMsg.TaskID),
zap.String("old_queue_id", queuedMsg.ID),
zap.String("new_queue_id", requeuedMsg.ID),
zap.String("queued_by", queuedBy),
zap.String("coalesce_key", coalesceKey),
zap.Bool("replaced", replaced))
s.publishQueueStatusEvent(ctx, queuedMsg.SessionID)
return true
}
func messageCoalesceKey(queuedMsg *messagequeue.QueuedMessage) string {
if queuedMsg == nil || len(queuedMsg.Metadata) == 0 {
return ""
}
value, ok := queuedMsg.Metadata[messagequeue.MetadataCoalesceKey]
if !ok {
return ""
}
key, ok := value.(string)
if !ok {
return ""
}
return key
}
// handleAgentBootReady handles the boot signal: an agent's ACP session has
// finished initializing but no turn has run yet. This event is distinct from
// agent.ready (turn-end) so the orchestrator never has to disambiguate the
// two with race-prone flags.
//
// Two jobs here: (1) flip the session to WAITING_FOR_INPUT so callers
// that are gating on that state (e.g. PromptTask's waitForSessionReady after
// ensureSessionRunning kicked off ResumeSession) can proceed; (2) drain any
// orphaned queued message. Without the drain, a workflow auto-start prompt
// queued against a session that died mid-turn (or before its first prompt)
// would sit forever after the user resumed it — agent.ready (the usual drain
// trigger) never fires for a turn that never completed. Crucially we do
// NOT call processOnTurnCompleteViaEngine — there's no turn to complete, and
// stepping the workflow off a boot signal is what caused the production
// ping-pong bug.
func (s *Service) handleAgentBootReady(ctx context.Context, data watcher.AgentEventData) {
if data.SessionID == "" {
s.logger.Warn("missing session_id for agent boot ready event",
zap.String("task_id", data.TaskID))
return
}
if s.isSessionResetInProgress(data.SessionID) {
s.logger.Debug("ignoring agent.boot_ready while session reset is in progress",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID))
return
}
lock, release := s.acquireCancelInFlightGuard(data.SessionID)
if !lock.TryLock() {
if s.isCancelInFlight(data.SessionID) {
// Cancellation owns reconciliation and intentionally holds the guard
// until it has settled the session. Never make it wait for this stale
// boot signal.
release()
s.logger.Debug("ignoring agent.boot_ready while cancel is in progress",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID))
return
}
// Stream metadata also uses this guard, but boot-ready is a one-shot
// lifecycle signal. Wait for ordinary stream persistence to finish
// instead of dropping the only event that can settle a booted session.
lock.Lock()
}
guardLocked := true
defer func() {
if guardLocked {
lock.Unlock()
}
release()
}()
if s.isCancelInFlight(data.SessionID) {
// A boot-ready event that arrives while cancellation is waiting must
// not revive the session in the cancellation window. The cancellation
// owner will reconcile the session after the lifecycle wait; dropping
// this stale boot signal also avoids blocking a handler on a marker that
// is intentionally held until that reconciliation is complete.
s.logger.Debug("ignoring agent.boot_ready while cancel is in progress",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID))
return
}
session, err := s.repo.GetTaskSession(ctx, data.SessionID)
if err != nil {
s.logger.Warn("failed to load session for agent.boot_ready",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.Error(err))
return
}
// Pre-refactor this branch dropped events from "non-active" executions by
// comparing data.AgentExecutionID with session.AgentExecutionID. With the
// in-memory ExecutionStore now the single source of truth (and persisted
// in lockstep with executors_running), a live event arriving here means
// the lifecycle manager already considers data.AgentExecutionID active
// for this session — there's no "old execution" to drop. The check is gone.
// If the in-memory store has been torn down, the event simply has nowhere
// to land and the downstream session-state guard handles it.
// Terminal sessions never need a boot signal — if a stale init event
// arrives after the session was completed/cancelled, just drop it.
if isTerminalSessionState(session.State) {
s.logger.Debug("ignoring agent.boot_ready for terminal session",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.String("session_state", string(session.State)))
return
}
recoveryResolvedAt := s.markRecoveryResolved(ctx, data.SessionID, session)
// Idempotent: if the session is already WAITING_FOR_INPUT (e.g. revived
// from a previously launched session and the boot signal arrived faster
// than persistResumeState wrote STARTING), skip the flip — but still
// fall through to the drain below: an orphaned queued message would
// otherwise sit forever.
if session.State == models.TaskSessionStateWaitingForInput {
if recoveryResolvedAt != nil {
s.publishTaskSessionStateChanged(
ctx,
data.TaskID,
data.SessionID,
session.State,
session.State,
session.ErrorMessage,
recoveryResolvedAt,
session,
)
}
s.logger.Debug("agent.boot_ready: session already WAITING_FOR_INPUT, skipping flip",
zap.String("session_id", data.SessionID))
} else {
s.setSessionWaitingForInput(ctx, data.TaskID, data.SessionID, session)
}
// Drain any orphaned queued message. handleAgentReady drains on turn-end,
// but a session that crashed mid-turn (or never started its first turn)
// won't fire agent.ready — leaving e.g. workflow auto-start prompts stuck
// on the queue until the user manually sends another message. After the
// agent has booted and the session is back to WAITING_FOR_INPUT it's safe
// to dispatch any pending message.
// Claim the shared per-session lock first (see handleAgentReady's
// analogous claim for the full race this closes): a concurrent
// QueueAndInterruptForPeerMessage racing to deliver its own just-queued
// message must never have this drain steal it before the interrupt's
// own cancel+take runs. drainQueuedMessageForPromptableSession now owns
// that acquisition itself (blocking, plus its own promptability
// reload) — see its doc comment.
// Release the guard before the public drain reacquires it. The state flip
// above is the guarded admission decision; the drain performs its own
// cancellation check and leaves the queue untouched if a new cancellation
// claims the session in this handoff.
lock.Unlock()
guardLocked = false
s.drainQueuedMessageForPromptableSession(ctx, data.SessionID)
}
// handleAgentReady handles turn-end ready events: the agent finished processing
// a prompt and is waiting for the next input. This is the *only* event that
// should evaluate workflow on_turn_complete actions — boot signals route
// through handleAgentBootReady instead.
//
// Acquires the per-session cancelInFlight guard *before* any
// turn-completion/pending-move/on_turn_complete bookkeeping runs, not just
// before the final queue-take decision (as it did before). Without this, a
// ready event could pass the early checks, then a concurrent parent
// interrupt (QueueAndInterruptForPeerMessage) could acquire the guard and
// cancel-and-redispatch on this same session — starting a *new* turn — all
// before this event reaches its own completeTurnForSession /
// processOnTurnCompleteViaEngine, which would then wrongly complete and
// evaluate on_turn_complete against that new turn instead of the one this
// event actually reports the completion of, or apply a pending move while
// the interrupt is still targeting the "old" session.
//
// The acquisition is a genuine blocking Lock (mirroring
// QueueAndInterruptForPeerMessage's own precedent), not the previous
// TryLock-and-skip: skipping here would leave this event's own
// turn-completion/workflow bookkeeping undone forever — nothing else
// performs it — and cancelAndTakeForPeerMessage's own "cancel failed but
// already promptable" recovery explicitly relies on a *future* agent.ready
// completing that bookkeeping once the guard frees up; this event may be
// exactly that future ready event. Blocking here adds no new deadlock
// risk: every existing guard holder already bounds its own hold time (see
// QueueAndInterruptForPeerMessage's doc comment), and acquiring an
// uncontended mutex via Lock costs the same as via TryLock, so the common
// (non-racing) case is unaffected.
//
// Once the guard is held, the session and its active turn are re-validated
// against the snapshot taken before waiting (session state, plus turn
// identity via peekActiveTurnID when a TurnService is wired): if either
// changed, a concurrent interrupt (or another turn entirely) has already
// superseded this event, so it backs off without touching anything —
// whatever superseded it owns that turn's own eventual completion.
func (s *Service) handleAgentReady(ctx context.Context, data watcher.AgentEventData) {
if data.SessionID == "" {
s.logger.Warn("missing session_id for agent ready event",
zap.String("task_id", data.TaskID))
return
}
// A passthrough lifecycle reservation cannot be executed while this ready
// handler holds the per-session guard: executeQueuedMessage performs its
// own final claim through that guard. Register the deferred dispatch before
// acquiring it, so LIFO defer ordering releases the guard first. Keeping
// execution in this handler preserves the legacy ready-event completion
// boundary while still using the normal lifecycle delivery pipeline.
var deferredLifecycleDispatch *messagequeue.QueuedMessage
var deferredLifecycleReservation *queuedDispatchReservation
defer func() {
if deferredLifecycleDispatch == nil {
return
}
if hook := s.afterReadyLifecycleReservation; hook != nil {
hook()
}
s.executeQueuedMessageWithReservation(data.SessionID, deferredLifecycleDispatch, deferredLifecycleReservation)
}()
if s.isSessionResetInProgress(data.SessionID) {
s.logger.Debug("ignoring agent.ready while session reset is in progress",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.String("agent_execution_id", data.AgentExecutionID))
return
}
session, err := s.repo.GetTaskSession(ctx, data.SessionID)
if err != nil {
s.logger.Warn("failed to load session for agent.ready",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.Error(err))
return
}
// See comment in handleAgentBootReady: the stale-execution drop is gone; the
// in-memory ExecutionStore is the source of truth and a live event implies
// the emitting execution is the active one for this session.
if session.State != models.TaskSessionStateRunning && session.State != models.TaskSessionStateStarting {
s.logger.Debug("ignoring agent.ready while session is not running or starting",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.String("session_state", string(session.State)))
return
}
// Snapshot which turn this event reports the completion of *before*
// contending for the guard — re-checked below once it's held. See the
// function doc comment for the race this closes.
turnAtEventFire, turnSnapshotErr := s.peekActiveTurnID(ctx, data.SessionID)
lock, release := s.acquireCancelInFlightGuard(data.SessionID)
defer release()
lock.Lock()
guardLocked := true
defer func() {
if guardLocked {
lock.Unlock()
}
}()
waitedForReservation := false
for {
for s.isCancelInFlight(data.SessionID) {
// The cancellation owner temporarily releases this mutex while the
// lifecycle manager waits for terminal stream frames. Do not complete
// the still-running turn in that window, but also do not discard this
// ready event: if cancellation fails, it is the event that must finish
// the unchanged turn and drain its queue.
lock.Unlock()
guardLocked = false
if err := s.waitForCancelInFlight(agentReadyDetachedContext(ctx), data.SessionID); err != nil {
// A cancellation error does not make this ready event stale. The
// owner may have failed before mutating session/turn state; once the
// operation is gone, re-read both below and let this event settle its
// captured turn. Returning here would strand the turn and any queued
// peer message with no future ready event to drain it.
s.logger.Warn("cancellation failed while agent.ready was waiting; re-evaluating the captured turn",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.Error(err))
}
lock.Lock()
guardLocked = true
}
reservation := s.reservedPromptTurn(data.SessionID)
if reservation == nil {
break
}
waitedForReservation = true
lock.Unlock()
guardLocked = false
waitTimeout := s.agentReadyReservationWaitTimeout
if waitTimeout <= 0 {
waitTimeout = detachedClarificationDispatchTimeout + promptFailureCleanupTimeout
}
waitCtx, cancel := context.WithTimeout(agentReadyDetachedContext(ctx), waitTimeout)
accepted, waitErr := reservation.wait(waitCtx)
cancel()
lock.Lock()
guardLocked = true
if waitErr != nil {
if data.PromptGeneration == 0 {
s.logger.Warn("dropping generationless agent.ready after reserved prompt wait timed out",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.String("turn_id", reservation.id),
zap.Error(waitErr))
return
}
// A later reservation may outlive this callback invocation. Preserve
// request values, then attach the lifecycle owner again when it runs.
retryCtx := context.WithoutCancel(ctx)
deferred := s.deferReservedPromptCallback(reservation, func(ownerCtx context.Context) {
callbackCtx, cancelCallback := reservedPromptCallbackContext(ownerCtx, retryCtx)
defer cancelCallback()
s.handleAgentReady(callbackCtx, data)
})
if !deferred {
continue
}
s.logger.Warn("agent.ready timed out waiting for reserved prompt dispatch; deferred reconciliation until resolution",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.String("turn_id", reservation.id),
zap.Error(waitErr))
return
}
if !accepted {
s.logger.Debug("revalidating agent.ready after overlapping prompt reservation rolled back",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.String("turn_id", reservation.id))
}
}
if waitedForReservation {
ctx = agentReadyDetachedContext(ctx)
if data.PromptGeneration == 0 {
s.logger.Debug("ignoring generationless agent.ready that overlapped a prompt reservation",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID))
return
}
turnAtEventFire, turnSnapshotErr = s.peekActiveTurnID(ctx, data.SessionID)
}
// Re-validate now that the guard is held: a concurrent interrupt (or
// clarification recovery, or another drain) may have already resolved
// this exact session while this event waited. isSessionResetInProgress
// and the session state are re-checked in case either changed in that
// window; the turn-identity comparison additionally catches the
// specific case a plain state re-check can't — a *different* turn
// (e.g. one the interrupt cancelled-and-redispatched) that also
// happens to be RUNNING/STARTING.
if s.isSessionResetInProgress(data.SessionID) {
s.logger.Debug("stale agent.ready: session reset started while waiting for the guard",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID))
return
}
session, err = s.repo.GetTaskSession(ctx, data.SessionID)
if err != nil {
s.logger.Warn("failed to reload session for agent.ready once the guard was held",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.Error(err))
return
}
if session.State != models.TaskSessionStateRunning && session.State != models.TaskSessionStateStarting {
s.logger.Debug("stale agent.ready: session no longer running/starting once the guard was held",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.String("session_state", string(session.State)))
return
}
if data.PromptGeneration != 0 {
generationOwner, ok := s.agentManager.(interface {
OwnsPromptGeneration(sessionID, executionID string, generation uint64) bool
})
// Generation-bearing events fail closed: without an ownership validator,
// the handler cannot prove that the event still belongs to this turn.
if !ok || !generationOwner.OwnsPromptGeneration(
data.SessionID, data.AgentExecutionID, data.PromptGeneration,
) {
s.logger.Debug("stale agent.ready: prompt generation no longer owns the session",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.String("agent_execution_id", data.AgentExecutionID),
zap.Uint64("event_prompt_generation", data.PromptGeneration))
return
}
}
if s.turnService != nil {
if turnSnapshotErr != nil {
s.logger.Warn("could not confirm this event's active turn before waiting for the guard; treating as stale rather than risk completing a possible successor turn",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.Error(turnSnapshotErr))
return
}
turnNow, turnNowErr := s.peekActiveTurnID(ctx, data.SessionID)
if turnNowErr != nil || turnNow != turnAtEventFire {
s.logger.Debug("stale agent.ready: active turn changed (or could not be reconfirmed) while waiting for the guard",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.Error(turnNowErr))
return
}
}
// A turn completed successfully — clear any transient retry budget so a
// later, unrelated provider overload starts its backoff fresh at attempt 1.
s.resetTransientRetry(data.SessionID)
// Snapshot the turn this event is about to close for the office cost
// subscriber's benefit: publishPromptUsage's complete-stream frame for
// this same completion is published (and processed) after this handler
// returns, by which point completeTurnForSession below has already
// removed it from activeTurns. See markReadyTurn's doc comment.
s.markReadyTurn(data.SessionID, data.AgentExecutionID, data.PromptGeneration, turnAtEventFire)
// Complete the current turn
s.completeTurnForSession(ctx, data.SessionID)
// A move_task_kandev call during this turn deferred the actual move to
// avoid racing on_enter against the running turn. Apply it now: the move
// is the explicit transition the agent requested, so skip the regular
// on_turn_complete evaluation against the (still old) step.
if pendingMove, exists := s.messageQueue.TakePendingMove(ctx, data.SessionID); exists {
s.applyPendingMove(ctx, data.TaskID, data.SessionID, session, pendingMove)
return
}
// Explicit agent-requested moves (move_task_kandev) take precedence over pending clarifications.
if s.sessionHasPendingClarification(ctx, data.SessionID) {
s.logger.Info("deferring on_turn_complete while clarification is pending",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID))
s.setSessionWaitingForInput(ctx, data.TaskID, data.SessionID, session)
return
}
// Check for workflow transition based on session's current step.
// Uses the engine when available; falls back to legacy evaluation.
// The ViaEngine method handles setSessionWaitingForInput internally when no transition occurs.
transitioned := s.processOnTurnCompleteViaEngine(ctx, data.TaskID, session)
// When a workflow transition occurred (e.g. Work → Review), the new step's
// on_enter actions handle the next prompt (auto_start_agent launches a goroutine).
// Skip the queued-message check to avoid racing with that auto-start goroutine —
// both would try to call PromptTask and the loser's queued message would be lost.
if transitioned {
s.logger.Debug("workflow transition occurred, skipping queued message check",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID))
return
}
// Passthrough sessions: deliver queued messages via PTY stdin instead of ACP.
if s.agentManager.IsPassthroughSession(ctx, data.SessionID) {
queuedMsg, exists := s.messageQueue.ReserveQueued(ctx, data.SessionID)
if !exists {
return
}
// A durable lifecycle reservation must take the same guarded delivery
// path as an ACP session. In particular, executeQueuedMessage performs
// the final active-task/session claim, records the visible message, and
// only acknowledges the reservation after Executor.Prompt accepts it.
// Executor.Prompt itself is passthrough-aware and writes to PTY stdin.
// Ordinary entries retain the historical direct PTY behavior below.
if queuedMsg.IsDurableLifecycle() {
// Reserve the durable row's dispatch token while this ready handler
// still owns the same guard used by all drains. Deferring this mark
// until after guard release lets a competing manual drain reserve the
// same durable row and begin a duplicate delivery attempt.
deferredLifecycleReservation = s.markQueuedDispatchInFlightWithSourceLocked(data.SessionID, queuedMsg.ID, queuedMsg)
deferredLifecycleDispatch = queuedMsg
return
}
if queuedMsg.Content != "" {
if err := s.deliverPassthroughPrompt(ctx, data.SessionID, queuedMsg.Content); err != nil {
s.logger.Warn("failed to deliver queued message to passthrough",
zap.String("session_id", data.SessionID),
zap.Error(err))
return
}
}
return
}
// Check for queued messages when no workflow transition occurred. Uses
// the Locked variant directly: the guard above is held for this
// entire function now, not just this final step.
s.drainQueuedMessageForPromptableSessionLocked(ctx, data.SessionID)
}
const githubPRAutomationOrigin = "github_pr_automation"
// isLifecycleAutomationOrigin reports whether a queued message's "origin"
// metadata identifies it as a durable lifecycle prompt (GitHub PR or GitLab
// MR automation) rather than an ordinary queued message. Both producers
// share the same durable-queue contract (QueueLifecycleMessageWithCoalesceKey,
// AcknowledgeQueued on accepted delivery); this is the single place that
// recognizes the set of origins entitled to that treatment, so adding a
// future provider only needs a change here.
func isLifecycleAutomationOrigin(origin interface{}) bool {
return origin == githubPRAutomationOrigin || origin == mrAutomationOrigin
}
func (s *Service) recordQueuedUserMessage(ctx context.Context, queuedMsg *messagequeue.QueuedMessage, attachments []v1.MessageAttachment) error {
alreadyRecorded, _ := queuedMsg.Metadata[metaKeyUserMessageRecorded].(bool)
if s.messageCreator == nil || alreadyRecorded {
return nil
}
turnID := s.getActiveTurnID(queuedMsg.SessionID)
if turnID == "" {
s.startTurnForSession(ctx, queuedMsg.SessionID)
turnID = s.getActiveTurnID(queuedMsg.SessionID)
}
references := entityrefs.NormalizePersisted(queuedMsg.Metadata[messagequeue.MetadataEntityReferences])
promptContent := AppendEntityReferenceContext(queuedMsg.Content, references)
meta := NewUserMessageMeta().
WithPlanMode(queuedMsg.PlanMode).
WithAttachments(attachments).
WithEntityReferences(references)
metaMap := mergeMetadata(meta.ToMap(), metadataWithoutEntityReferences(queuedMsg.Metadata))
if err := s.messageCreator.CreateUserMessage(ctx, queuedMsg.TaskID, promptContent, queuedMsg.SessionID, turnID, metaMap); err != nil {
s.logger.Error("failed to create user message for queued message", zap.String("session_id", queuedMsg.SessionID), zap.Error(err))
return err
}
if queuedMsg.Metadata == nil {
queuedMsg.Metadata = map[string]interface{}{}
}
queuedMsg.Metadata[metaKeyUserMessageRecorded] = true
return nil
}
func (s *Service) executeQueuedMessage(callerSessionID string, queuedMsg *messagequeue.QueuedMessage) {
s.executeQueuedMessageWithReservation(callerSessionID, queuedMsg, nil)
}
func (s *Service) executeQueuedMessageWithReservation(
callerSessionID string,
queuedMsg *messagequeue.QueuedMessage,
reservation *queuedDispatchReservation,
) {
promptCtx := context.Background() // Use a fresh context for async execution
reservedSessionID := queuedMsg.SessionID
if reservation == nil {
reservation = s.queuedDispatchReservationForEntry(reservedSessionID, queuedMsg.ID)
}
defer func() {
s.clearQueuedDispatchInFlightIfCurrent(reservedSessionID, reservation)
if s.onQueuedMessageExecutionComplete != nil {
s.onQueuedMessageExecutionComplete()
}
}()
lifecyclePrompt := isLifecycleAutomationOrigin(queuedMsg.Metadata["origin"])
claimEntryID, handoffDone := s.claimQueuedMessageHandoff(
promptCtx, callerSessionID, queuedMsg, reservation,
)
if handoffDone {
return
}
if s.isSessionResetInProgress(queuedMsg.SessionID) {
s.logger.Warn("queued message execution deferred due to context reset in progress",
zap.String("session_id", callerSessionID),
zap.String("task_id", queuedMsg.TaskID),
zap.String("queue_id", queuedMsg.ID))
s.requeueMessage(promptCtx, queuedMsg, "workflow-auto-start-reset-retry")
return
}
if lifecyclePrompt && !s.lifecycleQueuedDispatchIsCurrent(promptCtx, queuedMsg) {
s.logger.Info("discarding stale lifecycle dispatch before workflow side effects",
zap.String("session_id", callerSessionID),
zap.String("task_id", queuedMsg.TaskID),
zap.String("queue_id", queuedMsg.ID))
return
}
attachments := make([]v1.MessageAttachment, len(queuedMsg.Attachments))
for i, att := range queuedMsg.Attachments {
attachments[i] = v1.MessageAttachment{
Type: att.Type,
AttachmentID: att.AttachmentID,
Data: att.Data,
MimeType: att.MimeType,
Name: att.Name,
SizeBytes: att.SizeBytes,
DeliveryMode: att.DeliveryMode,
}
}
references := entityrefs.NormalizePersisted(queuedMsg.Metadata[messagequeue.MetadataEntityReferences])
promptContent := AppendEntityReferenceContext(queuedMsg.Content, references)
// Create user messages for ordinary queue entries now. Lifecycle entries
// persist their visible message only after the final active-task claim.
// Skip when the queued metadata is tagged user_message_recorded — that means
// autoStartStepPrompt already inserted the chat row via recordAutoStartMessage
// before queueing (the post-recordAutoStartMessage retry branches). Recording
// here would produce the duplicate user message observed when a workflow
// auto-start failed transiently and the queue drained on boot_ready.
alreadyRecorded, _ := queuedMsg.Metadata[metaKeyUserMessageRecorded].(bool)
userMessageRecorded := alreadyRecorded
if !lifecyclePrompt {
if err := s.recordQueuedUserMessage(promptCtx, queuedMsg, attachments); err == nil && s.messageCreator != nil {
userMessageRecorded = true
}
}
// Process on_turn_start before sending the queued prompt, just like
// dispatchPromptAsync does for user-initiated messages. This allows
// workflow transitions (e.g. move_to_next) to fire on auto-started prompts.
if session, sErr := s.repo.GetTaskSession(promptCtx, queuedMsg.SessionID); sErr == nil {
if lifecyclePrompt && !s.lifecycleQueuedDispatchIsCurrent(promptCtx, queuedMsg) {
s.logger.Info("discarding stale lifecycle dispatch before workflow side effects",
zap.String("session_id", callerSessionID),
zap.String("task_id", queuedMsg.TaskID),
zap.String("queue_id", queuedMsg.ID))
return
}
s.processOnTurnStartViaEngine(promptCtx, queuedMsg.TaskID, session)
}
// Call promptTask with this entry's ID as a second ownership check. The
// worker already claimed the handoff before visible side effects; promptTask
// revalidates that ownership while it marks the session RUNNING.
afterClaim := s.queuedLifecycleAfterClaim(promptCtx, queuedMsg, attachments, lifecyclePrompt)
_, err := s.promptTask(promptCtx, queuedMsg.TaskID, queuedMsg.SessionID,
promptContent, queuedMsg.Model, queuedMsg.PlanMode, attachments, false,
promptTaskOptions{
claimEntryID: claimEntryID,
lifecyclePrompt: lifecyclePrompt,
afterClaim: afterClaim,
})
s.finishQueuedMessageExecution(
promptCtx, callerSessionID, reservedSessionID, queuedMsg,
lifecyclePrompt, userMessageRecorded, err,
)
}
func (s *Service) queuedLifecycleAfterClaim(
ctx context.Context,
queuedMsg *messagequeue.QueuedMessage,
attachments []v1.MessageAttachment,
lifecyclePrompt bool,
) func() error {
if !lifecyclePrompt {
return nil
}
return func() error {
if !s.lifecycleQueuedDispatchIsCurrent(ctx, queuedMsg) {
return errLifecyclePromptReservationSuperseded
}
return s.recordQueuedUserMessage(ctx, queuedMsg, attachments)
}
}
func (s *Service) finishQueuedMessageExecution(
ctx context.Context,
callerSessionID, reservedSessionID string,
queuedMsg *messagequeue.QueuedMessage,
lifecyclePrompt, userMessageRecorded bool,
err error,
) {
if errors.Is(err, errLifecyclePromptReservationSuperseded) {
s.logger.Info("discarding stale lifecycle dispatch after final claim",
zap.String("session_id", callerSessionID),
zap.String("task_id", queuedMsg.TaskID),
zap.String("queue_id", queuedMsg.ID))
return
}
if errors.Is(err, errLifecyclePromptInactive) {
s.acknowledgeLifecycleQueueEntry(ctx, reservedSessionID, queuedMsg)
return
}
var reselected *lifecyclePromptReselectedError
if errors.As(err, &reselected) {
queuedMsg.SessionID = reselected.sessionID
if s.requeueLifecycleMessage(
ctx, queuedMsg, queuedMsg.QueuedBy, messageCoalesceKey(queuedMsg),
) {
s.acknowledgeLifecycleQueueEntry(ctx, reservedSessionID, queuedMsg)
}
return
}
if errors.Is(err, errQueuedDispatchSuperseded) {
// A newer dispatch for this same session (e.g. a second parent
// interrupt cancelling and re-taking while this one was still
// settling) won the claim first. This entry's content still
// matters — steering messages are not silently dropped — so
// requeue it instead of losing it; it will be delivered once the
// winning turn completes naturally.
s.logger.Info("queued message superseded by a newer dispatch for the same session before it could be prompted; requeueing",
zap.String("session_id", callerSessionID),
zap.String("task_id", queuedMsg.TaskID),
zap.String("queue_id", queuedMsg.ID))
s.requeueMessage(ctx, queuedMsg, "superseded-by-newer-dispatch")
return
}
if err != nil {
s.handleQueuedMessageExecutionError(
ctx, callerSessionID, queuedMsg, lifecyclePrompt, userMessageRecorded, err,
)
return
}
if lifecyclePrompt {
s.acknowledgeLifecycleQueueEntry(ctx, reservedSessionID, queuedMsg)
}
}
func (s *Service) handleQueuedMessageExecutionError(
ctx context.Context,
callerSessionID string,
queuedMsg *messagequeue.QueuedMessage,
lifecyclePrompt, userMessageRecorded bool,
err error,
) {
s.logger.Error("failed to execute queued message",
zap.String("session_id", callerSessionID),
zap.String("task_id", queuedMsg.TaskID),
zap.String("queue_id", queuedMsg.ID),
zap.Error(err))
manualRecovery := isManualRecoveryPromptError(err)
if lifecyclePrompt || errors.Is(err, errLifecyclePromptClaim) ||
errors.Is(err, errLifecyclePromptMessagePersistence) ||
isSessionBusyError(err) || isTransientPromptError(err) || manualRecovery ||
errors.Is(err, lifecycle.ErrCancelEscalated) || isSessionResetInProgressError(err) {
if userMessageRecorded {
markQueuedUserMessageRecorded(queuedMsg)
}
s.logger.Warn("queued message execution failed; requeueing",
zap.String("session_id", callerSessionID),
zap.String("task_id", queuedMsg.TaskID),
zap.String("queue_id", queuedMsg.ID),
zap.Bool("manual_recovery", manualRecovery))
if manualRecovery {
s.restoreQueuedMessage(ctx, queuedMsg)
} else {
s.requeueMessage(ctx, queuedMsg, "workflow-auto-start-retry")
}
return
}
// TODO: Implement dead letter queue for failed queued messages
// Currently, failed messages are lost. Consider:
// 1. Retry mechanism with exponential backoff
// 2. Persist failed messages to database for manual recovery
// 3. Notification to user about failed queue execution
s.logger.Warn("queued message execution failed - message is lost (no retry/dead letter queue)",
zap.String("session_id", callerSessionID),
zap.String("queue_id", queuedMsg.ID),
zap.Int("content_length", len(queuedMsg.Content)))
}
// claimQueuedMessageHandoff resolves direct/test reservations and claims the
// worker's ownership before executeQueuedMessage performs visible side effects.
// The bool return is true when the caller must stop because another dispatch
// already won or the reservation could not be claimed.
func (s *Service) claimQueuedMessageHandoff(
ctx context.Context,
callerSessionID string,
queuedMsg *messagequeue.QueuedMessage,
reservation *queuedDispatchReservation,
) (string, bool) {
reservedSessionID := queuedMsg.SessionID
if reservation == nil {
pending := s.pendingQueuedDispatch(reservedSessionID)
accepted := s.acceptedQueuedDispatchForSession(reservedSessionID)
switch {
case pending != nil && pending.entryID == queuedMsg.ID:
reservation = pending
case accepted != nil && accepted.entryID == queuedMsg.ID:
reservation = accepted
case pending != nil || accepted != nil:
s.requeueMessage(ctx, queuedMsg, "superseded-by-newer-dispatch")
return "", true
}
}
if reservation == nil {
return "", false
}
tracked, claimErr := s.claimQueuedDispatchForExecution(reservedSessionID, queuedMsg.ID, reservation)
switch {
case errors.Is(claimErr, errQueuedDispatchSupersededBySendNow):
// Send Now restored and claimed this exact source; the stale FIFO worker
// must not requeue it or create any visible side effects.
return "", true
case errors.Is(claimErr, errQueuedDispatchSuperseded):
s.requeueMessage(ctx, queuedMsg, "superseded-by-newer-dispatch")
return "", true
case claimErr != nil:
s.logger.Warn("failed to claim queued dispatch ownership",
zap.String("session_id", callerSessionID),
zap.String("queue_id", queuedMsg.ID),
zap.Error(claimErr))
s.requeueMessage(ctx, queuedMsg, "queued-dispatch-claim-retry")
return "", true
case tracked:
return queuedMsg.ID, false
default:
return "", false
}
}
func (s *Service) lifecycleQueuedDispatchIsCurrent(
ctx context.Context,
queuedMsg *messagequeue.QueuedMessage,
) bool {
task, err := s.repo.GetTask(ctx, queuedMsg.TaskID)
if err != nil || task == nil || task.ArchivedAt != nil {
return false
}
dispatchTracked := s.isQueuedDispatchInFlight(queuedMsg.SessionID)
if dispatchTracked && !s.isCurrentQueuedDispatch(queuedMsg.SessionID, queuedMsg.ID) {
return false
}
// Legacy/direct TakeQueued callers remove the row before execution, so
// there is no durable reservation left to validate. ReserveQueued marks
// its returned copy independently of persisted metadata so the check still
// applies after the final claim clears the dispatch token.
return !queuedMsg.IsReservedLifecycleDelivery() ||
(s.messageQueue != nil && s.messageQueue.IsCurrentLifecycleReservation(ctx, queuedMsg))
}
func (s *Service) acknowledgeLifecycleQueueEntry(
ctx context.Context,
sessionID string,
queuedMsg *messagequeue.QueuedMessage,
) {
if s.messageQueue == nil || queuedMsg == nil || !queuedMsg.IsDurableLifecycle() {
return
}
if err := s.messageQueue.AcknowledgeQueued(ctx, sessionID, queuedMsg.ID); err != nil {
s.logger.Error("failed to acknowledge accepted lifecycle message",
zap.String("session_id", sessionID),
zap.String("task_id", queuedMsg.TaskID),
zap.String("queue_id", queuedMsg.ID),
zap.Error(err))
return
}
s.publishQueueStatusEvent(ctx, sessionID)
}
func (s *Service) restoreQueuedMessage(ctx context.Context, queuedMsg *messagequeue.QueuedMessage) {
restored, err := s.messageQueue.RestoreMessage(ctx, queuedMsg)
if err != nil {
s.logger.Error("failed to restore queued message",
zap.String("session_id", queuedMsg.SessionID),
zap.String("task_id", queuedMsg.TaskID),
zap.String("queue_id", queuedMsg.ID),
zap.Error(err))
return
}
s.logger.Info("message restored for manual recovery",
zap.String("session_id", restored.SessionID),
zap.String("task_id", restored.TaskID),
zap.String("queue_id", restored.ID),
zap.Int64("position", restored.Position))
s.publishQueueStatusEvent(ctx, queuedMsg.SessionID)
}
func markQueuedUserMessageRecorded(queuedMsg *messagequeue.QueuedMessage) {
if queuedMsg.Metadata == nil {
queuedMsg.Metadata = make(map[string]interface{})
}
queuedMsg.Metadata[metaKeyUserMessageRecorded] = true
}
func metadataWithoutEntityReferences(metadata map[string]interface{}) map[string]interface{} {
if len(metadata) == 0 {
return nil
}
copy := make(map[string]interface{}, len(metadata))
for key, value := range metadata {
if key != messagequeue.MetadataEntityReferences {
copy[key] = value
}
}
return copy
}
// handleAgentCompleted handles agent completion events
func (s *Service) handleAgentCompleted(ctx context.Context, data watcher.AgentEventData) {
if data.SessionID == "" {
s.handleAgentCompletedLocked(ctx, data)
return
}
// Reconcile task_session_commits before acquiring the per-session
// cancel-in-flight mutex below. This must still run before
// handleAgentCompletedLocked's rotated-execution/terminal-state guards
// (see captureSessionCommitsSweep's doc for why - GetGitLog is resolved
// by session ID, not execution ID, and a session's worktree is shared
// across executions), but it does not need this mutex's exclusivity: the
// sweep is read-mostly, best-effort, and idempotent on the write side
// (ON CONFLICT DO NOTHING). Running it here, before the lock, keeps
// Stop/Cancel/Delete on this session from blocking behind up to 10s of
// git I/O - the mutex below is needed by ~20 other call sites across
// internal/orchestrator/ for exactly those operations.
s.captureSessionCommitsSweep(context.WithoutCancel(ctx), data.SessionID)
// Completion owns workflow advancement only while serialized with every
// cancel/interrupt decision for this session. If coordinator stop won while
// the event waited, the guarded state reload below observes CANCELLED and
// suppresses all workflow/on_enter side effects.
lock, release := s.acquireCancelInFlightGuard(data.SessionID)
defer release()
lock.Lock()
defer lock.Unlock()
if s.isCancelInFlight(data.SessionID) {
s.logger.Debug("deferring agent.completed while cancellation is in progress",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.String("agent_execution_id", data.AgentExecutionID))
return
}
s.handleAgentCompletedLocked(ctx, data)
}
func (s *Service) handleAgentCompletedLocked(ctx context.Context, data watcher.AgentEventData) {
s.logger.Info("handling agent completed",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.String("agent_execution_id", data.AgentExecutionID))
s.markExecutionCompleted(data.SessionID, data.AgentExecutionID)
// agent.completed is terminal for this lifecycle execution. Retire its
// activity ownership before evaluating successor workflow state.
s.retireExecutionActivityAndPublish(
context.WithoutCancel(ctx), data.TaskID, data.SessionID, data.AgentExecutionID,
)
// Check for workflow transition based on session's current step.
session, err := s.repo.GetTaskSession(ctx, data.SessionID)
if err != nil {
s.logger.Warn("failed to load session for agent completed",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.Error(err))
go s.cleanupAgentExecution(data.AgentExecutionID, data.TaskID, data.SessionID)
return
}
// task_session_commits reconciliation (captureSessionCommitsSweep) runs in
// the caller, handleAgentCompleted, before this function's rotated-
// execution/terminal-state guards below are even reached - and, for a
// session-scoped event, before the per-session cancel-in-flight mutex is
// acquired at all (see the comment at that call site for why the sweep
// does not need that mutex's exclusivity).
// Skip transition logic when this event is the side-effect of a deliberate
// stop (e.g. a workflow profile-switch calling completeAndStopSession). Two
// signals identify that case:
// - The session's current live execution differs from the event's: the
// lifecycle manager has rotated the session to a new execution, so this
// event refers to a stopped run, not the current one. (Pre-refactor this
// compared session.AgentExecutionID; now the lifecycle store is the
// source of truth.)
// - Terminal session state: completeAndStopSession set state to COMPLETED
// before StopAgent fired this event.
// Without this guard, processOnTurnCompleteViaEngine evaluates the *current*
// task step (which has already moved past where this agent ran) and triggers
// spurious transitions — manifesting as task-step ping-pong on profile switches.
liveExecID, _ := s.agentManager.GetExecutionIDForSession(ctx, data.SessionID)
if data.AgentExecutionID != "" && liveExecID != "" && liveExecID != data.AgentExecutionID {
s.logger.Debug("ignoring agent.completed for non-active (rotated) execution",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.String("event_execution_id", data.AgentExecutionID),
zap.String("live_execution_id", liveExecID))
go s.cleanupAgentExecution(data.AgentExecutionID, data.TaskID, data.SessionID)
return
}
if isTerminalSessionState(session.State) {
s.logger.Debug("ignoring agent.completed; session already in terminal state (deliberate stop)",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.String("session_state", string(session.State)))
go s.cleanupAgentExecution(data.AgentExecutionID, data.TaskID, data.SessionID)
return
}
// A successful, still-live completion clears retry state and scheduler
// ownership only after the guarded terminal/rotation checks above.
s.resetTransientRetry(data.SessionID)
s.scheduler.HandleTaskCompleted(data.TaskID, true)
s.scheduler.RemoveTask(data.TaskID)
// The agent finished a turn, so any stored failure no longer describes the
// session. `session` was read above, so the guard costs nothing.
s.clearRecoveredAgentError(context.WithoutCancel(ctx), data.TaskID, session)
s.completeTurnForSession(context.WithoutCancel(ctx), data.SessionID)
if s.sessionHasPendingClarification(ctx, data.SessionID) {
s.logger.Info("deferring on_turn_complete on agent.completed while clarification is pending",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID))
s.setSessionWaitingForInput(ctx, data.TaskID, data.SessionID, session)
go s.cleanupAgentExecution(data.AgentExecutionID, data.TaskID, data.SessionID)
// captureGitStatusSnapshot and finalizeAutomationRun are deferred
// until a later agent turn completes without pending clarifications, or the
// user dismisses a stale overlay (clarification.stale_dismissed).
return
}
transitioned := s.processOnTurnCompleteViaEngine(ctx, data.TaskID, session)
// Agent-exit path: processOnTurnCompleteViaEngine handles normal
// on_turn_complete transitions. If it did not transition, ensure the
// completed session leaves RUNNING and let setSessionWaitingForInput perform
// the guarded task REVIEW reconciliation when needed.
s.logger.Debug("agent.completed turn-complete decision",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.String("session_state", string(session.State)),
zap.Bool("workflow_transitioned", transitioned))
if !transitioned && session.State != models.TaskSessionStateWaitingForInput {
// Terminal-receipt path. Only flip the session to WAITING_FOR_INPUT
// when the most recent agent-authored message actually asked the
// user for input. Sibling sessions (root task, ParentID empty)
// keep the original affordance so a finishing session on a
// multi-session task still flips to WAITING — only subtasks
// (ParentID non-empty) get the guard. setSessionWaitingForInputIfRequested
// itself inspects the task row to pick the path; for sibling
// sessions the call is a no-op pass-through to the unconditional
// helper, preserving the pre-fix behavior.
s.setSessionWaitingForInputIfRequested(ctx, data.TaskID, data.SessionID, session)
}
// Capture a git status snapshot before cleanup so it can be served
// when clients subscribe to this session later (sidebar diff stats, etc.).
s.captureGitStatusSnapshot(ctx, data.SessionID)
// Clean up the agent execution (stop agentctl, release port)
go s.cleanupAgentExecution(data.AgentExecutionID, data.TaskID, data.SessionID)
// Finalize the automation run: mark status=succeeded so the automation's
// concurrency slot is released. The worktree stays.
s.finalizeAutomationRun(ctx, data.TaskID, true, "")
// Settle point: turn has been completed and the session state has been
// reconciled. reclaimIdleSession is the synchronous equivalent of the
// (intentionally-not-built) runtime auto-convergence tick. It only
// proceeds when no live agent process and no active turn are observed,
// so the typical WAITING_FOR_INPUT case (live agent waiting for the
// user) is a no-op pass-through. Subtask terminals that already
// collapsed to COMPLETED inside setSessionWaitingForInputIfRequested
// reclaimed earlier; this call covers sibling/office flows whose
// settled shape has no live runtime.
if err := s.reclaimIdleSession(context.WithoutCancel(ctx), data.SessionID); err != nil {
s.logger.Warn("agent.completed settle: reclaim failed; row preserved",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.Error(err))
}
}
// handleAgentFailed handles agent failure events
func (s *Service) handleAgentFailed(ctx context.Context, data watcher.AgentEventData) {
if data.SessionID == "" {
s.handleAgentFailedLocked(ctx, data)
return
}
// Linearize every session-backed failure decision with coordinator stop,
// interrupt, and queued-dispatch ownership. The state is re-read inside
// handleAgentFailedLocked after this lock is held: if stop won, failure
// recovery must not create messages, arm retries, or force-clean the
// execution that graceful teardown now owns.
lock, release := s.acquireCancelInFlightGuard(data.SessionID)
defer release()
lock.Lock()
defer lock.Unlock()
if s.isCancelInFlight(data.SessionID) {
s.logger.Debug("deferring agent.failed while cancellation is in progress",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.String("agent_execution_id", data.AgentExecutionID))
return
}
s.handleAgentFailedLocked(ctx, data)
}
func (s *Service) handleAgentFailedLocked(ctx context.Context, data watcher.AgentEventData) {
data = s.withDynamicAttemptEvidence(data)
s.logger.Warn("handling agent failed",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.String("agent_execution_id", data.AgentExecutionID),
zap.String("error_message", data.ErrorMessage))
s.markExecutionFailed(data.SessionID, data.AgentExecutionID)
if drop, _ := s.shouldDropSessionFailure(ctx, data, "agent.failed", true); drop {
// A dropped failure is still terminal for the execution named by the
// lifecycle event (commonly a rotated predecessor). Retire only that
// execution's activity; successor ownership remains untouched.
s.retireExecutionActivityAndPublish(
context.WithoutCancel(ctx), data.TaskID, data.SessionID, data.AgentExecutionID,
)
return
}
// Short transient provider errors get a paced, visible retry-with-backoff
// before any red banner. This is the ONLY non-terminal
// failure path, so it runs before automation finalization below — otherwise
// a transient 529 on an automation run would mark the run failed and
// reap its ephemeral worktree out from under the in-flight retry.
// handleTransientFailure returns false (falling through) for non-transient
// errors, office tasks, or an exhausted budget.
if data.SessionID != "" && s.handleTransientFailure(ctx, data) {
return
}
if data.SessionID != "" && s.routeDynamicAgentFailure(ctx, data, classifyKanbanFailure(data)) {
return
}
// All paths below are terminal for this execution (resume recovery included).
// A transient retry returned above and retains activity until its execution
// is actually stopped.
s.retireExecutionActivityAndPublish(
context.WithoutCancel(ctx), data.TaskID, data.SessionID, data.AgentExecutionID,
)
// Terminal from here. Finalize the automation run — every branch
// below returns early (session-backed recoverable failure, no-session retry),
// and automations need their AutomationRun flipped on *every* terminal
// failure path.
errMsg := data.ErrorMessage
if errMsg == "" {
errMsg = "agent failed"
}
s.finalizeAutomationRun(ctx, data.TaskID, false, errMsg)
// Make all agent CLI failures recoverable — let the user choose to resume or start fresh.
if data.SessionID != "" {
s.handleRecoverableFailureLocked(ctx, data)
return
}
// No session — fall back to scheduler retry + task to REVIEW unless another
// session is still working.
s.scheduler.HandleTaskCompleted(data.TaskID, false)
s.scheduler.RetryTask(data.TaskID)
s.writeTaskReviewState(ctx, data.TaskID, data.SessionID)
go s.cleanupAgentExecution(data.AgentExecutionID, data.TaskID, data.SessionID)
}
func (s *Service) shouldDropSessionFailure(
ctx context.Context,
data watcher.AgentEventData,
source string,
dropWhenUnavailable bool,
) (bool, models.TaskSessionState) {
if data.SessionID == "" {
return false, ""
}
session, err := s.repo.GetTaskSession(ctx, data.SessionID)
if err != nil || session == nil {
s.logger.Warn("dropping session failure because current state is unavailable",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.String("failure_source", source),
zap.Error(err))
if dropWhenUnavailable {
// The workflow state is unavailable, but the execution ID is still
// authoritative enough for bounded runtime cleanup. Leaving it alive
// here leaks agentctl/port state until restart reconciliation.
go s.cleanupAgentExecution(data.AgentExecutionID, data.TaskID, data.SessionID)
}
return dropWhenUnavailable, ""
}
if isTerminalSessionState(session.State) {
s.resetTransientRetry(data.SessionID)
s.logger.Debug("dropping session failure for terminal session",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.String("failure_source", source),
zap.String("session_state", string(session.State)))
go s.cleanupAgentExecution(data.AgentExecutionID, data.TaskID, data.SessionID)
return true, session.State
}
liveExecutionID, _ := s.agentManager.GetExecutionIDForSession(ctx, data.SessionID)
if data.AgentExecutionID != "" && liveExecutionID != "" && liveExecutionID != data.AgentExecutionID {
s.logger.Debug("dropping session failure for rotated execution",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.String("failure_source", source),
zap.String("event_execution_id", data.AgentExecutionID),
zap.String("live_execution_id", liveExecutionID))
go s.cleanupAgentExecution(data.AgentExecutionID, data.TaskID, data.SessionID)
return true, ""
}
return false, ""
}
type executionTeardownIntent uint8
const (
executionTeardownIntentGraceful executionTeardownIntent = iota + 1
executionTeardownIntentForce
)
type executionTeardownClaim struct {
intent executionTeardownIntent
expiresAt time.Time
}
// claimExecutionTeardown accepts the first teardown intent for one concrete
// execution. Callers with a session ID must invoke it while holding that
// session's cancelInFlight guard, making the decision atomic with the state
// read/write that justified the intent. The blocking teardown itself happens
// only after that guard is released.
func (s *Service) claimExecutionTeardown(
sessionID, executionID string,
intent executionTeardownIntent,
) bool {
if sessionID == "" || executionID == "" {
return false
}
key := terminalExecutionKey(sessionID, executionID)
for {
now := time.Now()
claim := executionTeardownClaim{
intent: intent,
expiresAt: now.Add(completedExecutionRetention),
}
value, loaded := s.executionTeardownClaims.LoadOrStore(key, claim)
if !loaded {
time.AfterFunc(completedExecutionRetention, func() {
s.deleteExecutionTeardownClaimIfExpired(key, claim.expiresAt)
})
return true
}
current, ok := value.(executionTeardownClaim)
if !ok {
s.executionTeardownClaims.Delete(key)
continue
}
if now.Before(current.expiresAt) {
return false
}
if s.executionTeardownClaims.CompareAndDelete(key, current) {
continue
}
}
}
// claimForcedExecutionCleanup serializes exact-execution cleanup arbitration
// with coordinator cancellation. The caller performs blocking cleanup only
// after this method releases the per-session guard.
func (s *Service) claimForcedExecutionCleanup(sessionID, executionID string) bool {
if executionID == "" {
return false
}
if sessionID == "" {
return true
}
for {
if s.isCancelInFlight(sessionID) {
// Cleanup is a terminal claim, not an advisory event. Defer it until
// the cancellation owner has completed its lifecycle and reconciliation
// so a cancellation cannot strand the execution teardown.
if err := s.waitForCancelInFlight(context.Background(), sessionID); err != nil {
return false
}
continue
}
lock, release := s.acquireCancelInFlightGuard(sessionID)
lock.Lock()
if s.isCancelInFlight(sessionID) {
lock.Unlock()
release()
continue
}
claimed := s.claimExecutionTeardown(
sessionID,
executionID,
executionTeardownIntentForce,
)
lock.Unlock()
release()
return claimed
}
}
// RegisterExecutionStopOwner records explicit teardown ownership before a
// cancellation write. Registration never suppresses the requested stop; it
// only keeps orphan cleanup from racing that owner for the same execution.
func (s *Service) RegisterExecutionStopOwner(sessionID, executionID string, force bool) {
if sessionID == "" || executionID == "" {
return
}
lock, release := s.acquireCancelInFlightGuard(sessionID)
lock.Lock()
defer func() {
lock.Unlock()
release()
}()
if s.isCancelInFlight(sessionID) {
s.logger.Debug("deferring execution stop ownership while cancellation is in progress",
zap.String("session_id", sessionID),
zap.String("execution_id", executionID))
return
}
intent := executionTeardownIntentGraceful
if force {
intent = executionTeardownIntentForce
}
if s.claimExecutionTeardown(sessionID, executionID, intent) || !force {
return
}
// An explicit force request escalates advisory metadata, but it always runs
// regardless of whether another stop already registered this execution.
key := terminalExecutionKey(sessionID, executionID)
value, ok := s.executionTeardownClaims.Load(key)
current, valid := value.(executionTeardownClaim)
if !ok || !valid || current.intent != executionTeardownIntentGraceful {
return
}
upgraded := executionTeardownClaim{
intent: executionTeardownIntentForce,
expiresAt: time.Now().Add(completedExecutionRetention),
}
s.executionTeardownClaims.Store(key, upgraded)
time.AfterFunc(completedExecutionRetention, func() {
s.deleteExecutionTeardownClaimIfExpired(key, upgraded.expiresAt)
})
}
func (s *Service) deleteExecutionTeardownClaimIfExpired(key string, expiresAt time.Time) {
value, ok := s.executionTeardownClaims.Load(key)
if !ok {
return
}
claim, ok := value.(executionTeardownClaim)
if !ok || !claim.expiresAt.After(expiresAt) {
s.executionTeardownClaims.Delete(key)
}
}
func (s *Service) hasExecutionTeardownOwner(sessionID, executionID string) bool {
if sessionID == "" || executionID == "" {
return false
}
value, ok := s.executionTeardownClaims.Load(terminalExecutionKey(sessionID, executionID))
if !ok {
return false
}
claim, ok := value.(executionTeardownClaim)
return ok && time.Now().Before(claim.expiresAt)
}
// wasResumeAttempt checks whether the session's last execution used a resume token.
// If the token is still present in the DB, the agent was started with --resume.
func (s *Service) wasResumeAttempt(ctx context.Context, sessionID string) bool {
running, err := s.repo.GetExecutorRunningBySessionID(ctx, sessionID)
if err != nil || running == nil {
return false
}
return running.ResumeToken != ""
}
// clearResumeToken removes the resume token from the executor running record so
// 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) error {
err := s.repo.UpdateResumeToken(ctx, sessionID, "", "", "")
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.
// Instead of marking the session FAILED (terminal), it sets WAITING_FOR_INPUT and
// creates an error message with recovery action buttons so the user can choose to
// resume the agent session or start fresh.
func (s *Service) handleRecoverableFailure(ctx context.Context, data watcher.AgentEventData) {
if data.SessionID == "" {
s.handleRecoverableFailureLocked(ctx, data)
return
}
lock, release := s.acquireCancelInFlightGuard(data.SessionID)
defer release()
lock.Lock()
defer lock.Unlock()
if _, err := s.repo.GetTaskSession(ctx, data.SessionID); err != nil {
if errors.Is(err, models.ErrTaskSessionNotFound) {
s.logger.Debug("skipping recoverable failure for deleted session",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID))
return
}
s.logger.Warn("failed to reload session before recoverable failure; continuing",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.Error(err))
}
s.handleRecoverableFailureLocked(ctx, data)
}
// handleRecoverableFailureLocked performs recovery side effects while the
// session's cancelInFlight guard is held. Deletion uses the same guard, so an
// active error event cannot publish after the deleted-session inactive event.
func (s *Service) handleRecoverableFailureLocked(ctx context.Context, data watcher.AgentEventData) {
s.logger.Warn("handling recoverable agent failure",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.String("error", data.ErrorMessage))
// Complete the current turn.
s.completeTurnForSession(ctx, data.SessionID)
s.persistLastAgentError(ctx, data)
// Create a status message with recovery action metadata.
// Skipped for office sessions: the office task page renders its
// own structured RunErrorEntry sourced from the FAILED session,
// and including the legacy ActionMessage would double-show the
// red banner (top-level + inside the embedded chat panel).
if s.messageCreator != nil && !s.isOfficeSession(ctx, data.SessionID) {
s.createRecoveryStatusMessage(ctx, data)
}
// Set session state. Office-owned tasks
// transition to FAILED so the chat correctly stops rendering "Agent
// working" and the topbar spinner clears. Kanban / quick-chat tasks
// keep the legacy WAITING_FOR_INPUT path so the user can resume via
// the Resume / Start fresh recovery buttons in the existing chat
// surface. (See docs/specs/office/requirements/runtime.md.)
nextState := models.TaskSessionStateWaitingForInput
if s.isOfficeSession(ctx, data.SessionID) {
nextState = models.TaskSessionStateFailed
}
s.updateTaskSessionState(ctx, data.TaskID, data.SessionID, nextState, data.ErrorMessage, false)
// Ensure task is in REVIEW state unless another session is still working.
// Unlike the success path (processOnTurnCompleteViaEngine runs first and
// skips this write entirely on a transition), REVIEW is written before the
// reconciliation below runs. A pending signal that reconciles into a
// transition here is a transient REVIEW flash a watching client could
// observe; that's accepted as the price of keeping this failure path
// simple, since the agent genuinely did fail.
s.writeTaskReviewState(ctx, data.TaskID, data.SessionID)
// Give the ADR 0015 reconciler a second chance: a step_complete_kandev
// call that landed mid-turn (session still RUNNING) is never picked up
// by processOnTurnCompleteViaEngine when the turn fails instead of
// completing successfully, so the signal would otherwise sit inert in
// the session's metadata bag until it's silently cleared on resume.
// Office sessions go FAILED, not WAITING_FOR_INPUT, and must not
// advance the step here.
if nextState == models.TaskSessionStateWaitingForInput && data.SessionID != "" {
session, err := s.repo.GetTaskSession(ctx, data.SessionID)
if err != nil {
s.logger.Warn("failed to reload session for step-completion reconciliation; "+
"a pending signal may be dropped",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.Error(err))
} else if signal, ok := models.LoadPendingStepSignal(session.Metadata); ok {
s.reconcileStepCompletionSignalLocked(ctx, data.TaskID, data.SessionID, signal.StepID)
}
}
// Clean up the agent execution.
go s.cleanupAgentExecution(data.AgentExecutionID, data.TaskID, data.SessionID)
}
func (s *Service) persistLastAgentError(ctx context.Context, data watcher.AgentEventData) {
errMsg := data.ErrorMessage
if errMsg == "" {
errMsg = "agent failed"
}
details := routingerr.Sanitize(data.FailureDetails)
// Keep this metadata until the user dismisses the UI notice locally or a
// later recoverable failure replaces it. A successful turn should not erase
// the investigation breadcrumb that explains why the task was marked REVIEW.
lastErr := models.LastAgentError{
Message: errMsg,
OccurredAt: time.Now().UTC(),
AgentExecutionID: data.AgentExecutionID,
RemediationURL: providerRemediationURL(data),
Code: data.FailureCode,
Details: details,
}
if err := s.repo.SetSessionMetadataKey(ctx, data.SessionID, models.SessionMetaKeyLastAgentError, lastErr); err != nil {
s.logger.Warn("failed to persist last agent error",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.Error(err))
return
}
if s.eventBus != nil {
eventData := map[string]interface{}{
"task_id": data.TaskID,
"session_id": data.SessionID,
"active": true,
"message": lastErr.Message,
"occurred_at": lastErr.OccurredAt.Format(time.RFC3339Nano),
"stamp": lastErr.Stamp(),
"agent_execution_id": lastErr.AgentExecutionID,
}
if lastErr.RemediationURL != "" {
eventData["remediation_url"] = lastErr.RemediationURL
}
if lastErr.Code != "" {
eventData["code"] = lastErr.Code
}
if lastErr.Details != "" {
eventData["details"] = lastErr.Details
}
if err := s.eventBus.Publish(ctx, events.TaskSessionErrorChanged, bus.NewEvent(
events.TaskSessionErrorChanged,
"orchestrator",
eventData,
)); err != nil {
s.logger.Warn("failed to publish task session error event",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.Error(err))
}
}
}
// clearRecoveredAgentError drops a session's stored agent failure once the agent
// completes a turn, and publishes the inactive error event so open clients drop
// the red error affordance.
//
// persistLastAgentError deliberately keeps the record across a successful turn
// as an investigation breadcrumb, but nothing ever retired it, so a failure the
// agent recovered from weeks ago still read as live: every path that re-derives
// task status from session metadata (a status-summary rebuild, a backend
// restart, a later session event) put it straight back. The failure also lands
// in the transcript as a recovery message, which is where an investigation
// actually looks, so the metadata copy is not the durable record.
//
// Writes JSON null rather than a delete: LoadLastAgentError already treats that
// as absent, so no new repository surface is needed.
func (s *Service) clearRecoveredAgentError(ctx context.Context, taskID string, session *models.TaskSession) {
if session == nil || session.ID == "" {
return
}
if _, ok := models.LoadLastAgentError(session.Metadata); !ok {
return
}
if err := s.repo.SetSessionMetadataKey(
ctx, session.ID, models.SessionMetaKeyLastAgentError, nil,
); err != nil {
s.logger.Warn("failed to clear recovered agent error",
zap.String("task_id", taskID),
zap.String("session_id", session.ID),
zap.Error(err))
return
}
// Keep the in-memory copy in step: the session-state publish below reads
// its `session_metadata` straight off this object.
delete(session.Metadata, models.SessionMetaKeyLastAgentError)
if s.eventBus == nil {
return
}
if err := s.eventBus.Publish(ctx, events.TaskSessionErrorChanged, bus.NewEvent(
events.TaskSessionErrorChanged,
"orchestrator",
map[string]interface{}{
"task_id": taskID,
"session_id": session.ID,
"active": false,
},
)); err != nil {
s.logger.Warn("failed to publish recovered task session error event",
zap.String("task_id", taskID),
zap.String("session_id", session.ID),
zap.Error(err))
}
}
// markRecoveryResolved persists the successful boot timestamp on the session.
// The boot transcript is useful observability, but its writes are best effort.
// Session metadata is the authoritative recovery result used by the frontend
// after a reload when the transcript row is missing or incomplete.
func (s *Service) markRecoveryResolved(ctx context.Context, sessionID string, session *models.TaskSession) *time.Time {
resolvedAt := time.Now().UTC()
resolvedAtValue := resolvedAt.Format(time.RFC3339Nano)
if err := s.repo.SetSessionMetadataKey(
ctx,
sessionID,
models.SessionMetaKeyRecoveryResolvedAt,
resolvedAtValue,
); err != nil {
s.logger.Warn("failed to persist recovery resolution",
zap.String("session_id", sessionID),
zap.Error(err))
return nil
}
if session.Metadata == nil {
session.Metadata = make(map[string]interface{})
}
session.Metadata[models.SessionMetaKeyRecoveryResolvedAt] = resolvedAtValue
return &resolvedAt
}
// providerRemediationURL returns the adapter-validated remediation URL from the
// normalized provider diagnostic, or "" when the failure carried none. The URL
// is only ever set by the adapter's allowlist validator; the orchestrator does
// not validate or reconstruct URLs from prose.
func providerRemediationURL(data watcher.AgentEventData) string {
if data.ProviderError == nil || !data.ProviderError.Valid() {
return ""
}
return data.ProviderError.RemediationURL
}
// createRecoveryStatusMessage builds and persists the ActionMessage shown
// in the kanban chat surface after a recoverable agent failure. Must only
// be called for non-office sessions (office sessions render their own error UI).
func (s *Service) createRecoveryStatusMessage(ctx context.Context, data watcher.AgentEventData) {
authErr := isAuthError(data.ErrorMessage)
resumeCorrupted := routingerr.IsResumeCorrupted(data.ErrorMessage)
displayMsg := data.ErrorMessage
if authErr {
if readable := extractReadableAuthError(data.ErrorMessage); readable != "" {
displayMsg = readable
}
}
// Resume-corrupted failures (poisoned extended-thinking state after a
// session/load) can't be fixed by resuming — steer the user to a fresh
// session instead of dumping the raw 400.
classified := classifyKanbanFailure(data)
statusMsg := fmt.Sprintf("Agent encountered an error: %s", displayMsg)
if resumeCorrupted {
statusMsg = "This agent session can't be resumed — its saved reasoning state is corrupted. Start a fresh session to continue."
} else if routingerr.Decide(routingerr.ContextKanban, classified, time.Now().UTC()) == routingerr.DecisionShortRetry {
// Reached after the transient retry budget is exhausted — show friendly
// provider-neutral copy instead of dumping raw adapter evidence.
statusMsg = transientFailureExhaustedMessage(classified)
}
hasResumeToken := s.wasResumeAttempt(ctx, data.SessionID)
meta := map[string]interface{}{
"variant": "error",
"recovery_actions": true,
"session_id": data.SessionID,
"task_id": data.TaskID,
"has_resume_token": hasResumeToken,
"is_auth_error": authErr,
"resume_corrupted": resumeCorrupted,
}
managedRuntimeNpmFailure := data.FailureCode == string(routingerr.CodeManagedRuntimeNpmResolution)
if managedRuntimeNpmFailure {
meta["failure_kind"] = string(routingerr.CodeManagedRuntimeNpmResolution)
if details := routingerr.Sanitize(data.FailureDetails); details != "" {
meta["error_output"] = details
}
}
// The validated remediation URL is carried independently of quota
// classification so the generic recoverable card can still show the link.
if remediationURL := providerRemediationURL(data); remediationURL != "" {
meta["remediation_url"] = remediationURL
}
applyProviderQuotaMetadata(meta, data)
// Include cached auth methods so the frontend can show login options.
if authErr {
if methods := s.agentManager.GetSessionAuthMethods(data.SessionID); len(methods) > 0 {
meta["auth_methods"] = methods
}
}
if managedRuntimeNpmFailure {
meta["actions"] = []map[string]interface{}{
wsRecoveryAction(
data.TaskID,
data.SessionID,
"runtime_retry",
"Retry runtime",
"refresh",
"",
"managed-runtime-npm-retry-button",
),
}
} else {
meta["actions"] = buildRecoveryActions(data.TaskID, data.SessionID, hasResumeToken, authErr, resumeCorrupted)
}
if err := s.messageCreator.CreateSessionMessage(
ctx,
data.TaskID,
statusMsg,
data.SessionID,
string(v1.MessageTypeStatus),
s.getActiveTurnID(data.SessionID),
meta,
false,
); err != nil {
s.logger.Warn("failed to create recovery status message",
zap.String("task_id", data.TaskID),
zap.Error(err))
}
}
// applyProviderQuotaMetadata promotes only a validated OpenCode terminal
// diagnostic to the specialized recovery surface. Generic prose and provider
// diagnostics from other agents retain the existing error card.
func applyProviderQuotaMetadata(meta map[string]interface{}, data watcher.AgentEventData) bool {
if data.AgentID != "opencode-acp" || data.ProviderError == nil ||
data.ProviderError.Source != streams.ProviderErrorSourceOpenCodeStderr ||
!data.ProviderError.Valid() {
return false
}
classified := routingerr.Classify(routingerr.Input{
Phase: routingerr.PhaseStreaming,
ProviderID: data.AgentID,
Stderr: data.ProviderError.Message,
})
if classified.Code != routingerr.CodeQuotaLimited || classified.Confidence != routingerr.ConfHigh {
return false
}
meta["failure_kind"] = "provider_quota_limited"
meta["provider_name"] = "OpenCode"
if modelID := routingerr.Sanitize(data.ProviderError.ModelID); modelID != "" {
meta["model_id"] = modelID
}
if resetAt := data.ProviderError.ResetAt; resetAt != nil && !resetAt.IsZero() {
meta["reset_at"] = resetAt.UTC().Format(time.RFC3339)
}
if details := routingerr.Sanitize(data.ProviderError.Message); details != "" {
meta["error_output"] = details
}
return true
}
// isOfficeSession resolves Office ownership through the session's task.
// Best-effort: a missing session or task falls back to the Kanban path.
func (s *Service) isOfficeSession(ctx context.Context, sessionID string) bool {
if sessionID == "" {
return false
}
session, err := s.repo.GetTaskSession(ctx, sessionID)
if err != nil || session == nil {
return false
}
task, err := s.repo.GetTask(ctx, session.TaskID)
return err == nil && task != nil && task.IsFromOffice
}
// handleAgentStartFailed is called by the executor when StartAgentProcess fails.
// It detects auth errors and routes them through the recoverable failure path so
// the frontend shows login guidance instead of a terminal failure. When the
// failure occurred during a background session resume (fromResume=true) and is
// not an auth error, it sets the suppressToast flag so the default FAILED
// transition does not surface a user-facing toast for a transient bootstrap
// error on focus / auto-resume.
// Returns true if the failure was handled (caller should skip default FAILED logic).
func (s *Service) handleAgentStartFailed(ctx context.Context, taskID, sessionID, agentExecutionID string, err error, fromResume bool) bool {
failureData := watcher.AgentEventData{
TaskID: taskID,
SessionID: sessionID,
AgentExecutionID: agentExecutionID,
ErrorMessage: err.Error(),
}
if classified := classifyManagedRuntimeNpmStartFailure(err); classified != nil {
failureData.ErrorMessage = "managed npm runtime failed to prepare"
failureData.FailureCode = string(routingerr.CodeManagedRuntimeNpmResolution)
failureData.FailureDetails = classified.RawExcerpt
}
if sessionID != "" {
lock, release := s.acquireCancelInFlightGuard(sessionID)
defer release()
lock.Lock()
defer lock.Unlock()
if s.isCancelInFlight(sessionID) {
s.logger.Debug("deferring agent start failure while cancellation is in progress",
zap.String("task_id", taskID),
zap.String("session_id", sessionID),
zap.Error(err))
return true
}
if drop, terminalState := s.shouldDropSessionFailure(ctx, failureData, "agent process start", false); drop {
// A cancellation that landed after the executor's first terminal-state
// read still needs its exact-execution cleanup path. Returning false lets
// the executor observe the final CANCELLED state and arbitrate teardown.
if terminalState == models.TaskSessionStateCancelled {
return false
}
return true
}
}
if failureData.FailureCode == string(routingerr.CodeManagedRuntimeNpmResolution) {
s.logger.Info("managed npm runtime startup failure is recoverable",
zap.String("task_id", taskID),
zap.String("session_id", sessionID),
zap.String("agent_execution_id", agentExecutionID))
s.handleRecoverableFailureLocked(ctx, failureData)
return true
}
if !isAuthError(err.Error()) {
if fromResume {
s.logger.Info("suppressing toast for resume bootstrap failure",
zap.String("task_id", taskID),
zap.String("session_id", sessionID),
zap.Error(err))
s.suppressToast.Store(sessionID, true)
}
return false
}
s.logger.Info("agent start failure is auth error, treating as recoverable",
zap.String("task_id", taskID),
zap.String("session_id", sessionID))
s.handleRecoverableFailureLocked(ctx, failureData)
return true
}
func classifyManagedRuntimeNpmStartFailure(err error) *routingerr.Error {
if err == nil {
return nil
}
var structured *routingerr.ManagedRuntimeStartupError
if errors.As(err, &structured) {
if structured.Code != routingerr.CodeManagedRuntimeNpmResolution {
return nil
}
return &routingerr.Error{
Code: structured.Code,
Confidence: routingerr.ConfHigh,
Phase: routingerr.PhaseSessionInit,
RawExcerpt: structured.Details,
}
}
return nil
}
// actionMetaKey* are the shared keys of the frontend ActionMessage button
// descriptor (see apps/web .../messages/action-message.tsx). Defined as
// constants because the shape is built in more than one place in this package.
const (
actionMetaKeyType = "type"
actionMetaKeyLabel = "label"
actionMetaKeyIcon = "icon"
actionMetaKeyTooltip = "tooltip"
actionMetaKeyTestID = "test_id"
)
const (
recoveryFreshButtonTestID = "recovery-fresh-button"
recoveryRestartButtonTestID = "recovery-restart-button"
recoveryResumeButtonTestID = "recovery-resume-button"
)
// wsRecoveryAction builds a single session.recover button descriptor. Keeping
// the map keys in one place avoids drift between the buttons and keeps the
// metadata shape consistent.
func wsRecoveryAction(taskID, sessionID, recoverAction, label, icon, tooltip, testID string) map[string]interface{} {
action := map[string]interface{}{
actionMetaKeyType: "ws_request",
actionMetaKeyLabel: label,
actionMetaKeyIcon: icon,
actionMetaKeyTestID: testID,
"params": map[string]interface{}{
"method": "session.recover",
"payload": map[string]interface{}{"task_id": taskID, "session_id": sessionID, "action": recoverAction},
},
}
if tooltip != "" {
action[actionMetaKeyTooltip] = tooltip
}
return action
}
// buildRecoveryActions creates the generic actions array for agent error
// recovery. Ordinary failures list Resume first (cheapest recovery, keeps
// context) then Start fresh. For resume-corrupted failures the order flips:
// Start fresh becomes the primary action and Resume is kept but flagged as
// likely-to-fail, since the agent's persisted state is poisoned.
func buildRecoveryActions(taskID, sessionID string, hasResumeToken, isAuthError, resumeCorrupted bool) []map[string]interface{} {
resumeTooltip := "Re-launch with resume flag — keeps all previous messages and context"
if resumeCorrupted {
resumeTooltip = "Resume will likely fail again — this session's saved state is corrupted. Prefer Start fresh."
}
resume := func() map[string]interface{} {
return wsRecoveryAction(taskID, sessionID, "resume",
"Resume session", "refresh", resumeTooltip, recoveryResumeButtonTestID)
}
freshLabel, freshTestID := "Start fresh session", recoveryFreshButtonTestID
if isAuthError {
freshLabel, freshTestID = "Restart session", recoveryRestartButtonTestID
}
fresh := wsRecoveryAction(taskID, sessionID, "fresh_start", freshLabel, "player-play",
"New agent process on the same workspace — no previous conversation context", freshTestID)
actions := []map[string]interface{}{}
if resumeCorrupted {
// Fresh is primary; resume kept but de-emphasized below it.
actions = append(actions, fresh)
if hasResumeToken {
actions = append(actions, resume())
}
return actions
}
if hasResumeToken {
actions = append(actions, resume())
}
actions = append(actions, fresh)
return actions
}
// handleAgentStopped handles agent stopped events (manual stop or cancellation)
func (s *Service) handleAgentStopped(ctx context.Context, data watcher.AgentEventData) {
s.logger.Info("handling agent stopped",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.String("agent_execution_id", data.AgentExecutionID))
// Stopped executions never own a valid trailing stream completion. Mark the
// exact lifecycle terminal before detaching activity so buffered frames
// cannot recreate ownership after teardown.
s.markExecutionFailed(data.SessionID, data.AgentExecutionID)
// Reconcile before the rotated-execution guard: a late stop from an old
// execution must retire only that execution's activity while preserving
// ownership already claimed by its successor.
s.retireExecutionActivityAndPublish(
context.WithoutCancel(ctx), data.TaskID, data.SessionID, data.AgentExecutionID,
)
// NOTE: we deliberately do NOT resetTransientRetry here — the transient
// retry tears down the failed execution via StopExecution as part of its
// own re-drive, which surfaces as an agent.stopped event; clearing the loop
// on that self-inflicted stop would abort the retry. The loop is freed on
// ready/completed (success), cancel, and exhaustion instead.
// An explicit teardown owner already decided and persisted the session
// state before stopping this exact execution. Treat the resulting stopped
// event as an acknowledgement, not a new state decision. Recovery may have
// advanced the row to STARTING before the predecessor's delayed stop event
// arrives, while the replacement is not yet visible in the runtime store.
if s.hasExecutionTeardownOwner(data.SessionID, data.AgentExecutionID) {
s.logger.Info("ignoring agent.stopped for explicitly owned teardown",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.String("agent_execution_id", data.AgentExecutionID))
return
}
// Drop stopped events that belong to a previous (rotated) execution. The
// session might already be running a fresh resume cycle; flipping its state
// to CANCELLED based on the corpse of the prior execution poisons the
// recovery (the new cycle's session/load succeeds against a session that
// looks terminal to the rest of the system). Mirrors the rotation guard in
// handleAgentCompleted.
if s.agentManager != nil && data.AgentExecutionID != "" && data.SessionID != "" {
if liveExecID, _ := s.agentManager.GetExecutionIDForSession(ctx, data.SessionID); liveExecID != "" && liveExecID != data.AgentExecutionID {
s.logger.Info("ignoring agent.stopped for non-active (rotated) execution",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.String("event_execution_id", data.AgentExecutionID),
zap.String("live_execution_id", liveExecID))
return
}
}
// Complete the current turn if there is one
s.completeTurnForSession(ctx, data.SessionID)
// Don't override WAITING_FOR_INPUT or IDLE — these are "stopped on
// purpose" states the caller already set. WAITING_FOR_INPUT comes from
// the recovery path so the user can choose to resume; IDLE comes from
// the office fire-and-forget turn-complete handler which intentionally
// stops the agent and parks the session for the next run. Either
// way, the AgentStopped event here is a side-effect of that stop —
// clobbering the state to CANCELLED would mark the row terminal and
// break the next office run (EnsureSessionForAgent then tries to
// INSERT a new row and the partial unique index rejects it).
if session, err := s.repo.GetTaskSession(ctx, data.SessionID); err == nil &&
(session.State == models.TaskSessionStateWaitingForInput ||
session.State == models.TaskSessionStateIdle) {
s.logger.Info("skipping CANCELLED transition; session was stopped on purpose",
zap.String("session_id", data.SessionID),
zap.String("state", string(session.State)))
return
}
// Update session state to cancelled (already done by executor, but ensure consistency)
s.updateTaskSessionState(ctx, data.TaskID, data.SessionID, models.TaskSessionStateCancelled, "", false)
// NOTE: We do NOT update task state here because:
// 1. If this is from CompleteTask(), the task state will be set to COMPLETED by the caller
// 2. If this is from StopTask(), the task state should be set to REVIEW by the caller
// 3. Updating here would create a race condition with the caller's state update
//
// The task state management is the responsibility of the operation that triggered the stop,
// not the event handler. This handler only manages session-level cleanup.
}
// cleanupAgentExecution stops the agentctl instance and releases its port after
// the agent reaches a terminal state (completed/failed). This runs in a goroutine
// so it doesn't block the event handler.
func (s *Service) cleanupAgentExecution(executionID, taskID, sessionID string) {
if executionID == "" {
return
}
if !s.claimForcedExecutionCleanup(sessionID, executionID) {
s.logger.Debug("skipping duplicate execution teardown",
zap.String("execution_id", executionID),
zap.String("task_id", taskID),
zap.String("session_id", sessionID))
return
}
ctx := context.Background()
if !s.isExecutionCompleted(sessionID, executionID) {
// Direct crash/forced-cleanup callers may reach this boundary without a
// preceding lifecycle event. Preserve an existing completed marker (and
// its allowed terminal stream), otherwise tombstone trailing frames.
s.markExecutionFailed(sessionID, executionID)
}
// Defensive terminal-boundary retirement. Normal lifecycle events run this
// first; the repeated forced-cleanup call is idempotent.
s.retireExecutionActivityAndPublish(ctx, taskID, sessionID, executionID)
if err := s.executor.StopExecution(ctx, executionID, "agent completed", true); err != nil {
s.logger.Debug("agent execution cleanup after terminal state",
zap.String("execution_id", executionID),
zap.String("task_id", taskID),
zap.Error(err))
}
}