fix: keep tasks active while sibling sessions run (#1551)

* fix: keep tasks active while sibling sessions run

* fix: avoid duplicate task review writes

* fix: clear completed sessions before review guard

* fix: serialize session launch state

* test: cover completed sibling session states

* fix: bound completed execution markers

* fix: amortize completed execution marker cleanup

* fix: fail closed on stale session starts

* fix: ignore stale completed execution events

* test: cover stale completed tool calls

* fix: harden stale session state guards

* fix: handle terminal execution races

* fix: avoid duplicate terminal complete side effects

* fix: harden terminal execution race guards

* fix: guard task review reconciliation

* fix: handle late terminal complete side effects

* fix: align review-state test guards

* fix: fail closed on stale review bridges

* fix: reduce review reconcile complexity

---------

Co-authored-by: root <root@kandev.ts.net>
This commit is contained in:
Carlos Florêncio
2026-06-30 02:56:46 +01:00
committed by GitHub
parent f5dc6e2322
commit e51e0ebf3c
28 changed files with 2285 additions and 94 deletions
@@ -24,6 +24,10 @@ func (a *turnServiceAdapter) CompleteTurn(ctx context.Context, turnID string) er
return a.svc.CompleteTurn(ctx, turnID)
}
func (a *turnServiceAdapter) GetTurn(ctx context.Context, turnID string) (*models.Turn, error) {
return a.svc.GetTurn(ctx, turnID)
}
func (a *turnServiceAdapter) GetActiveTurn(ctx context.Context, sessionID string) (*models.Turn, error) {
return a.svc.GetActiveTurn(ctx, sessionID)
}
@@ -219,6 +219,10 @@ func (a *testTurnServiceAdapter) CompleteTurn(ctx context.Context, turnID string
return a.svc.CompleteTurn(ctx, turnID)
}
func (a *testTurnServiceAdapter) GetTurn(ctx context.Context, turnID string) (*models.Turn, error) {
return a.svc.GetTurn(ctx, turnID)
}
func (a *testTurnServiceAdapter) GetActiveTurn(ctx context.Context, sessionID string) (*models.Turn, error) {
return a.svc.GetActiveTurn(ctx, sessionID)
}
@@ -190,6 +190,7 @@ func (s *Service) RegisterEventSubscribers(eb bus.EventBus) error {
type AgentTurnMessageData struct {
TaskID string `json:"task_id"`
SessionID string `json:"session_id"`
TurnID string `json:"turn_id"`
AgentText string `json:"agent_text"`
AgentID string `json:"agent_id"`
}
@@ -214,7 +215,12 @@ func (s *Service) handleAgentTurnMessageSaved(ctx context.Context, event *bus.Ev
// For streaming agents, Data.Text is empty because chunks were already
// drained. Fall back to the last agent message saved in session messages.
agentText := data.AgentText
if agentText == "" && data.SessionID != "" && s.taskWorkspace != nil {
if agentText == "" && data.TurnID != "" && s.taskWorkspace != nil {
if lastMsg, msgErr := s.taskWorkspace.GetLastAgentMessageForTurn(ctx, data.TurnID); msgErr == nil && lastMsg != "" {
agentText = lastMsg
}
}
if agentText == "" && data.TurnID == "" && data.SessionID != "" && s.taskWorkspace != nil {
if lastMsg, msgErr := s.taskWorkspace.GetLastAgentMessage(ctx, data.SessionID); msgErr == nil && lastMsg != "" {
agentText = lastMsg
}
@@ -15,7 +15,8 @@ import (
// stubTaskWorkspace is a minimal implementation of service.TaskWorkspaceService
// that returns a fixed message from GetLastAgentMessage.
type stubTaskWorkspace struct {
lastMsg string
lastMsg string
lastMsgByTurn map[string]string
}
func (s *stubTaskWorkspace) GetWorkspace(_ context.Context, _ string) (*taskmodels.Workspace, error) {
@@ -36,6 +37,9 @@ func (s *stubTaskWorkspace) DeleteTask(_ context.Context, _ string) error { retu
func (s *stubTaskWorkspace) GetLastAgentMessage(_ context.Context, _ string) (string, error) {
return s.lastMsg, nil
}
func (s *stubTaskWorkspace) GetLastAgentMessageForTurn(_ context.Context, turnID string) (string, error) {
return s.lastMsgByTurn[turnID], nil
}
// newTestServiceWithTaskWorkspace creates a service+bus like newTestServiceWithBus
// but injects the given TaskWorkspace stub.
@@ -86,3 +90,70 @@ func TestAutoPostAgentComment_FallbackPath(t *testing.T) {
}
t.Fatalf("expected session comment with body from GetLastAgentMessage, got %+v", comments)
}
func TestAutoPostAgentComment_TurnScopedFallbackPath(t *testing.T) {
stub := &stubTaskWorkspace{
lastMsg: "newer active turn reply",
lastMsgByTurn: map[string]string{
"turn-terminal": "terminal turn reply",
},
}
svc, eb := newTestServiceWithTaskWorkspace(t, stub)
ctx := context.Background()
createTestAgent(t, svc, "ws-1", "agent-turn-fallback")
taskID := createOfficeTask(t, svc, "ws-1", "agent-turn-fallback")
event := bus.NewEvent(events.AgentTurnMessageSaved, "orchestrator", map[string]string{
"task_id": taskID,
"session_id": "sess-fallback",
"turn_id": "turn-terminal",
"agent_text": "",
"agent_id": "agent-turn-fallback",
})
if err := eb.Publish(ctx, events.AgentTurnMessageSaved, event); err != nil {
t.Fatalf("publish event: %v", err)
}
comments, err := svc.ListComments(ctx, taskID)
if err != nil {
t.Fatalf("list comments: %v", err)
}
for _, c := range comments {
if c.Source == "session" && c.AuthorType == "agent" && c.Body == "terminal turn reply" {
return
}
}
t.Fatalf("expected session comment with body from turn-scoped fallback, got %+v", comments)
}
func TestAutoPostAgentComment_TurnScopedFallbackMissDoesNotUseSessionMessage(t *testing.T) {
stub := &stubTaskWorkspace{lastMsg: "newer active turn reply"}
svc, eb := newTestServiceWithTaskWorkspace(t, stub)
ctx := context.Background()
createTestAgent(t, svc, "ws-1", "agent-turn-miss")
taskID := createOfficeTask(t, svc, "ws-1", "agent-turn-miss")
event := bus.NewEvent(events.AgentTurnMessageSaved, "orchestrator", map[string]string{
"task_id": taskID,
"session_id": "sess-fallback",
"turn_id": "turn-terminal",
"agent_text": "",
"agent_id": "agent-turn-miss",
})
if err := eb.Publish(ctx, events.AgentTurnMessageSaved, event); err != nil {
t.Fatalf("publish event: %v", err)
}
comments, err := svc.ListComments(ctx, taskID)
if err != nil {
t.Fatalf("list comments: %v", err)
}
for _, c := range comments {
if c.Source == "session" {
t.Fatalf("expected no session comment from session fallback when turn lookup misses, got %+v", comments)
}
}
}
@@ -106,6 +106,7 @@ type TaskWorkspaceService interface {
ListTasksByWorkspace(ctx context.Context, workspaceID, workflowID, repositoryID, query string, page, pageSize int, includeArchived, includeEphemeral, onlyEphemeral, excludeConfig bool) ([]*taskmodels.Task, int, error)
DeleteTask(ctx context.Context, id string) error
GetLastAgentMessage(ctx context.Context, sessionID string) (string, error)
GetLastAgentMessageForTurn(ctx context.Context, turnID string) (string, error)
}
// WorkspaceGroupCleaner removes Kandev-owned materialized task workspaces
@@ -148,6 +148,10 @@ func (f *fakeWorkspaceTaskService) GetLastAgentMessage(context.Context, string)
return "", nil
}
func (f *fakeWorkspaceTaskService) GetLastAgentMessageForTurn(context.Context, string) (string, error) {
return "", nil
}
type fakeWorkspaceGroupCleaner struct {
svc *service.Service
groupID string
@@ -490,6 +490,7 @@ func (s *Service) handleAgentCompleted(ctx context.Context, data watcher.AgentEv
// Update scheduler and remove from queue
s.scheduler.HandleTaskCompleted(data.TaskID, true)
s.scheduler.RemoveTask(data.TaskID)
s.markExecutionCompleted(data.SessionID, data.AgentExecutionID)
// Check for workflow transition based on session's current step.
session, err := s.repo.GetTaskSession(ctx, data.SessionID)
@@ -533,8 +534,9 @@ func (s *Service) handleAgentCompleted(ctx context.Context, data watcher.AgentEv
return
}
s.completeTurnForSession(context.WithoutCancel(ctx), data.SessionID)
if s.sessionHasPendingClarification(ctx, data.SessionID) {
s.completeTurnForSession(context.WithoutCancel(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))
@@ -548,29 +550,18 @@ func (s *Service) handleAgentCompleted(ctx context.Context, data watcher.AgentEv
transitioned := s.processOnTurnCompleteViaEngine(ctx, data.TaskID, session)
// Agent-exit path: unlike the streaming complete event, this path does NOT
// itself complete the open turn or clear the session's RUNNING state — it
// relies entirely on the workflow engine's on_turn_complete transition.
// workflow_transitioned=false means no transition fired, so the session may
// still be RUNNING (only the task row moves to REVIEW below) and the chat UI
// keeps showing the agent as working. Pairs with the frontend [session:state]
// / [session:turns] trace — filter both by the same task_id.
s.logger.Debug("agent.completed turn-complete decision (session state not cleared by this path)",
// 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 no workflow transition occurred, move task to REVIEW state for user review
if !transitioned {
if err := s.taskRepo.UpdateTaskState(ctx, data.TaskID, v1.TaskStateReview); err != nil {
s.logger.Error("failed to update task state to REVIEW",
zap.String("task_id", data.TaskID),
zap.Error(err))
} else {
s.logger.Info("task moved to REVIEW state after agent completion",
zap.String("task_id", data.TaskID))
}
if !transitioned && session.State != models.TaskSessionStateWaitingForInput {
s.setSessionWaitingForInput(ctx, data.TaskID, data.SessionID, session)
}
// Capture a git status snapshot before cleanup so it can be served
@@ -593,6 +584,8 @@ func (s *Service) handleAgentFailed(ctx context.Context, data watcher.AgentEvent
zap.String("agent_execution_id", data.AgentExecutionID),
zap.String("error_message", data.ErrorMessage))
s.markExecutionFailed(data.SessionID, data.AgentExecutionID)
// Transient provider errors (529 Overloaded) 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
@@ -631,15 +624,11 @@ func (s *Service) handleAgentFailed(ctx context.Context, data watcher.AgentEvent
return
}
// No session — fall back to scheduler retry + task to REVIEW.
// 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)
if err := s.taskRepo.UpdateTaskState(ctx, data.TaskID, v1.TaskStateReview); err != nil {
s.logger.Error("failed to update task state to REVIEW after failure",
zap.String("task_id", data.TaskID),
zap.Error(err))
}
s.writeTaskReviewState(ctx, data.TaskID, data.SessionID)
go s.cleanupAgentExecution(data.AgentExecutionID, data.TaskID, data.SessionID)
}
@@ -712,12 +701,8 @@ func (s *Service) handleResumeFailure(ctx context.Context, data watcher.AgentEve
// 3. Set session to WAITING_FOR_INPUT (not FAILED) so the user can interact.
s.updateTaskSessionState(ctx, data.TaskID, data.SessionID, models.TaskSessionStateWaitingForInput, "", false)
// 4. Ensure task is in REVIEW state.
if err := s.taskRepo.UpdateTaskState(ctx, data.TaskID, v1.TaskStateReview); err != nil {
s.logger.Warn("failed to set task to REVIEW after resume failure",
zap.String("task_id", data.TaskID),
zap.Error(err))
}
// 4. Ensure task is in REVIEW state unless another session is still working.
s.writeTaskReviewState(ctx, data.TaskID, data.SessionID)
return true
}
@@ -757,12 +742,8 @@ func (s *Service) handleRecoverableFailure(ctx context.Context, data watcher.Age
}
s.updateTaskSessionState(ctx, data.TaskID, data.SessionID, nextState, data.ErrorMessage, false)
// Ensure task is in REVIEW state.
if err := s.taskRepo.UpdateTaskState(ctx, data.TaskID, v1.TaskStateReview); err != nil {
s.logger.Warn("failed to set task to REVIEW after recoverable failure",
zap.String("task_id", data.TaskID),
zap.Error(err))
}
// Ensure task is in REVIEW state unless another session is still working.
s.writeTaskReviewState(ctx, data.TaskID, data.SessionID)
// Clean up the agent execution.
go s.cleanupAgentExecution(data.AgentExecutionID, data.TaskID, data.SessionID)
@@ -19,6 +19,7 @@ type repoBackedTurnService struct {
type testRepo interface {
CreateTurn(ctx context.Context, turn *models.Turn) error
CompleteTurn(ctx context.Context, id string) error
GetTurn(ctx context.Context, id string) (*models.Turn, error)
GetActiveTurnBySessionID(ctx context.Context, sessionID string) (*models.Turn, error)
UpdateTurn(ctx context.Context, turn *models.Turn) error
}
@@ -39,6 +40,10 @@ func (s *repoBackedTurnService) CompleteTurn(ctx context.Context, turnID string)
return s.repo.CompleteTurn(ctx, turnID)
}
func (s *repoBackedTurnService) GetTurn(ctx context.Context, turnID string) (*models.Turn, error) {
return s.repo.GetTurn(ctx, turnID)
}
func (s *repoBackedTurnService) GetActiveTurn(ctx context.Context, sessionID string) (*models.Turn, error) {
return s.repo.GetActiveTurnBySessionID(ctx, sessionID)
}
@@ -14,7 +14,6 @@ import (
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/task/models"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
// subscribeClarificationEvents subscribes to clarification-related events.
@@ -80,11 +79,7 @@ func (s *Service) handleClarificationStaleDismissed(ctx context.Context, event *
s.finalizeAutomationRunIfEphemeral(writeCtx, data.TaskID, data.SessionID, true, "")
transitioned := s.processOnTurnCompleteViaEngine(writeCtx, data.TaskID, session)
if !transitioned {
if err := s.taskRepo.UpdateTaskState(writeCtx, data.TaskID, v1.TaskStateReview); err != nil {
s.logger.Error("failed to update task state to REVIEW after stale-dismiss",
zap.String("task_id", data.TaskID),
zap.Error(err))
}
s.writeTaskReviewState(writeCtx, data.TaskID, data.SessionID)
}
return nil
}
@@ -244,6 +244,7 @@ func TestHandleClarificationStaleDismissed(t *testing.T) {
ID: "step1", WorkflowID: "wf1", Name: "Plan", Position: 0,
}
taskRepo := newMockTaskRepo()
seedMockTaskState(taskRepo, "t1", v1.TaskStateInProgress)
svc := createEngineService(t, repo, stepGetter, &mockAgentManager{})
svc.taskRepo = taskRepo
@@ -2,6 +2,7 @@ package orchestrator
import (
"context"
"fmt"
"time"
"go.uber.org/zap"
@@ -10,6 +11,7 @@ import (
"github.com/kandev/kandev/internal/agentctl/types/streams"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/orchestrator/sessionstate"
"github.com/kandev/kandev/internal/task/models"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
@@ -23,10 +25,29 @@ func (s *Service) handleAgentStreamEvent(ctx context.Context, payload *lifecycle
taskID := payload.TaskID
sessionID := payload.SessionID
eventType := payload.Data.Type
terminalCompleteStream := false
// Any agent stream activity means the agent resumed after clarification.
// Cancel primary-path clarification watchdogs for this session.
s.cancelClarificationWatchdogsForSession(sessionID, eventType)
if eventType == agentEventComplete {
if marker, ok := s.terminalExecutionMarker(sessionID, payload.ExecutionID); ok {
if !marker.allowCompleteStream {
s.logger.Debug("ignoring complete stream event from terminal failed execution",
zap.String("task_id", taskID),
zap.String("session_id", sessionID),
zap.String("agent_execution_id", payload.ExecutionID))
return
}
terminalCompleteStream = true
}
} else if s.shouldDropCompletedExecutionStreamEvent(payload) {
return
}
if !terminalCompleteStream {
// Any live agent stream activity means the agent resumed after clarification.
// Cancel primary-path clarification watchdogs for this session. Late terminal
// completes are excluded because they belong to an already-finished execution.
s.cancelClarificationWatchdogsForSession(sessionID, eventType)
}
s.logger.Debug("handling agent stream event",
zap.String("task_id", taskID),
@@ -201,6 +222,9 @@ func (s *Service) handleToolCallEvent(ctx context.Context, payload *lifecycle.Ag
zap.String("tool_call_id", payload.Data.ToolCallID))
return
}
if s.shouldDropCompletedExecutionStreamEvent(payload) {
return
}
if s.messageCreator != nil {
if err := s.messageCreator.CreateToolCallMessage(
@@ -229,7 +253,7 @@ func (s *Service) handleToolCallEvent(ctx context.Context, payload *lifecycle.Ag
// flipped to IN_PROGRESS in lockstep — otherwise an out-of-turn tool
// event (e.g. a Monitor watcher firing after on_turn_complete moved
// the task to REVIEW) leaves session=RUNNING with task=REVIEW.
s.setSessionRunning(ctx, payload.TaskID, payload.SessionID)
s.setSessionRunningForExecution(ctx, payload.TaskID, payload.SessionID, payload.ExecutionID)
}
}
@@ -240,9 +264,23 @@ func (s *Service) saveAgentTextIfPresent(ctx context.Context, payload *lifecycle
if payload.Data.Text == "" || payload.SessionID == "" {
return
}
s.saveAgentTextForTurn(ctx, payload, s.getActiveTurnID(payload.SessionID))
}
func (s *Service) saveAgentTextForTurn(ctx context.Context, payload *lifecycle.AgentStreamEventPayload, turnID string) {
if payload.Data.Text == "" || payload.SessionID == "" {
return
}
if turnID == "" {
s.logger.Debug("skipping agent text without a target turn",
zap.String("task_id", payload.TaskID),
zap.String("session_id", payload.SessionID),
zap.String("agent_execution_id", payload.ExecutionID))
return
}
if s.messageCreator != nil {
if err := s.messageCreator.CreateAgentMessage(ctx, payload.TaskID, payload.Data.Text, payload.SessionID, s.getActiveTurnID(payload.SessionID)); err != nil {
if err := s.messageCreator.CreateAgentMessage(ctx, payload.TaskID, payload.Data.Text, payload.SessionID, turnID); err != nil {
s.logger.Error("failed to create agent message",
zap.String("task_id", payload.TaskID),
zap.Error(err))
@@ -259,6 +297,10 @@ func (s *Service) saveAgentTextIfPresent(ctx context.Context, payload *lifecycle
// The subscriber (office comment bridge) uses the task/session IDs to look up
// the agent's last message and auto-post it as a task comment.
func (s *Service) publishAgentTurnComplete(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) {
s.publishAgentTurnCompleteForTurn(ctx, payload, "")
}
func (s *Service) publishAgentTurnCompleteForTurn(ctx context.Context, payload *lifecycle.AgentStreamEventPayload, turnID string) {
if s.eventBus == nil || payload.TaskID == "" || payload.SessionID == "" {
return
}
@@ -271,6 +313,7 @@ func (s *Service) publishAgentTurnComplete(ctx context.Context, payload *lifecyc
"session_id": payload.SessionID,
"agent_text": payload.Data.Text,
"agent_id": payload.AgentID,
"turn_id": turnID,
}
event := bus.NewEvent(events.AgentTurnMessageSaved, "orchestrator", data)
if err := s.eventBus.Publish(ctx, events.AgentTurnMessageSaved, event); err != nil {
@@ -334,6 +377,9 @@ func (s *Service) handleToolUpdateEvent(ctx context.Context, payload *lifecycle.
zap.String("tool_call_id", payload.Data.ToolCallID))
return
}
if s.shouldDropCompletedExecutionStreamEvent(payload) {
return
}
if s.messageCreator == nil {
return
@@ -369,11 +415,25 @@ func (s *Service) handleToolUpdateEvent(ctx context.Context, payload *lifecycle.
// see comment in handleToolCallEvent for the REVIEW/RUNNING split bug.
if payload.Data.ToolStatus == agentEventComplete || payload.Data.ToolStatus == agentEventCompleted ||
payload.Data.ToolStatus == "success" || payload.Data.ToolStatus == agentEventError || payload.Data.ToolStatus == agentEventFailed {
s.setSessionRunning(ctx, payload.TaskID, payload.SessionID)
s.setSessionRunningForExecution(ctx, payload.TaskID, payload.SessionID, payload.ExecutionID)
}
}
}
func (s *Service) shouldDropCompletedExecutionStreamEvent(payload *lifecycle.AgentStreamEventPayload) bool {
if payload == nil || payload.ExecutionID == "" || payload.SessionID == "" {
return false
}
if !s.isExecutionCompleted(payload.SessionID, payload.ExecutionID) {
return false
}
s.logger.Debug("ignoring stream event from completed execution",
zap.String("task_id", payload.TaskID),
zap.String("session_id", payload.SessionID),
zap.String("agent_execution_id", payload.ExecutionID))
return true
}
// updateTaskSessionState transitions a session to nextState with guard checks.
// When a preloadedSession is provided, its State is used for guard conditions (terminal-state
// check, same-state check). This is an optimistic fast-path: between load and check another
@@ -540,6 +600,151 @@ func isTerminalSessionState(s models.TaskSessionState) bool {
s == models.TaskSessionStateCancelled
}
const completedExecutionRetention = 10 * time.Minute
type terminalExecutionMarker struct {
expiresAt time.Time
allowCompleteStream bool
turnID string
}
func terminalExecutionKey(sessionID, executionID string) string {
return sessionID + "\x00" + executionID
}
func (s *Service) markExecutionCompleted(sessionID, executionID string) {
s.markTerminalExecution(sessionID, executionID, true)
}
func (s *Service) markExecutionFailed(sessionID, executionID string) {
s.markTerminalExecution(sessionID, executionID, false)
}
func (s *Service) markTerminalExecution(sessionID, executionID string, allowCompleteStream bool) {
if sessionID == "" || executionID == "" {
return
}
key := terminalExecutionKey(sessionID, executionID)
expiresAt := time.Now().Add(completedExecutionRetention)
s.completedExecutions.Store(key, terminalExecutionMarker{
expiresAt: expiresAt,
allowCompleteStream: allowCompleteStream,
turnID: s.currentTurnIDForSession(context.Background(), sessionID),
})
time.AfterFunc(completedExecutionRetention, func() {
s.deleteCompletedExecutionIfExpired(key, expiresAt)
})
}
func (s *Service) isExecutionCompleted(sessionID, executionID string) bool {
_, ok := s.terminalExecutionMarker(sessionID, executionID)
return ok
}
func (s *Service) terminalCompleteStreamMarker(sessionID, executionID string) (terminalExecutionMarker, bool) {
marker, ok := s.terminalExecutionMarker(sessionID, executionID)
return marker, ok && marker.allowCompleteStream
}
func (s *Service) terminalExecutionMarker(sessionID, executionID string) (terminalExecutionMarker, bool) {
if sessionID == "" || executionID == "" {
return terminalExecutionMarker{}, false
}
key := terminalExecutionKey(sessionID, executionID)
value, ok := s.completedExecutions.Load(key)
if !ok {
return terminalExecutionMarker{}, false
}
marker, ok := value.(terminalExecutionMarker)
if !ok || time.Now().After(marker.expiresAt) {
s.deleteCompletedExecutionIfExpired(key, marker.expiresAt)
return terminalExecutionMarker{}, false
}
return marker, true
}
func (s *Service) currentTurnIDForSession(ctx context.Context, sessionID string) string {
if sessionID == "" {
return ""
}
if turnIDVal, ok := s.activeTurns.Load(sessionID); ok {
if turnID, ok := turnIDVal.(string); ok && turnID != "" {
return turnID
}
}
if s.turnService == nil {
return ""
}
turn, err := s.turnService.GetActiveTurn(ctx, sessionID)
if err != nil || turn == nil {
return ""
}
return turn.ID
}
func (s *Service) deleteCompletedExecutionIfExpired(key string, expiresAt time.Time) {
value, ok := s.completedExecutions.Load(key)
if !ok {
return
}
current, ok := value.(terminalExecutionMarker)
if !ok || !current.expiresAt.After(expiresAt) {
s.completedExecutions.Delete(key)
}
}
func (s *Service) setSessionStarting(ctx context.Context, taskID string, session *models.TaskSession, promoteTask bool) error {
if session == nil {
return nil
}
var publishSession *models.TaskSession
var stateUpdatedAt *time.Time
var oldState models.TaskSessionState
if err := func() error {
s.taskRuntimeStateMu.Lock()
defer s.taskRuntimeStateMu.Unlock()
current, err := s.repo.GetTaskSession(ctx, session.ID)
if err != nil {
return err
}
allowedTerminalRecovery := !promoteTask &&
session.State == models.TaskSessionStateStarting &&
current.State == models.TaskSessionStateFailed
if isTerminalSessionState(current.State) && !allowedTerminalRecovery {
return fmt.Errorf("session %s is %s; cannot mark STARTING", session.ID, current.State)
}
oldState = current.State
if err := s.repo.UpdateTaskSession(ctx, session); err != nil {
return err
}
if oldState != session.State {
if refreshed, err := s.repo.GetTaskSession(ctx, session.ID); err == nil && refreshed != nil {
if !refreshed.UpdatedAt.IsZero() {
t := refreshed.UpdatedAt.UTC()
stateUpdatedAt = &t
}
publishSession = refreshed
}
}
if promoteTask {
s.writeTaskInProgressForRuntime(ctx, taskID, session.ID)
}
return nil
}(); err != nil {
return err
}
if publishSession != nil {
s.publishTaskSessionStateChanged(ctx, taskID, session.ID, oldState, session.State, session.ErrorMessage, stateUpdatedAt, publishSession)
}
return nil
}
func (s *Service) setSessionWaitingForInput(ctx context.Context, taskID, sessionID string, preloadedSession ...*models.TaskSession) {
// Resolve session up front so we can skip the redundant task-state write
// when the session was already WAITING_FOR_INPUT. Without this guard, every
@@ -558,7 +763,7 @@ func (s *Service) setSessionWaitingForInput(ctx context.Context, taskID, session
// write so a transient lookup failure doesn't drop a needed
// REVIEW transition.
s.updateTaskSessionState(ctx, taskID, sessionID, models.TaskSessionStateWaitingForInput, "", false)
s.writeTaskReviewState(ctx, taskID)
s.writeTaskReviewState(ctx, taskID, sessionID)
return
}
}
@@ -574,20 +779,91 @@ func (s *Service) setSessionWaitingForInput(ctx context.Context, taskID, session
return
}
s.writeTaskReviewState(ctx, taskID)
s.writeTaskReviewState(ctx, taskID, sessionID)
}
func (s *Service) writeTaskReviewState(ctx context.Context, taskID string) {
if err := s.taskRepo.UpdateTaskState(ctx, taskID, v1.TaskStateReview); err != nil {
func (s *Service) writeTaskReviewState(ctx context.Context, taskID, completedSessionID string) {
// Task lookup errors fail closed so office/archived guards cannot be bypassed
// by a transient repository failure.
if dbTask, err := s.repo.GetTask(ctx, taskID); err != nil {
s.logger.Warn("failed to load task before REVIEW state reconcile",
zap.String("task_id", taskID),
zap.Error(err))
return
} else if dbTask != nil && dbTask.AssigneeAgentProfileID != "" {
s.logger.Debug("skipping REVIEW transition for office task",
zap.String("task_id", taskID))
return
}
s.taskRuntimeStateMu.Lock()
defer s.taskRuntimeStateMu.Unlock()
if completedSessionID != "" {
if session, err := s.repo.GetTaskSession(ctx, completedSessionID); err == nil && session != nil && isWorkingSessionState(session.State) {
s.logger.Debug("skipping task REVIEW state because completed session is active again",
zap.String("task_id", taskID),
zap.String("session_id", completedSessionID),
zap.String("session_state", string(session.State)))
return
}
}
if blockingSessionID, ok := s.otherWorkingSessionID(ctx, taskID, completedSessionID); !ok {
return
} else if blockingSessionID != "" {
s.logger.Debug("skipping task REVIEW state while another session is working",
zap.String("task_id", taskID),
zap.String("completed_session_id", completedSessionID),
zap.String("blocking_session_id", blockingSessionID))
return
}
updated, err := s.taskRepo.UpdateTaskStateIfCurrentIn(
ctx,
taskID,
v1.TaskStateReview,
[]v1.TaskState{v1.TaskStateInProgress, v1.TaskStateScheduling},
)
if err != nil {
s.logger.Error("failed to update task state to REVIEW",
zap.String("task_id", taskID),
zap.Error(err))
return
}
if !updated {
return
}
s.logger.Info("task moved to REVIEW state",
zap.String("task_id", taskID))
}
func isWorkingSessionState(state models.TaskSessionState) bool {
return sessionstate.IsWorking(state)
}
func (s *Service) otherWorkingSessionID(ctx context.Context, taskID, currentSessionID string) (string, bool) {
sessions, err := s.repo.ListTaskSessions(ctx, taskID)
if err != nil {
s.logger.Warn("failed to list task sessions before REVIEW state reconcile",
zap.String("task_id", taskID),
zap.String("session_id", currentSessionID),
zap.Error(err))
return "", false
}
for _, session := range sessions {
if session == nil {
continue
}
if currentSessionID != "" && session.ID == currentSessionID {
continue
}
if isWorkingSessionState(session.State) {
return session.ID, true
}
}
return "", true
}
// writeTaskReviewStateOnCancel clears the kanban "actively working" task
// states when the user cancels a turn mid-flight by landing the task in
// REVIEW — the same bucket a normal turn completion uses, so the sidebar
@@ -595,7 +871,7 @@ func (s *Service) writeTaskReviewState(ctx context.Context, taskID string) {
// Office task status reflects workflow position, not runtime cancel, so those
// tasks are left alone. Only actively-working tasks are reconciled; tasks
// already past IN_PROGRESS / SCHEDULING keep their state.
func (s *Service) writeTaskReviewStateOnCancel(ctx context.Context, taskID string) {
func (s *Service) writeTaskReviewStateOnCancel(ctx context.Context, taskID, sessionID string) {
dbTask, err := s.repo.GetTask(ctx, taskID)
if err != nil || dbTask == nil {
if err != nil {
@@ -609,6 +885,29 @@ func (s *Service) writeTaskReviewStateOnCancel(ctx context.Context, taskID strin
return
}
s.taskRuntimeStateMu.Lock()
defer s.taskRuntimeStateMu.Unlock()
if sessionID != "" {
if session, err := s.repo.GetTaskSession(ctx, sessionID); err == nil && session != nil && isWorkingSessionState(session.State) {
s.logger.Debug("skipping task REVIEW state after cancel because session is active again",
zap.String("task_id", taskID),
zap.String("session_id", sessionID),
zap.String("session_state", string(session.State)))
return
}
}
if blockingSessionID, ok := s.otherWorkingSessionID(ctx, taskID, sessionID); !ok {
return
} else if blockingSessionID != "" {
s.logger.Debug("skipping task REVIEW state after cancel while another session is working",
zap.String("task_id", taskID),
zap.String("session_id", sessionID),
zap.String("blocking_session_id", blockingSessionID))
return
}
updated, err := s.taskRepo.UpdateTaskStateIfCurrentIn(
ctx,
taskID,
@@ -629,6 +928,13 @@ func (s *Service) writeTaskReviewStateOnCancel(ctx context.Context, taskID strin
}
func (s *Service) setSessionRunning(ctx context.Context, taskID, sessionID string, preloadedSession ...*models.TaskSession) {
s.setSessionRunningForExecution(ctx, taskID, sessionID, "", preloadedSession...)
}
func (s *Service) setSessionRunningForExecution(ctx context.Context, taskID, sessionID, executionID string, preloadedSession ...*models.TaskSession) {
s.taskRuntimeStateMu.Lock()
defer s.taskRuntimeStateMu.Unlock()
// Resolve session up front so we can guard the task write against terminal
// states. updateTaskSessionState silently no-ops for terminal sessions, so
// without this guard a buffered tool event arriving after a CANCELLED /
@@ -646,6 +952,13 @@ func (s *Service) setSessionRunning(ctx context.Context, taskID, sessionID strin
if isTerminalSessionState(session.State) {
return
}
if session.State == models.TaskSessionStateWaitingForInput && s.isExecutionCompleted(sessionID, executionID) {
s.logger.Debug("ignoring stream event for completed execution",
zap.String("task_id", taskID),
zap.String("session_id", sessionID),
zap.String("agent_execution_id", executionID))
return
}
// Skip the redundant task-state write when the session is already RUNNING.
// Tool calls fire many events per turn and each was triggering an
@@ -663,6 +976,24 @@ func (s *Service) setSessionRunning(ctx context.Context, taskID, sessionID strin
return
}
s.writeTaskInProgressForRuntime(ctx, taskID, sessionID)
}
func (s *Service) writeTaskInProgressForRuntime(ctx context.Context, taskID, sessionID string) {
// Task lookup errors fail closed so office/archived guards cannot be bypassed
// by a transient repository failure.
task, err := s.repo.GetTask(ctx, taskID)
if err != nil {
s.logger.Warn("skipping IN_PROGRESS transition because task lookup failed",
zap.String("task_id", taskID),
zap.Error(err))
return
}
if task != nil && task.ArchivedAt != nil {
s.logger.Debug("skipping IN_PROGRESS transition for archived task",
zap.String("task_id", taskID))
return
}
// Office tasks do NOT transition to IN_PROGRESS when their agent's
// session enters RUNNING. The user-facing task status (todo /
// in_review / done / blocked) reflects workflow position, not the
@@ -670,11 +1001,32 @@ func (s *Service) setSessionRunning(ctx context.Context, taskID, sessionID strin
// spinner and the inline session timeline entry instead. Skipping
// the transition prevents the REVIEW → IN_PROGRESS → REVIEW flicker
// across follow-up comment turns.
if dbTask, err := s.repo.GetTask(ctx, taskID); err == nil && dbTask != nil && dbTask.AssigneeAgentProfileID != "" {
if task != nil && task.AssigneeAgentProfileID != "" {
s.logger.Debug("skipping IN_PROGRESS transition for office task",
zap.String("task_id", taskID))
return
}
if sessionID != "" {
session, sessionErr := s.repo.GetTaskSession(ctx, sessionID)
if sessionErr != nil {
s.logger.Warn("skipping IN_PROGRESS transition because session lookup failed",
zap.String("task_id", taskID),
zap.String("session_id", sessionID),
zap.Error(sessionErr))
return
}
if session == nil || !sessionstate.IsWorking(session.State) {
state := ""
if session != nil {
state = string(session.State)
}
s.logger.Debug("skipping IN_PROGRESS transition because session is no longer active",
zap.String("task_id", taskID),
zap.String("session_id", sessionID),
zap.String("session_state", state))
return
}
}
if err := s.taskRepo.UpdateTaskState(ctx, taskID, v1.TaskStateInProgress); err != nil {
s.logger.Error("failed to update task state to IN_PROGRESS",
@@ -691,6 +1043,7 @@ func (s *Service) handleCompleteStreamEvent(ctx context.Context, payload *lifecy
s.logger.Debug("handling complete stream event",
zap.String("task_id", payload.TaskID),
zap.String("session_id", payload.SessionID))
terminalMarker, terminalCompleteStream := s.terminalCompleteStreamMarker(payload.SessionID, payload.ExecutionID)
// Load session once up front — used by storeResumeToken, state check, and setSessionWaitingForInput.
var session *models.TaskSession
@@ -717,10 +1070,27 @@ func (s *Service) handleCompleteStreamEvent(ctx context.Context, payload *lifecy
s.storeResumeToken(ctx, payload.TaskID, payload.SessionID, payload.ExecutionID, payload.Data.ACPSessionID, lastMsgUUID)
}
s.publishPromptUsage(ctx, payload, session)
if terminalCompleteStream {
s.saveAgentTextForTurn(ctx, payload, terminalMarker.turnID)
s.publishAgentPlanForTurn(ctx, payload, terminalMarker.turnID, false)
s.persistTurnPromptMetadataForTurn(ctx, payload, session, terminalMarker.turnID)
if terminalMarker.turnID != "" {
s.publishAgentTurnCompleteForTurn(ctx, payload, terminalMarker.turnID)
}
s.detachClarificationWaiters(ctx, payload.SessionID)
s.logger.Debug("complete stream from terminal execution flushed final data; skipping active turn and runtime reconciliation",
zap.String("task_id", payload.TaskID),
zap.String("session_id", payload.SessionID),
zap.String("agent_execution_id", payload.ExecutionID),
zap.String("turn_id", terminalMarker.turnID))
return
}
s.saveAgentTextIfPresent(ctx, payload)
s.publishAgentPlanIfPresent(ctx, payload)
s.persistTurnPromptMetadata(ctx, payload, session)
s.publishPromptUsage(ctx, payload, session)
s.completeTurnForSession(ctx, payload.SessionID)
// Publish agent turn message event so the office comment bridge can
@@ -732,13 +1102,7 @@ func (s *Service) handleCompleteStreamEvent(ctx context.Context, payload *lifecy
// Detach any pending clarifications so WaitForResponse unblocks while the
// overlay stays interactive for a deferred answer via the event fallback path.
if s.clarificationCanceller != nil && payload.SessionID != "" {
if n := s.clarificationCanceller.DetachSessionAndNotify(ctx, payload.SessionID); n > 0 {
s.logger.Info("detached pending clarifications on turn complete",
zap.String("session_id", payload.SessionID),
zap.Int("count", n))
}
}
s.detachClarificationWaiters(ctx, payload.SessionID)
// Capture a fresh git status snapshot on every turn completion so the sidebar
// diff badge stays current even when the agent remains running (the
@@ -803,6 +1167,17 @@ func (s *Service) handleCompleteStreamEvent(ctx context.Context, payload *lifecy
s.setSessionWaitingForInput(ctx, payload.TaskID, payload.SessionID, session)
}
func (s *Service) detachClarificationWaiters(ctx context.Context, sessionID string) {
if s.clarificationCanceller == nil || sessionID == "" {
return
}
if n := s.clarificationCanceller.DetachSessionAndNotify(ctx, sessionID); n > 0 {
s.logger.Info("detached pending clarifications on turn complete",
zap.String("session_id", sessionID),
zap.Int("count", n))
}
}
// sessionStateString renders a session's state for logging, returning "" when
// the session is nil (e.g. a complete event with no session ID). Kept tiny so
// state-transition trace logs don't add branching to their hot-path callers.
@@ -927,6 +1302,10 @@ func (s *Service) handleAgentPlanEvent(ctx context.Context, payload *lifecycle.A
// publishAgentPlanIfPresent extracts plan_content from a complete event and creates
// a dedicated agent_plan message in the session.
func (s *Service) publishAgentPlanIfPresent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) {
s.publishAgentPlanForTurn(ctx, payload, "", true)
}
func (s *Service) publishAgentPlanForTurn(ctx context.Context, payload *lifecycle.AgentStreamEventPayload, turnID string, allowLazyTurn bool) {
if payload.SessionID == "" || payload.Data.Data == nil || s.messageCreator == nil {
return
}
@@ -940,9 +1319,15 @@ func (s *Service) publishAgentPlanIfPresent(ctx context.Context, payload *lifecy
}
sessionID := payload.SessionID
if turnID == "" && allowLazyTurn {
turnID = s.getActiveTurnID(sessionID)
}
if turnID == "" {
return
}
if err := s.messageCreator.CreateSessionMessage(
ctx, payload.TaskID, planContent, sessionID,
string(models.MessageTypeAgentPlan), s.getActiveTurnID(sessionID), nil, false,
string(models.MessageTypeAgentPlan), turnID, nil, false,
); err != nil {
s.logger.Error("failed to create agent plan message",
zap.String("task_id", payload.TaskID),
@@ -1021,6 +1406,38 @@ func (s *Service) persistTurnPromptMetadata(
if turn == nil {
return
}
s.persistPromptMetadataOnTurn(ctx, payload, session, turn)
}
func (s *Service) persistTurnPromptMetadataForTurn(
ctx context.Context,
payload *lifecycle.AgentStreamEventPayload,
session *models.TaskSession,
turnID string,
) {
if payload == nil || payload.Data == nil || payload.SessionID == "" || payload.Data.Usage == nil || s.turnService == nil || turnID == "" {
return
}
turn, err := s.turnService.GetTurn(ctx, turnID)
if err != nil {
s.logger.Warn("failed to get terminal turn for prompt usage metadata",
zap.String("turn_id", turnID),
zap.String("session_id", payload.SessionID),
zap.Error(err))
return
}
if turn == nil {
return
}
s.persistPromptMetadataOnTurn(ctx, payload, session, turn)
}
func (s *Service) persistPromptMetadataOnTurn(
ctx context.Context,
payload *lifecycle.AgentStreamEventPayload,
session *models.TaskSession,
turn *models.Turn,
) {
model, agentType := resolvePromptUsageLabels(payload, session)
metadata := turn.Metadata
if metadata == nil {
@@ -13,6 +13,7 @@ import (
"github.com/kandev/kandev/internal/automation"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/orchestrator/watcher"
"github.com/kandev/kandev/internal/task/models"
sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite"
v1 "github.com/kandev/kandev/pkg/api/v1"
@@ -28,6 +29,23 @@ type recordedEvent struct {
event *bus.Event
}
type recordingClarificationCanceller struct {
sessions []string
}
func (c *recordingClarificationCanceller) DetachSessionAndNotify(_ context.Context, sessionID string) int {
c.sessions = append(c.sessions, sessionID)
return 1
}
type listTaskSessionsErrorRepo struct {
sessionExecutorStore
}
func (r listTaskSessionsErrorRepo) ListTaskSessions(context.Context, string) ([]*models.TaskSession, error) {
return nil, errors.New("list task sessions failed")
}
type failSetSessionMetadataRepo struct {
repoStore
}
@@ -499,6 +517,603 @@ func TestToolEventsWakeSessionAndTaskTogether(t *testing.T) {
}
}
func TestToolUpdateFromCompletedExecutionDoesNotWakeWaitingSession(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "")
seedExecutorRunning(t, repo, "s1", "t1", "exec-1")
taskRepo := newMockTaskRepo()
seedMockTaskState(taskRepo, "t1", v1.TaskStateInProgress)
agentMgr := &mockAgentManager{repoForExecutionLookup: repo}
svc := createTestServiceWithScheduler(repo, newMockStepGetter(), taskRepo, agentMgr)
svc.messageCreator = &mockMessageCreator{}
svc.handleAgentCompleted(ctx, watcher.AgentEventData{
TaskID: "t1",
SessionID: "s1",
AgentExecutionID: "exec-1",
})
updated, err := repo.GetTaskSession(ctx, "s1")
require.NoError(t, err)
require.Equal(t, models.TaskSessionStateWaitingForInput, updated.State)
require.Equal(t, v1.TaskStateReview, taskRepo.updatedStates["t1"])
svc.handleToolUpdateEvent(ctx, &lifecycle.AgentStreamEventPayload{
TaskID: "t1",
SessionID: "s1",
ExecutionID: "exec-1",
Data: &lifecycle.AgentStreamEventData{
ToolCallID: "tc1",
ToolStatus: agentEventComplete,
},
})
updated, err = repo.GetTaskSession(ctx, "s1")
require.NoError(t, err)
require.Equal(t, models.TaskSessionStateWaitingForInput, updated.State,
"late tool events from the completed execution must not revive the session")
require.Equal(t, 1, taskRepo.stateWrites["t1"],
"late completed-execution tool event must not move the task back to IN_PROGRESS")
}
func TestToolUpdateFromCompletedExecutionDoesNotCreateMessage(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "")
taskRepo := newMockTaskRepo()
svc := createTestServiceWithScheduler(repo, newMockStepGetter(), taskRepo, &mockAgentManager{})
messages := &mockMessageCreator{}
svc.messageCreator = messages
svc.markExecutionCompleted("s1", "exec-1")
svc.handleToolUpdateEvent(ctx, &lifecycle.AgentStreamEventPayload{
TaskID: "t1",
SessionID: "s1",
ExecutionID: "exec-1",
Data: &lifecycle.AgentStreamEventData{
ToolCallID: "tc1",
ToolStatus: agentEventComplete,
},
})
require.Zero(t, messages.toolUpdateWrites,
"stale completed-execution tool updates must be dropped before message side effects")
}
func TestToolCallFromCompletedExecutionDoesNotCreateMessage(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "")
taskRepo := newMockTaskRepo()
svc := createTestServiceWithScheduler(repo, newMockStepGetter(), taskRepo, &mockAgentManager{})
messages := &mockMessageCreator{}
svc.messageCreator = messages
svc.markExecutionCompleted("s1", "exec-1")
svc.handleToolCallEvent(ctx, &lifecycle.AgentStreamEventPayload{
TaskID: "t1",
SessionID: "s1",
ExecutionID: "exec-1",
Data: &lifecycle.AgentStreamEventData{
ToolCallID: "tc1",
ToolStatus: "running",
},
})
require.Zero(t, messages.toolCallWrites,
"stale completed-execution tool calls must be dropped before message side effects")
}
func TestToolCallStreamFromCompletedExecutionDoesNotSaveAgentText(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "")
taskRepo := newMockTaskRepo()
taskRepo.tasks["t1"] = &v1.Task{ID: "t1", State: v1.TaskStateInProgress}
svc := createTestService(repo, newMockStepGetter(), taskRepo)
messages := &mockMessageCreator{}
svc.messageCreator = messages
svc.markExecutionCompleted("s1", "exec-1")
svc.handleAgentStreamEvent(ctx, &lifecycle.AgentStreamEventPayload{
TaskID: "t1",
SessionID: "s1",
ExecutionID: "exec-1",
Data: &lifecycle.AgentStreamEventData{
Type: agentEventToolCall,
Text: "stale text from completed execution",
ToolCallID: "tc1",
ToolStatus: "running",
},
})
require.Zero(t, messages.agentMessageWrites,
"top-level tool_call guard must run before saveAgentTextIfPresent")
require.Zero(t, messages.toolCallWrites,
"top-level tool_call guard must not fall through to handleToolCallEvent")
}
func TestCompleteStreamFromCompletedExecutionFlushesAgentText(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "")
taskRepo := newMockTaskRepo()
seedMockTaskState(taskRepo, "t1", v1.TaskStateInProgress)
svc := createTestService(repo, newMockStepGetter(), taskRepo)
svc.turnService = &repoTurnService{repo: repo}
messages := &mockMessageCreator{}
svc.messageCreator = messages
firstTurn, err := svc.turnService.StartTurn(ctx, "s1")
require.NoError(t, err)
svc.markExecutionCompleted("s1", "exec-1")
svc.completeTurnForSession(ctx, "s1")
nextTurn, err := svc.turnService.StartTurn(ctx, "s1")
require.NoError(t, err)
svc.handleAgentStreamEvent(ctx, &lifecycle.AgentStreamEventPayload{
TaskID: "t1",
SessionID: "s1",
ExecutionID: "exec-1",
Data: &lifecycle.AgentStreamEventData{
Type: agentEventComplete,
Text: "final flushed text",
},
})
require.Equal(t, 1, messages.agentMessageWrites,
"final complete streams must flush text even if agent.completed arrived first")
require.Equal(t, firstTurn.ID, messages.agentMessages[0].turnID,
"late terminal complete must write to the turn that belonged to the terminal execution")
activeTurn, err := svc.turnService.GetActiveTurn(ctx, "s1")
require.NoError(t, err)
require.NotNil(t, activeTurn)
require.Equal(t, nextTurn.ID, activeTurn.ID,
"late terminal complete must not close a newer active turn")
require.Zero(t, taskRepo.stateWrites["t1"],
"final complete streams from terminal executions must not re-run task state reconciliation")
}
func TestCompleteStreamFromCompletedExecutionPersistsTerminalTurnMetadata(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "")
taskRepo := newMockTaskRepo()
svc := createTestService(repo, newMockStepGetter(), taskRepo)
svc.turnService = &repoTurnService{repo: repo}
firstTurn, err := svc.turnService.StartTurn(ctx, "s1")
require.NoError(t, err)
svc.markExecutionCompleted("s1", "exec-1")
svc.completeTurnForSession(ctx, "s1")
nextTurn, err := svc.turnService.StartTurn(ctx, "s1")
require.NoError(t, err)
svc.handleAgentStreamEvent(ctx, &lifecycle.AgentStreamEventPayload{
TaskID: "t1",
SessionID: "s1",
ExecutionID: "exec-1",
AgentID: "agent-1",
Data: &lifecycle.AgentStreamEventData{
Type: agentEventComplete,
CurrentModelID: "gpt-5.5",
Usage: &streams.PromptUsage{
InputTokens: 10,
OutputTokens: 20,
TotalTokens: 42,
ProviderReportedCostSubcents: 123,
},
},
})
terminalTurn, err := repo.GetTurn(ctx, firstTurn.ID)
require.NoError(t, err)
require.Equal(t, "gpt-5.5", terminalTurn.Metadata["model"])
require.Equal(t, "agent-1", terminalTurn.Metadata["agent_id"])
usage, ok := terminalTurn.Metadata["prompt_usage"].(map[string]interface{})
require.True(t, ok)
require.Equal(t, float64(42), usage["total_tokens"])
require.Equal(t, float64(123), usage["provider_reported_cost_subcents"])
activeTurn, err := repo.GetTurn(ctx, nextTurn.ID)
require.NoError(t, err)
require.NotContains(t, activeTurn.Metadata, "prompt_usage",
"late terminal complete metadata must not be written to a newer active turn")
}
func TestCompleteStreamFromCompletedExecutionPublishesTerminalTurn(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "")
eb := &recordingEventBus{}
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
svc.eventBus = eb
svc.turnService = &repoTurnService{repo: repo}
firstTurn, err := svc.turnService.StartTurn(ctx, "s1")
require.NoError(t, err)
svc.markExecutionCompleted("s1", "exec-1")
svc.completeTurnForSession(ctx, "s1")
_, err = svc.turnService.StartTurn(ctx, "s1")
require.NoError(t, err)
svc.handleAgentStreamEvent(ctx, &lifecycle.AgentStreamEventPayload{
TaskID: "t1",
SessionID: "s1",
ExecutionID: "exec-1",
Data: &lifecycle.AgentStreamEventData{
Type: agentEventComplete,
},
})
require.Len(t, eb.events, 1)
require.Equal(t, events.AgentTurnMessageSaved, eb.events[0].subject)
data, ok := eb.events[0].event.Data.(map[string]string)
require.True(t, ok)
require.Equal(t, firstTurn.ID, data["turn_id"],
"late terminal complete publish must identify the completed turn")
}
func TestCompleteStreamFromCompletedExecutionSkipsDuplicateOfficeTeardown(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedOfficeSession(t, repo, "t-office-terminal", "s-office-terminal", "exec-office-terminal")
taskRepo := newMockTaskRepo()
mgr := &mockAgentManager{}
mgr.stopAgentArgs = append(mgr.stopAgentArgs, stopAgentCall{ExecutionID: "exec-office-terminal"})
svc := createTestServiceWithAgent(repo, newMockStepGetter(), taskRepo, mgr)
svc.markExecutionCompleted("s-office-terminal", "exec-office-terminal")
svc.handleAgentStreamEvent(ctx, &lifecycle.AgentStreamEventPayload{
TaskID: "t-office-terminal",
SessionID: "s-office-terminal",
ExecutionID: "exec-office-terminal",
Data: &lifecycle.AgentStreamEventData{
Type: agentEventComplete,
Data: map[string]interface{}{
"stop_reason": "end_turn",
},
},
})
mgr.mu.Lock()
stopCalls := append([]stopAgentCall(nil), mgr.stopAgentArgs...)
mgr.mu.Unlock()
require.Equal(t, []stopAgentCall{{ExecutionID: "exec-office-terminal"}}, stopCalls,
"terminal complete streams must not re-run office StopAgent teardown")
require.Zero(t, taskRepo.stateWrites["t-office-terminal"],
"terminal complete streams must not re-run task state reconciliation")
}
func TestCompleteStreamFromCompletedExecutionSkipsDuplicateAutomationFinalize(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedRunModeAutomationSession(t, repo, "t-auto-terminal", "s-auto-terminal", "exec-auto-terminal")
taskRepo := newMockTaskRepo()
mgr := &mockAgentManager{}
automationSvc := &mockAutomationRunService{succeededTaskIDs: []string{"t-auto-terminal"}}
svc := createTestServiceWithAgent(repo, newMockStepGetter(), taskRepo, mgr)
svc.SetAutomationService(automationSvc)
svc.markExecutionCompleted("s-auto-terminal", "exec-auto-terminal")
svc.handleAgentStreamEvent(ctx, &lifecycle.AgentStreamEventPayload{
TaskID: "t-auto-terminal",
SessionID: "s-auto-terminal",
ExecutionID: "exec-auto-terminal",
Data: &lifecycle.AgentStreamEventData{
Type: agentEventComplete,
Data: map[string]interface{}{
"stop_reason": "end_turn",
},
},
})
require.Equal(t, []string{"t-auto-terminal"}, automationSvc.succeededTaskIDs,
"terminal complete streams must not re-run automation success finalization")
require.Empty(t, automationSvc.failedTaskIDs)
mgr.mu.Lock()
stopCalls := append([]stopAgentCall(nil), mgr.stopAgentArgs...)
mgr.mu.Unlock()
require.Empty(t, stopCalls,
"terminal complete streams must not re-run automation StopAgent teardown")
}
func TestMessageStreamFromCompletedExecutionDoesNotCreateTurn(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "")
taskRepo := newMockTaskRepo()
svc := createTestService(repo, newMockStepGetter(), taskRepo)
svc.turnService = &repoTurnService{repo: repo}
messages := &mockMessageCreator{}
svc.messageCreator = messages
svc.markExecutionCompleted("s1", "exec-1")
svc.handleAgentStreamEvent(ctx, &lifecycle.AgentStreamEventPayload{
TaskID: "t1",
SessionID: "s1",
ExecutionID: "exec-1",
Data: &lifecycle.AgentStreamEventData{
Type: "message_streaming",
MessageID: "msg-1",
Text: "stale text from completed execution",
},
})
require.Zero(t, messages.agentStreamWrites,
"stale completed-execution message streams must be dropped before message side effects")
turn, err := svc.turnService.GetActiveTurn(ctx, "s1")
require.NoError(t, err)
require.Nil(t, turn, "stale stream events must not lazily create a new turn")
}
func TestCompletedExecutionStreamDoesNotCancelClarificationWatchdog(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "")
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
watchCtx, cancel := context.WithCancel(context.Background())
defer cancel()
svc.clarificationWatchdogs.Store(
svc.clarificationWatchdogKey("s1", "pending-1"),
&clarificationWatchdogEntry{cancel: cancel},
)
svc.markExecutionCompleted("s1", "exec-1")
svc.handleAgentStreamEvent(ctx, &lifecycle.AgentStreamEventPayload{
TaskID: "t1",
SessionID: "s1",
ExecutionID: "exec-1",
Data: &lifecycle.AgentStreamEventData{
Type: "tool_update",
ToolCallID: "tc1",
ToolStatus: agentEventComplete,
},
})
require.Equal(t, 1, countClarificationWatchdogs(svc),
"stale completed-execution streams must be dropped before watchdog cancellation")
select {
case <-watchCtx.Done():
t.Fatal("stale completed-execution stream cancelled clarification watchdog")
default:
}
}
func TestTerminalCompleteStreamDoesNotCancelClarificationWatchdog(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "")
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
watchCtx, cancel := context.WithCancel(context.Background())
defer cancel()
svc.clarificationWatchdogs.Store(
svc.clarificationWatchdogKey("s1", "pending-1"),
&clarificationWatchdogEntry{cancel: cancel},
)
svc.markExecutionCompleted("s1", "exec-1")
svc.handleAgentStreamEvent(ctx, &lifecycle.AgentStreamEventPayload{
TaskID: "t1",
SessionID: "s1",
ExecutionID: "exec-1",
Data: &lifecycle.AgentStreamEventData{
Type: agentEventComplete,
Text: "late complete",
},
})
require.Equal(t, 1, countClarificationWatchdogs(svc),
"late terminal complete streams must not cancel clarification watchdogs")
select {
case <-watchCtx.Done():
t.Fatal("late terminal complete cancelled clarification watchdog")
default:
}
}
func TestTerminalCompleteStreamDetachesClarificationWaitersWithoutCancellingWatchdog(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "")
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
canceller := &recordingClarificationCanceller{}
svc.clarificationCanceller = canceller
watchCtx, cancel := context.WithCancel(context.Background())
defer cancel()
svc.clarificationWatchdogs.Store(
svc.clarificationWatchdogKey("s1", "pending-1"),
&clarificationWatchdogEntry{cancel: cancel},
)
svc.markExecutionCompleted("s1", "exec-1")
svc.handleAgentStreamEvent(ctx, &lifecycle.AgentStreamEventPayload{
TaskID: "t1",
SessionID: "s1",
ExecutionID: "exec-1",
Data: &lifecycle.AgentStreamEventData{
Type: agentEventComplete,
},
})
require.Equal(t, []string{"s1"}, canceller.sessions)
require.Equal(t, 1, countClarificationWatchdogs(svc),
"late terminal complete should detach waiters without cancelling watchdog fallback entries")
select {
case <-watchCtx.Done():
t.Fatal("late terminal complete cancelled clarification watchdog")
default:
}
}
func TestWriteTaskReviewStateSkipsWhenSessionListFails(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "")
session, err := repo.GetTaskSession(ctx, "s1")
require.NoError(t, err)
session.State = models.TaskSessionStateWaitingForInput
require.NoError(t, repo.UpdateTaskSession(ctx, session))
taskRepo := newMockTaskRepo()
svc := createTestService(repo, newMockStepGetter(), taskRepo)
svc.repo = listTaskSessionsErrorRepo{sessionExecutorStore: svc.repo}
svc.writeTaskReviewState(ctx, "t1", "s1")
require.Zero(t, taskRepo.stateWrites["t1"],
"REVIEW writes must fail closed when sibling session reconciliation fails")
}
func TestWriteTaskReviewStateSkipsTerminalTaskState(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "")
session, err := repo.GetTaskSession(ctx, "s1")
require.NoError(t, err)
session.State = models.TaskSessionStateWaitingForInput
require.NoError(t, repo.UpdateTaskSession(ctx, session))
task, err := repo.GetTask(ctx, "t1")
require.NoError(t, err)
task.State = v1.TaskStateCompleted
require.NoError(t, repo.UpdateTask(ctx, task))
taskRepo := newMockTaskRepo()
taskRepo.tasks["t1"] = &v1.Task{ID: "t1", State: v1.TaskStateCompleted}
svc := createTestService(repo, newMockStepGetter(), taskRepo)
svc.writeTaskReviewState(ctx, "t1", "s1")
require.Zero(t, taskRepo.stateWrites["t1"],
"REVIEW reconcile must not rewind terminal task states")
}
func TestWriteTaskInProgressForRuntimeSkipsArchivedTask(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "")
require.NoError(t, repo.ArchiveTask(ctx, "t1"))
taskRepo := newMockTaskRepo()
svc := createTestService(repo, newMockStepGetter(), taskRepo)
svc.writeTaskInProgressForRuntime(ctx, "t1", "s1")
require.Zero(t, taskRepo.stateWrites["t1"],
"runtime reconciliation must not promote archived tasks to IN_PROGRESS")
}
func TestWriteTaskReviewStateOnCancelSkipsWhenSessionActiveAgain(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "")
session, err := repo.GetTaskSession(ctx, "s1")
require.NoError(t, err)
session.State = models.TaskSessionStateRunning
require.NoError(t, repo.UpdateTaskSession(ctx, session))
taskRepo := newMockTaskRepo()
taskRepo.tasks["t1"] = &v1.Task{ID: "t1", State: v1.TaskStateInProgress}
svc := createTestService(repo, newMockStepGetter(), taskRepo)
svc.writeTaskReviewStateOnCancel(ctx, "t1", "s1")
require.Zero(t, taskRepo.stateWrites["t1"],
"cancel reconcile must not move a restarted same session back to REVIEW")
}
func TestWriteTaskReviewStateOnCancelSkipsWhenSessionListFails(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "")
session, err := repo.GetTaskSession(ctx, "s1")
require.NoError(t, err)
session.State = models.TaskSessionStateWaitingForInput
require.NoError(t, repo.UpdateTaskSession(ctx, session))
taskRepo := newMockTaskRepo()
taskRepo.tasks["t1"] = &v1.Task{ID: "t1", State: v1.TaskStateInProgress}
svc := createTestService(repo, newMockStepGetter(), taskRepo)
svc.repo = listTaskSessionsErrorRepo{sessionExecutorStore: svc.repo}
svc.writeTaskReviewStateOnCancel(ctx, "t1", "s1")
require.Zero(t, taskRepo.stateWrites["t1"],
"cancel REVIEW writes must fail closed when sibling session reconciliation fails")
}
func TestToolUpdateFromFailedExecutionDoesNotCreateMessage(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "")
taskRepo := newMockTaskRepo()
svc := createTestServiceWithScheduler(repo, newMockStepGetter(), taskRepo, &mockAgentManager{})
messages := &mockMessageCreator{}
svc.messageCreator = messages
svc.handleAgentFailed(ctx, watcher.AgentEventData{
TaskID: "t1",
SessionID: "s1",
AgentExecutionID: "exec-1",
ErrorMessage: "agent failed",
})
svc.handleToolUpdateEvent(ctx, &lifecycle.AgentStreamEventPayload{
TaskID: "t1",
SessionID: "s1",
ExecutionID: "exec-1",
Data: &lifecycle.AgentStreamEventData{
ToolCallID: "tc1",
ToolStatus: agentEventComplete,
},
})
require.Zero(t, messages.toolUpdateWrites,
"late tool events from a failed execution must be dropped before message side effects")
}
func TestCompletedExecutionMarkerExpiresAndDeletes(t *testing.T) {
svc := &Service{}
svc.markExecutionCompleted("s1", "exec-1")
require.True(t, svc.isExecutionCompleted("s1", "exec-1"))
currentKey := terminalExecutionKey("s1", "exec-1")
expiredAt := time.Now().Add(-time.Minute)
svc.completedExecutions.Store(currentKey, terminalExecutionMarker{
expiresAt: expiredAt,
allowCompleteStream: true,
})
require.False(t, svc.isExecutionCompleted("s1", "exec-1"))
_, ok := svc.completedExecutions.Load(currentKey)
require.False(t, ok, "expired marker should be deleted on lookup")
laterExpiry := time.Now().Add(time.Hour)
svc.completedExecutions.Store(currentKey, terminalExecutionMarker{
expiresAt: laterExpiry,
allowCompleteStream: true,
})
svc.deleteCompletedExecutionIfExpired(currentKey, time.Now())
_, ok = svc.completedExecutions.Load(currentKey)
require.True(t, ok, "old expiry callback must not delete a refreshed marker")
svc.deleteCompletedExecutionIfExpired(currentKey, laterExpiry)
_, ok = svc.completedExecutions.Load(currentKey)
require.False(t, ok, "matching expiry callback should delete the marker")
}
// TestSetSessionRunning_NoRedundantTaskWrites locks in the dedup: when the
// session is already RUNNING, setSessionRunning must not re-write tasks.state.
// Without the guard, every tool_call / tool_update fired UpdateTaskState
@@ -572,6 +1187,134 @@ func TestSetSessionRunning_WritesOnTransition(t *testing.T) {
require.Equal(t, v1.TaskStateInProgress, taskRepo.updatedStates["t1"])
}
func TestSetSessionStartingWritesTaskInProgress(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "step1")
session, err := repo.GetTaskSession(ctx, "s1")
require.NoError(t, err)
session.State = models.TaskSessionStateStarting
session.ErrorMessage = ""
session.UpdatedAt = time.Now().UTC()
taskRepo := newMockTaskRepo()
svc := createTestService(repo, newMockStepGetter(), taskRepo)
require.NoError(t, svc.setSessionStarting(ctx, "t1", session, true))
updated, err := repo.GetTaskSession(ctx, "s1")
require.NoError(t, err)
require.Equal(t, models.TaskSessionStateStarting, updated.State)
require.Equal(t, 1, taskRepo.stateWrites["t1"])
require.Equal(t, v1.TaskStateInProgress, taskRepo.updatedStates["t1"])
}
func TestSetSessionStartingCanDeferTaskInProgress(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "step1")
session, err := repo.GetTaskSession(ctx, "s1")
require.NoError(t, err)
session.State = models.TaskSessionStateStarting
session.ErrorMessage = ""
session.UpdatedAt = time.Now().UTC()
taskRepo := newMockTaskRepo()
svc := createTestService(repo, newMockStepGetter(), taskRepo)
require.NoError(t, svc.setSessionStarting(ctx, "t1", session, false))
updated, err := repo.GetTaskSession(ctx, "s1")
require.NoError(t, err)
require.Equal(t, models.TaskSessionStateStarting, updated.State)
require.Empty(t, taskRepo.stateWrites)
}
func TestSetSessionStartingRejectsTerminalSession(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "step1")
current, err := repo.GetTaskSession(ctx, "s1")
require.NoError(t, err)
current.State = models.TaskSessionStateCancelled
require.NoError(t, repo.UpdateTaskSession(ctx, current))
next := *current
next.State = models.TaskSessionStateStarting
next.ErrorMessage = ""
next.UpdatedAt = time.Now().UTC()
taskRepo := newMockTaskRepo()
svc := createTestService(repo, newMockStepGetter(), taskRepo)
require.Error(t, svc.setSessionStarting(ctx, "t1", &next, true))
updated, err := repo.GetTaskSession(ctx, "s1")
require.NoError(t, err)
require.Equal(t, models.TaskSessionStateCancelled, updated.State)
require.Empty(t, taskRepo.stateWrites)
}
func TestSetSessionStartingRejectsCancelledTerminalResumeWithoutPromotion(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "step1")
current, err := repo.GetTaskSession(ctx, "s1")
require.NoError(t, err)
current.State = models.TaskSessionStateCancelled
require.NoError(t, repo.UpdateTaskSession(ctx, current))
next := *current
next.State = models.TaskSessionStateStarting
next.ErrorMessage = ""
next.UpdatedAt = time.Now().UTC()
taskRepo := newMockTaskRepo()
svc := createTestService(repo, newMockStepGetter(), taskRepo)
require.Error(t, svc.setSessionStarting(ctx, "t1", &next, false))
updated, err := repo.GetTaskSession(ctx, "s1")
require.NoError(t, err)
require.Equal(t, models.TaskSessionStateCancelled, updated.State)
require.Empty(t, taskRepo.stateWrites)
}
func TestSetSessionStartingAllowsTerminalResumeWithoutTaskPromotion(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "step1")
current, err := repo.GetTaskSession(ctx, "s1")
require.NoError(t, err)
current.State = models.TaskSessionStateFailed
current.ErrorMessage = "previous failure"
completedAt := time.Now().UTC()
current.CompletedAt = &completedAt
require.NoError(t, repo.UpdateTaskSession(ctx, current))
next := *current
next.State = models.TaskSessionStateStarting
next.ErrorMessage = ""
next.CompletedAt = nil
next.UpdatedAt = time.Now().UTC()
taskRepo := newMockTaskRepo()
svc := createTestService(repo, newMockStepGetter(), taskRepo)
require.NoError(t, svc.setSessionStarting(ctx, "t1", &next, false))
updated, err := repo.GetTaskSession(ctx, "s1")
require.NoError(t, err)
require.Equal(t, models.TaskSessionStateStarting, updated.State)
require.Nil(t, updated.CompletedAt)
require.Empty(t, taskRepo.stateWrites)
}
// Pins the call-site wiring: cancelled office turn must NOT leave the session at IDLE.
func TestHandleCompleteStreamEvent_CancelledOfficeSessionLandsWaitingForInput(t *testing.T) {
ctx := context.Background()
@@ -848,6 +1591,7 @@ func TestSetSessionWaitingForInput_WritesOnTransition(t *testing.T) {
require.NoError(t, repo.UpdateTaskSession(ctx, session))
taskRepo := newMockTaskRepo()
seedMockTaskState(taskRepo, "t1", v1.TaskStateInProgress)
svc := createTestService(repo, newMockStepGetter(), taskRepo)
svc.setSessionWaitingForInput(ctx, "t1", "s1")
@@ -857,6 +1601,51 @@ func TestSetSessionWaitingForInput_WritesOnTransition(t *testing.T) {
require.Equal(t, v1.TaskStateReview, taskRepo.updatedStates["t1"])
}
func TestSetSessionWaitingForInput_DoesNotMoveTaskToReviewWhileSiblingRuns(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s-finishing", "step1")
now := time.Now().UTC()
require.NoError(t, repo.CreateTaskSession(ctx, &models.TaskSession{
ID: "s-running",
TaskID: "t1",
State: models.TaskSessionStateRunning,
StartedAt: now.Add(time.Second),
UpdatedAt: now.Add(time.Second),
}))
taskRepo := newMockTaskRepo()
svc := createTestService(repo, newMockStepGetter(), taskRepo)
svc.setSessionWaitingForInput(ctx, "t1", "s-finishing")
updatedFinishing, err := repo.GetTaskSession(ctx, "s-finishing")
require.NoError(t, err)
require.Equal(t, models.TaskSessionStateWaitingForInput, updatedFinishing.State)
require.Equal(t, 0, taskRepo.stateWrites["t1"],
"finishing one session must not move the task to REVIEW while another session is running")
}
func TestWriteTaskReviewState_DoesNotMoveTaskToReviewWhenSameSessionRestarted(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "step1")
session, err := repo.GetTaskSession(ctx, "s1")
require.NoError(t, err)
session.State = models.TaskSessionStateRunning
require.NoError(t, repo.UpdateTaskSession(ctx, session))
taskRepo := newMockTaskRepo()
svc := createTestService(repo, newMockStepGetter(), taskRepo)
svc.writeTaskReviewState(ctx, "t1", "s1")
require.Empty(t, taskRepo.updatedStates,
"the just-completed session may have restarted before REVIEW reconciliation")
}
func TestSessionStateString(t *testing.T) {
require.Equal(t, "", sessionStateString(nil),
"nil session must render as empty so trace logs stay clean")
@@ -98,6 +98,10 @@ func newMockTaskRepo() *mockTaskRepo {
}
}
func seedMockTaskState(repo *mockTaskRepo, taskID string, state v1.TaskState) {
repo.tasks[taskID] = &v1.Task{ID: taskID, State: state}
}
func (m *mockTaskRepo) GetTask(_ context.Context, taskID string) (*v1.Task, error) {
m.mu.Lock()
defer m.mu.Unlock()
@@ -722,6 +726,7 @@ func TestHandleAgentReadyGuards(t *testing.T) {
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "step1")
taskRepo := newMockTaskRepo()
seedMockTaskState(taskRepo, "t1", v1.TaskStateInProgress)
// Register the workflow step so processOnTurnComplete can resolve it.
stepGetter := newMockStepGetter()
@@ -1046,6 +1051,250 @@ func TestHandleAgentCompleted_CleansUpExecution(t *testing.T) {
}
}
func TestHandleAgentCompleted_MarksRotatedExecutionCompleted(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "")
seedExecutorRunning(t, repo, "s1", "t1", "exec-live")
taskRepo := newMockTaskRepo()
agentMgr := &mockAgentManager{repoForExecutionLookup: repo}
svc := createTestServiceWithScheduler(repo, newMockStepGetter(), taskRepo, agentMgr)
svc.handleAgentCompleted(ctx, watcher.AgentEventData{
TaskID: "t1",
SessionID: "s1",
AgentExecutionID: "exec-old",
})
waitForStopCall(t, agentMgr)
if !svc.isExecutionCompleted("s1", "exec-old") {
t.Fatal("rotated agent.completed events must still block late tool events from the old execution")
}
}
func TestHandleAgentCompleted_DoesNotMoveTaskToReviewWhileSiblingRuns(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s-finishing", "")
seedExecutorRunning(t, repo, "s-finishing", "t1", "exec-finishing")
now := time.Now().UTC()
if err := repo.CreateTaskSession(ctx, &models.TaskSession{
ID: "s-running",
TaskID: "t1",
State: models.TaskSessionStateRunning,
StartedAt: now.Add(time.Second),
UpdatedAt: now.Add(time.Second),
}); err != nil {
t.Fatalf("failed to create running sibling session: %v", err)
}
taskRepo := newMockTaskRepo()
agentMgr := &mockAgentManager{repoForExecutionLookup: repo}
svc := createTestServiceWithScheduler(repo, newMockStepGetter(), taskRepo, agentMgr)
svc.handleAgentCompleted(ctx, watcher.AgentEventData{
TaskID: "t1",
SessionID: "s-finishing",
AgentExecutionID: "exec-finishing",
})
waitForStopCall(t, agentMgr)
if got := taskRepo.stateWrites["t1"]; got != 0 {
t.Fatalf("agent completion must not move the task to REVIEW while another session is running, writes=%d state=%q",
got, taskRepo.updatedStates["t1"])
}
updatedFinishing, err := repo.GetTaskSession(ctx, "s-finishing")
if err != nil {
t.Fatalf("failed to load finishing session: %v", err)
}
if updatedFinishing.State != models.TaskSessionStateWaitingForInput {
t.Fatalf("expected finishing session to leave RUNNING, got %q", updatedFinishing.State)
}
}
func TestHandleAgentCompleted_MovesTaskToReviewWhenLastSiblingFinishes(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s-first", "step1")
seedExecutorRunning(t, repo, "s-first", "t1", "exec-first")
now := time.Now().UTC()
if err := repo.CreateTaskSession(ctx, &models.TaskSession{
ID: "s-last",
TaskID: "t1",
State: models.TaskSessionStateRunning,
StartedAt: now.Add(time.Second),
UpdatedAt: now.Add(time.Second),
}); err != nil {
t.Fatalf("failed to create running sibling session: %v", err)
}
seedExecutorRunning(t, repo, "s-last", "t1", "exec-last")
taskRepo := newMockTaskRepo()
seedMockTaskState(taskRepo, "t1", v1.TaskStateInProgress)
agentMgr := &mockAgentManager{repoForExecutionLookup: repo}
stepGetter := newMockStepGetter()
stepGetter.steps["step1"] = &wfmodels.WorkflowStep{
ID: "step1",
WorkflowID: "wf1",
Name: "Step 1",
Position: 0,
}
svc := createTestServiceWithScheduler(repo, stepGetter, taskRepo, agentMgr)
svc.handleAgentCompleted(ctx, watcher.AgentEventData{
TaskID: "t1",
SessionID: "s-first",
AgentExecutionID: "exec-first",
})
if got := taskRepo.stateWrites["t1"]; got != 0 {
t.Fatalf("first completion must not move task to REVIEW while sibling runs, writes=%d state=%q",
got, taskRepo.updatedStates["t1"])
}
svc.handleAgentCompleted(ctx, watcher.AgentEventData{
TaskID: "t1",
SessionID: "s-last",
AgentExecutionID: "exec-last",
})
if got := taskRepo.stateWrites["t1"]; got != 1 {
t.Fatalf("last sibling completion must move task to REVIEW once, writes=%d state=%q",
got, taskRepo.updatedStates["t1"])
}
if state := taskRepo.updatedStates["t1"]; state != v1.TaskStateReview {
t.Fatalf("expected task state %q, got %q", v1.TaskStateReview, state)
}
}
func TestHandleAgentCompleted_NoWorkflowStepLastSiblingMovesTaskToReview(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s-first", "")
seedExecutorRunning(t, repo, "s-first", "t1", "exec-first")
now := time.Now().UTC()
if err := repo.CreateTaskSession(ctx, &models.TaskSession{
ID: "s-last",
TaskID: "t1",
State: models.TaskSessionStateRunning,
StartedAt: now.Add(time.Second),
UpdatedAt: now.Add(time.Second),
}); err != nil {
t.Fatalf("failed to create running sibling session: %v", err)
}
seedExecutorRunning(t, repo, "s-last", "t1", "exec-last")
taskRepo := newMockTaskRepo()
seedMockTaskState(taskRepo, "t1", v1.TaskStateInProgress)
agentMgr := &mockAgentManager{repoForExecutionLookup: repo}
svc := createTestServiceWithScheduler(repo, newMockStepGetter(), taskRepo, agentMgr)
svc.handleAgentCompleted(ctx, watcher.AgentEventData{
TaskID: "t1",
SessionID: "s-first",
AgentExecutionID: "exec-first",
})
if got := taskRepo.stateWrites["t1"]; got != 0 {
t.Fatalf("first no-step completion must not move task to REVIEW while sibling runs, writes=%d state=%q",
got, taskRepo.updatedStates["t1"])
}
updatedFirst, err := repo.GetTaskSession(ctx, "s-first")
if err != nil {
t.Fatalf("failed to load first session: %v", err)
}
if updatedFirst.State != models.TaskSessionStateWaitingForInput {
t.Fatalf("expected first session state %q, got %q", models.TaskSessionStateWaitingForInput, updatedFirst.State)
}
svc.handleAgentCompleted(ctx, watcher.AgentEventData{
TaskID: "t1",
SessionID: "s-last",
AgentExecutionID: "exec-last",
})
if got := taskRepo.stateWrites["t1"]; got != 1 {
t.Fatalf("last no-step sibling completion must move task to REVIEW once, writes=%d state=%q",
got, taskRepo.updatedStates["t1"])
}
updatedFirst, err = repo.GetTaskSession(ctx, "s-first")
if err != nil {
t.Fatalf("failed to reload first session: %v", err)
}
if updatedFirst.State != models.TaskSessionStateWaitingForInput {
t.Fatalf("expected first session to remain %q, got %q", models.TaskSessionStateWaitingForInput, updatedFirst.State)
}
updatedLast, err := repo.GetTaskSession(ctx, "s-last")
if err != nil {
t.Fatalf("failed to load last session: %v", err)
}
if updatedLast.State != models.TaskSessionStateWaitingForInput {
t.Fatalf("expected last session state %q, got %q", models.TaskSessionStateWaitingForInput, updatedLast.State)
}
}
func TestHandleAgentCompleted_ExitOnlyCompletesActiveTurn(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "task1", "session1", "")
seedExecutorRunning(t, repo, "session1", "task1", "exec-1")
taskRepo := newMockTaskRepo()
agentMgr := &mockAgentManager{repoForExecutionLookup: repo}
svc := createTestServiceWithScheduler(repo, newMockStepGetter(), taskRepo, agentMgr)
svc.turnService = &repoTurnService{repo: repo}
if _, err := svc.turnService.StartTurn(ctx, "session1"); err != nil {
t.Fatalf("failed to seed active turn: %v", err)
}
if open := openTurnCount(t, repo, "session1"); open != 1 {
t.Fatalf("expected 1 open turn before completion, got %d", open)
}
svc.handleAgentCompleted(ctx, watcher.AgentEventData{
TaskID: "task1",
SessionID: "session1",
AgentExecutionID: "exec-1",
})
waitForStopCall(t, agentMgr)
if open := openTurnCount(t, repo, "session1"); open != 0 {
t.Fatalf("expected agent.completed to close the active turn, got %d open turns", open)
}
updated, err := repo.GetTaskSession(ctx, "session1")
if err != nil {
t.Fatalf("failed to load session: %v", err)
}
if updated.State != models.TaskSessionStateWaitingForInput {
t.Fatalf("expected session state %q, got %q", models.TaskSessionStateWaitingForInput, updated.State)
}
}
func TestHandleAgentFailedWithoutSession_DoesNotMoveTaskToReviewWhileSessionRuns(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s-running", "")
taskRepo := newMockTaskRepo()
agentMgr := &mockAgentManager{repoForExecutionLookup: repo}
svc := createTestServiceWithScheduler(repo, newMockStepGetter(), taskRepo, agentMgr)
svc.handleAgentFailed(ctx, watcher.AgentEventData{
TaskID: "t1",
ErrorMessage: "agent failed before session binding",
})
if got := taskRepo.stateWrites["t1"]; got != 0 {
t.Fatalf("sessionless failure must not move the task to REVIEW while a session is running, writes=%d state=%q",
got, taskRepo.updatedStates["t1"])
}
}
func TestHandleAgentFailed_CleansUpExecution(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
@@ -1496,6 +1745,7 @@ func TestHandleRecoverableFailure(t *testing.T) {
seedSession(t, repo, "t1", "s1", "step1")
taskRepo := newMockTaskRepo()
seedMockTaskState(taskRepo, "t1", v1.TaskStateInProgress)
agentMgr := &mockAgentManager{repoForExecutionLookup: repo}
svc := createTestServiceWithScheduler(repo, newMockStepGetter(), taskRepo, agentMgr)
@@ -1603,6 +1853,7 @@ func TestHandleResumeFailure(t *testing.T) {
})
taskRepo := newMockTaskRepo()
seedMockTaskState(taskRepo, "t1", v1.TaskStateInProgress)
svc := createTestService(repo, newMockStepGetter(), taskRepo)
result := svc.handleResumeFailure(ctx, watcher.AgentEventData{
@@ -7,6 +7,7 @@ import (
"testing"
"time"
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
"github.com/kandev/kandev/internal/orchestrator/watcher"
"github.com/kandev/kandev/internal/task/models"
)
@@ -78,6 +79,33 @@ func TestHandleTransientFailure_SchedulesRetryAndEmitsWarning(t *testing.T) {
}
}
func TestTransientFailedExecutionToolUpdateDoesNotCreateMessage(t *testing.T) {
svc, mc := newTransientTestService(t)
t.Cleanup(svc.cancelAllTransientRetries)
svc.handleAgentFailed(context.Background(), watcher.AgentEventData{
TaskID: "t1",
SessionID: "s1",
AgentExecutionID: "exec-1",
ErrorMessage: overloaded529,
})
svc.handleAgentStreamEvent(context.Background(), &lifecycle.AgentStreamEventPayload{
TaskID: "t1",
SessionID: "s1",
ExecutionID: "exec-1",
Data: &lifecycle.AgentStreamEventData{
Type: "tool_update",
ToolCallID: "tc1",
ToolStatus: agentEventComplete,
},
})
if mc.toolUpdateWrites != 0 {
t.Fatalf("expected stale transient-failure tool update to be dropped, got %d writes", mc.toolUpdateWrites)
}
}
func TestHandleTransientFailure_NonTransientReturnsFalse(t *testing.T) {
svc, mc := newTransientTestService(t)
t.Cleanup(svc.cancelAllTransientRetries)
@@ -2159,12 +2159,13 @@ func (s *Service) applyEngineTransition(
// IN_PROGRESS, leaving the spinner spinning in the new column even though
// the agent has paused. If the target step's on_enter starts another agent,
// setSessionRunning will flip tasks.state back to IN_PROGRESS — the
// REVIEW write is a safe intermediate that any active-running follow-up
// will overwrite.
// REVIEW write is a safe intermediate when no sibling session is already
// working; otherwise the task should remain IN_PROGRESS until all active
// agent work has paused.
if session.State == models.TaskSessionStateRunning || session.State == models.TaskSessionStateStarting {
s.updateTaskSessionState(ctx, taskID, session.ID, models.TaskSessionStateWaitingForInput, "", false, session)
session.State = models.TaskSessionStateWaitingForInput
s.writeTaskReviewState(ctx, taskID)
s.writeTaskReviewState(ctx, taskID, session.ID)
}
// Launch processOnEnter asynchronously to avoid blocking the stream reader goroutine.
@@ -494,6 +494,16 @@ type TaskStateChangeFunc func(ctx context.Context, taskID string, state v1.TaskS
// publish events (e.g. WebSocket notifications) alongside the DB update.
type SessionStateChangeFunc func(ctx context.Context, taskID, sessionID string, state models.TaskSessionState, errorMessage string) error
// SessionStartingFunc is called when the executor has prepared/resumed an
// execution and needs to mark the session STARTING while preserving other
// session-row updates such as metadata. promoteTask controls whether the
// callback should also move the parent task to IN_PROGRESS immediately.
type SessionStartingFunc func(ctx context.Context, taskID string, session *models.TaskSession, promoteTask bool) error
// TaskReviewStateReconcileFunc is called when runtime work stopped and the
// parent task should move to REVIEW only if no session is still STARTING/RUNNING.
type TaskReviewStateReconcileFunc func(ctx context.Context, taskID, completedSessionID string)
// AgentStartFailedFunc is called when the agent process fails to start.
// It receives the task/session/execution IDs and the error. fromResume is true
// when the failure occurred during a background session resume (rather than a
@@ -541,6 +551,16 @@ type Executor struct {
// updates the DB and publishes WebSocket events.
onSessionStateChange SessionStateChangeFunc
// Callback for STARTING writes that carry full session-row changes. Set by
// the orchestrator so launch/resume/model-switch transitions serialize with
// runtime task-state reconciliation.
onSessionStarting SessionStartingFunc
// Callback for REVIEW reconciliation after runtime start failures. Set by the
// orchestrator so failed-start writes share the same serialized guard as
// normal turn completion.
onTaskReviewStateReconcile TaskReviewStateReconcileFunc
// Callback for agent process start failures. When set, the executor
// delegates failure handling to this callback, allowing the orchestrator
// to detect auth errors and treat them as recoverable.
@@ -633,6 +653,17 @@ func (e *Executor) SetOnSessionStateChange(fn SessionStateChangeFunc) {
e.onSessionStateChange = fn
}
// SetOnSessionStarting sets a callback for full session-row STARTING updates.
func (e *Executor) SetOnSessionStarting(fn SessionStartingFunc) {
e.onSessionStarting = fn
}
// SetOnTaskReviewStateReconcile sets the guarded task REVIEW reconciliation
// callback used after resume/start failures.
func (e *Executor) SetOnTaskReviewStateReconcile(fn TaskReviewStateReconcileFunc) {
e.onTaskReviewStateReconcile = fn
}
// SetRepoCloner sets the cloner used to clone provider-backed repositories on launch.
func (e *Executor) SetRepoCloner(cloner RepoCloner, updater RepoUpdater) {
e.repoCloner = cloner
@@ -13,6 +13,7 @@ import (
"github.com/google/uuid"
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
"github.com/kandev/kandev/internal/orchestrator/sessionstate"
"github.com/kandev/kandev/internal/sysprompt"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/worktree"
@@ -80,6 +81,8 @@ func (e *Executor) runAgentProcessAsync(ctx context.Context, taskID, sessionID,
zap.String("task_id", taskID),
zap.Error(updateErr))
}
} else {
e.writeTaskReviewStateIfNoWorkingSessions(updateCtx, taskID, sessionID)
}
// Clean up the execution environment (e.g., destroy remote Sprites instance).
// Use force=true since the agent process never fully started.
@@ -106,6 +109,113 @@ func (e *Executor) startAgentProcessAsync(ctx context.Context, taskID, sessionID
}, true, false)
}
func (e *Executor) stopUnstartedExecution(ctx context.Context, sessionID, agentExecutionID string) {
if agentExecutionID == "" {
return
}
stopCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
defer cancel()
if stopErr := e.agentManager.StopAgent(stopCtx, agentExecutionID, true); stopErr != nil {
e.logger.Warn("failed to stop unstarted agent execution",
zap.String("session_id", sessionID),
zap.String("agent_execution_id", agentExecutionID),
zap.Error(stopErr))
}
}
func (e *Executor) writeTaskReviewStateIfNoWorkingSessions(ctx context.Context, taskID, failedSessionID string) {
if e.onTaskReviewStateReconcile != nil {
e.onTaskReviewStateReconcile(ctx, taskID, failedSessionID)
return
}
if e.shouldSkipFailedStartReviewForTask(ctx, taskID, failedSessionID) {
return
}
if e.failedSessionStillWorkingOrUnknown(ctx, taskID, failedSessionID) {
return
}
if e.hasOtherWorkingSessions(ctx, taskID, failedSessionID) {
return
}
if updateErr := e.updateTaskState(ctx, taskID, v1.TaskStateReview); updateErr != nil {
e.logger.Warn("failed to update task state to REVIEW after start error",
zap.String("task_id", taskID),
zap.Error(updateErr))
}
}
func (e *Executor) shouldSkipFailedStartReviewForTask(ctx context.Context, taskID, failedSessionID string) bool {
task, err := e.repo.GetTask(ctx, taskID)
if err != nil {
e.logger.Warn("failed to load task before failed-start REVIEW state reconcile",
zap.String("task_id", taskID),
zap.String("session_id", failedSessionID),
zap.Error(err))
return true
}
if task != nil && task.AssigneeAgentProfileID != "" {
e.logger.Debug("skipping failed-start task REVIEW state for office task",
zap.String("task_id", taskID),
zap.String("session_id", failedSessionID))
return true
}
return false
}
func (e *Executor) failedSessionStillWorkingOrUnknown(ctx context.Context, taskID, failedSessionID string) bool {
if failedSessionID == "" {
return false
}
session, err := e.repo.GetTaskSession(ctx, failedSessionID)
if err != nil {
e.logger.Warn("failed to load failed session before failed-start REVIEW state reconcile",
zap.String("task_id", taskID),
zap.String("session_id", failedSessionID),
zap.Error(err))
return true
}
if session != nil && isRuntimeWorkingSessionState(session.State) {
e.logger.Debug("skipping failed-start task REVIEW state because failed session is active again",
zap.String("task_id", taskID),
zap.String("session_id", failedSessionID),
zap.String("session_state", string(session.State)))
return true
}
return false
}
func (e *Executor) hasOtherWorkingSessions(ctx context.Context, taskID, failedSessionID string) bool {
sessions, err := e.repo.ListTaskSessions(ctx, taskID)
if err != nil {
e.logger.Warn("failed to list task sessions before failed-start REVIEW state reconcile",
zap.String("task_id", taskID),
zap.String("session_id", failedSessionID),
zap.Error(err))
return true
}
for _, session := range sessions {
if session == nil {
continue
}
if failedSessionID != "" && session.ID == failedSessionID {
continue
}
if isRuntimeWorkingSessionState(session.State) {
e.logger.Debug("skipping failed-start task REVIEW state while another session is working",
zap.String("task_id", taskID),
zap.String("failed_session_id", failedSessionID),
zap.String("blocking_session_id", session.ID))
return true
}
}
return false
}
func isRuntimeWorkingSessionState(state models.TaskSessionState) bool {
return sessionstate.IsWorking(state)
}
// updateTaskState updates a task's state, using the callback if set for event publishing,
// or falling back to the raw repository.
func (e *Executor) updateTaskState(ctx context.Context, taskID string, state v1.TaskState) error {
@@ -124,6 +234,16 @@ func (e *Executor) updateSessionState(ctx context.Context, taskID, sessionID str
return e.repo.UpdateTaskSessionState(ctx, sessionID, state, errorMessage)
}
// updateSessionStarting persists a full session-row STARTING transition, using
// the orchestrator callback when present so task/session runtime state stays
// serialized with guarded REVIEW reconciliation.
func (e *Executor) updateSessionStarting(ctx context.Context, taskID string, session *models.TaskSession, promoteTask bool) error {
if e.onSessionStarting != nil {
return e.onSessionStarting(ctx, taskID, session, promoteTask)
}
return e.repo.UpdateTaskSession(ctx, session)
}
// shouldUseWorktree returns true if the given executor type should use Git worktrees.
func shouldUseWorktree(executorType string) bool {
return models.ExecutorType(executorType) == models.ExecutorTypeWorktree
@@ -550,7 +670,10 @@ func (e *Executor) handleLaunchFailure(ctx context.Context, taskID, sessionID st
// finalizeLaunch persists launch state and returns the resulting TaskExecution.
func (e *Executor) finalizeLaunch(ctx context.Context, task *v1.Task, session *models.TaskSession, agentProfileID, sessionID string, repoInfo *repoInfo, resp *LaunchAgentResponse, startAgent bool, execCfg executorConfig) (*TaskExecution, error) {
now := time.Now().UTC()
e.persistLaunchState(ctx, task.ID, sessionID, session, resp, startAgent, now)
if err := e.persistLaunchState(ctx, task.ID, sessionID, session, resp, startAgent, now); err != nil {
e.stopUnstartedExecution(ctx, sessionID, resp.AgentExecutionID)
return nil, err
}
e.persistWorktreeAssociation(ctx, task.ID, session, repoInfo.RepositoryID, resp)
sessionState := v1.TaskSessionStateCreated
@@ -991,10 +1114,11 @@ func (e *Executor) startAgentOnExistingWorkspace(ctx context.Context, task *v1.T
session.State = models.TaskSessionStateStarting
session.ErrorMessage = ""
session.UpdatedAt = now
if err := e.repo.UpdateTaskSession(ctx, session); err != nil {
if err := e.updateSessionStarting(ctx, task.ID, session, true); err != nil {
e.logger.Error("failed to update session state for agent start",
zap.String("session_id", session.ID),
zap.Error(err))
return nil, err
}
execution := &TaskExecution{
@@ -409,7 +409,10 @@ func (e *Executor) launchModelSwitchAgent(ctx context.Context, taskID, sessionID
return fmt.Errorf("failed to launch agent with new model: %w", err)
}
e.persistModelSwitchState(ctx, taskID, sessionID, session, newModel)
if err := e.persistModelSwitchState(ctx, taskID, sessionID, session, newModel); err != nil {
e.stopUnstartedExecution(ctx, sessionID, resp.AgentExecutionID)
return err
}
if err := e.agentManager.StartAgentProcess(ctx, resp.AgentExecutionID); err != nil {
e.logger.Error("failed to start agent process after model switch",
@@ -516,7 +519,7 @@ func (e *Executor) applyWorktreeToSwitchRequest(req *LaunchAgentRequest, session
// after a model switch launch. The executors_running row's agent_execution_id /
// container_id / status are written by the lifecycle manager during the launch
// itself (lifecycle.persistExecutorRunning) and not touched here.
func (e *Executor) persistModelSwitchState(ctx context.Context, taskID, sessionID string, session *models.TaskSession, newModel string) {
func (e *Executor) persistModelSwitchState(ctx context.Context, taskID, sessionID string, session *models.TaskSession, newModel string) error {
session.State = models.TaskSessionStateStarting
session.UpdatedAt = time.Now().UTC()
@@ -525,14 +528,15 @@ func (e *Executor) persistModelSwitchState(ctx context.Context, taskID, sessionI
}
session.AgentProfileSnapshot["model"] = newModel
if err := e.repo.UpdateTaskSession(ctx, session); err != nil {
if err := e.updateSessionStarting(ctx, taskID, session, true); err != nil {
e.logger.Error("failed to update session after model switch",
zap.String("task_id", taskID),
zap.String("session_id", sessionID),
zap.Error(err))
return
return err
}
e.persistRuntimeModelMetadata(ctx, sessionID, session, newModel)
return nil
}
// persistInPlaceModelSwitch updates the session snapshot model after a successful
@@ -230,7 +230,7 @@ func (e *Executor) backfillRepoDefaultBranch(ctx context.Context, repo *models.R
// What remains here: state transitions (e.g., STARTING) and prepare-result
// metadata merge, both of which are session-row concerns the lifecycle manager
// doesn't know about.
func (e *Executor) persistLaunchState(ctx context.Context, taskID, sessionID string, session *models.TaskSession, resp *LaunchAgentResponse, startAgent bool, now time.Time) {
func (e *Executor) persistLaunchState(ctx context.Context, taskID, sessionID string, session *models.TaskSession, resp *LaunchAgentResponse, startAgent bool, now time.Time) error {
if startAgent {
session.State = models.TaskSessionStateStarting
}
@@ -247,12 +247,20 @@ func (e *Executor) persistLaunchState(ctx context.Context, taskID, sessionID str
session.Metadata["prepare_result"] = buildPrepareResultMetadata(resp.PrepareResult)
}
if err := e.repo.UpdateTaskSession(ctx, session); err != nil {
var updateErr error
if startAgent {
updateErr = e.updateSessionStarting(ctx, taskID, session, true)
} else {
updateErr = e.repo.UpdateTaskSession(ctx, session)
}
if updateErr != nil {
e.logger.Error("failed to update agent session after launch",
zap.String("task_id", taskID),
zap.String("session_id", sessionID),
zap.Error(err))
zap.Error(updateErr))
return updateErr
}
return nil
}
// buildPrepareResultMetadata serializes a prepare result for storage in session metadata.
@@ -389,7 +397,10 @@ func (e *Executor) ResumeSession(ctx context.Context, session *models.TaskSessio
return nil, err
}
e.persistResumeState(ctx, task.ID, session, startAgent)
if err := e.persistResumeState(ctx, task.ID, session, startAgent); err != nil {
e.stopUnstartedExecution(ctx, session.ID, resp.AgentExecutionID)
return nil, err
}
e.persistWorktreeAssociation(ctx, task.ID, session, repositoryID, resp)
// Refresh task_environments after a successful resume so the row reflects
// the new worktree, container, and ready status. Without this, sessions
@@ -827,19 +838,27 @@ func resolveResumeTaskDirName(existingEnv *models.TaskEnvironment, task *v1.Task
// and not touched here — see lifecycle.persistExecutorRunning. The
// orchestrator's only remaining responsibility is the session-row state
// machine (STARTING / CompletedAt-clear).
func (e *Executor) persistResumeState(ctx context.Context, taskID string, session *models.TaskSession, startAgent bool) {
func (e *Executor) persistResumeState(ctx context.Context, taskID string, session *models.TaskSession, startAgent bool) error {
session.ErrorMessage = ""
if startAgent {
session.State = models.TaskSessionStateStarting
session.CompletedAt = nil
}
if err := e.repo.UpdateTaskSession(ctx, session); err != nil {
var updateErr error
if startAgent {
updateErr = e.updateSessionStarting(ctx, taskID, session, false)
} else {
updateErr = e.repo.UpdateTaskSession(ctx, session)
}
if updateErr != nil {
e.logger.Error("failed to update task session for resume",
zap.String("task_id", taskID),
zap.String("session_id", session.ID),
zap.Error(err))
zap.Error(updateErr))
return updateErr
}
return nil
}
// prNumberFromMetadata extracts a GitHub PR number from a task_repository's
@@ -876,9 +895,50 @@ func prNumberFromMetadata(metadata map[string]interface{}) int {
// just logs successful process start.
func (e *Executor) startAgentProcessOnResume(ctx context.Context, taskID string, session *models.TaskSession, agentExecutionID string) {
e.runAgentProcessAsync(ctx, taskID, session.ID, agentExecutionID, func(updCtx context.Context) {
if updateErr := e.writeTaskInProgressForRuntime(updCtx, taskID, session.ID); updateErr != nil {
e.logger.Warn("failed to update task state to IN_PROGRESS after resume start",
zap.String("task_id", taskID),
zap.String("session_id", session.ID),
zap.Error(updateErr))
}
e.logger.Debug("agent resumed successfully",
zap.String("task_id", taskID),
zap.String("session_id", session.ID),
zap.String("session_state", string(session.State)))
}, false, true)
}
func (e *Executor) writeTaskInProgressForRuntime(ctx context.Context, taskID, sessionID string) error {
task, err := e.repo.GetTask(ctx, taskID)
if err != nil {
return err
}
if task != nil && task.ArchivedAt != nil {
e.logger.Debug("skipping IN_PROGRESS transition for archived task",
zap.String("task_id", taskID))
return nil
}
if task != nil && task.AssigneeAgentProfileID != "" {
e.logger.Debug("skipping IN_PROGRESS transition for office task",
zap.String("task_id", taskID))
return nil
}
if sessionID != "" {
session, err := e.repo.GetTaskSession(ctx, sessionID)
if err != nil {
return err
}
if session == nil || !isRuntimeWorkingSessionState(session.State) {
state := ""
if session != nil {
state = string(session.State)
}
e.logger.Debug("skipping IN_PROGRESS transition because resumed session is no longer active",
zap.String("task_id", taskID),
zap.String("session_id", sessionID),
zap.String("session_state", state))
return nil
}
}
return e.updateTaskState(ctx, taskID, v1.TaskStateInProgress)
}
@@ -2,6 +2,7 @@ package executor
import (
"context"
"errors"
"fmt"
"os/exec"
"sync"
@@ -262,6 +263,71 @@ func TestLaunchPreparedSession_Success(t *testing.T) {
}
}
func TestLaunchPreparedSession_AbortsWhenStartingPersistenceFails(t *testing.T) {
repo := newMockRepository()
session := &models.TaskSession{
ID: "session-abort",
TaskID: "task-abort",
AgentProfileID: "profile-123",
State: models.TaskSessionStateCreated,
StartedAt: time.Now(),
UpdatedAt: time.Now(),
}
repo.sessions[session.ID] = session
startCalled := make(chan struct{}, 1)
stopCalled := make(chan string, 1)
agentManager := &mockAgentManager{
launchAgentFunc: func(ctx context.Context, req *LaunchAgentRequest) (*LaunchAgentResponse, error) {
return &LaunchAgentResponse{
AgentExecutionID: "exec-abort",
Status: v1.AgentStatusStarting,
}, nil
},
startAgentProcessFunc: func(ctx context.Context, agentExecutionID string) error {
startCalled <- struct{}{}
return nil
},
stopAgentFunc: func(ctx context.Context, agentExecutionID string, force bool) error {
if !force {
t.Error("expected cleanup stop to be forced")
}
stopCalled <- agentExecutionID
return nil
},
}
persistErr := errors.New("session is terminal")
executor := newTestExecutor(t, agentManager, repo)
executor.SetOnSessionStarting(func(ctx context.Context, taskID string, session *models.TaskSession, promoteTask bool) error {
return persistErr
})
task := &v1.Task{ID: "task-abort", WorkspaceID: "workspace-123", Title: "Test Task"}
execution, err := executor.LaunchPreparedSession(context.Background(), task, "session-abort", LaunchOptions{
AgentProfileID: "profile-123",
StartAgent: true,
})
if !errors.Is(err, persistErr) {
t.Fatalf("expected persistence error, got execution=%v err=%v", execution, err)
}
select {
case got := <-stopCalled:
if got != "exec-abort" {
t.Fatalf("stopped execution %q, want exec-abort", got)
}
case <-time.After(5 * time.Second):
t.Fatal("expected unstarted execution to be stopped")
}
select {
case <-startCalled:
t.Fatal("agent process must not start after STARTING persistence fails")
default:
}
}
// TestLaunchPreparedSession_PropagatesIsPassthrough mirrors
// TestResumeSession_PropagatesIsPassthrough for the initial-launch path: the
// session's IsPassthrough snapshot must reach the LaunchAgentRequest so the
@@ -1050,6 +1116,7 @@ verified:
// other recorded fields, removing the need for atomicity or polling.
type runAgentProcessAsyncFailureFixture struct {
exec *Executor
repo *mockRepository
taskStateUpdates []string
sessionFailedSeen bool
startFailedCalls int
@@ -1063,7 +1130,7 @@ func newRunAgentProcessAsyncFailureFixture(t *testing.T) *runAgentProcessAsyncFa
repo.sessions["session-123"] = &models.TaskSession{
ID: "session-123", TaskID: "task-123", State: models.TaskSessionStateStarting,
}
f := &runAgentProcessAsyncFailureFixture{stopCh: make(chan struct{})}
f := &runAgentProcessAsyncFailureFixture{repo: repo, stopCh: make(chan struct{})}
agentManager := &mockAgentManager{
startAgentProcessFunc: func(ctx context.Context, agentExecutionID string) error {
return fmt.Errorf("ACP initialize handshake failed: context deadline exceeded")
@@ -1113,8 +1180,8 @@ func TestRunAgentProcessAsync_ResumeDoesNotEscalateTaskState(t *testing.T) {
if !f.sessionFailedSeen {
t.Error("expected session state FAILED")
}
if len(f.taskStateUpdates) != 0 {
t.Errorf("expected no task state updates on resume failure, got %v", f.taskStateUpdates)
if len(f.taskStateUpdates) != 1 || f.taskStateUpdates[0] != string(v1.TaskStateReview) {
t.Errorf("expected resume failure to reconcile task state to REVIEW, got %v", f.taskStateUpdates)
}
if f.startFailedCalls != 1 {
t.Errorf("expected onAgentStartFailed called once, got %d", f.startFailedCalls)
@@ -1124,6 +1191,244 @@ func TestRunAgentProcessAsync_ResumeDoesNotEscalateTaskState(t *testing.T) {
}
}
func TestRunAgentProcessAsync_ResumeFailureDoesNotReviewWhileSiblingWorks(t *testing.T) {
f := newRunAgentProcessAsyncFailureFixture(t)
f.repo.sessions["session-456"] = &models.TaskSession{
ID: "session-456", TaskID: "task-123", State: models.TaskSessionStateRunning,
}
f.exec.runAgentProcessAsync(context.Background(), "task-123", "session-123", "exec-456",
func(ctx context.Context) { t.Error("onSuccess should not run on failure") },
false, true)
f.awaitStop(t)
if !f.sessionFailedSeen {
t.Error("expected session state FAILED")
}
if len(f.taskStateUpdates) != 0 {
t.Errorf("expected working sibling to block REVIEW reconcile, got %v", f.taskStateUpdates)
}
}
func TestRunAgentProcessAsync_ResumeFailureUsesReviewReconcileCallback(t *testing.T) {
f := newRunAgentProcessAsyncFailureFixture(t)
var reviewCalls []string
f.exec.SetOnTaskReviewStateReconcile(func(ctx context.Context, taskID, completedSessionID string) {
reviewCalls = append(reviewCalls, taskID+"/"+completedSessionID)
})
f.exec.runAgentProcessAsync(context.Background(), "task-123", "session-123", "exec-456",
func(ctx context.Context) { t.Error("onSuccess should not run on failure") },
false, true)
f.awaitStop(t)
if len(reviewCalls) != 1 || reviewCalls[0] != "task-123/session-123" {
t.Fatalf("expected guarded review reconcile callback once, got %v", reviewCalls)
}
if len(f.taskStateUpdates) != 0 {
t.Fatalf("expected callback to replace direct task REVIEW writes, got %v", f.taskStateUpdates)
}
}
func TestWriteTaskReviewStateIfNoWorkingSessionsSkipsSameSessionActiveAgain(t *testing.T) {
repo := newMockRepository()
repo.sessions["session-123"] = &models.TaskSession{
ID: "session-123", TaskID: "task-123", State: models.TaskSessionStateRunning,
}
exec := newTestExecutor(t, &mockAgentManager{}, repo)
var taskStateUpdates []v1.TaskState
exec.SetOnTaskStateChange(func(ctx context.Context, taskID string, state v1.TaskState) error {
taskStateUpdates = append(taskStateUpdates, state)
return nil
})
exec.writeTaskReviewStateIfNoWorkingSessions(context.Background(), "task-123", "session-123")
if len(taskStateUpdates) != 0 {
t.Errorf("expected same active session to block REVIEW reconcile, got %v", taskStateUpdates)
}
}
func TestWriteTaskReviewStateIfNoWorkingSessionsSkipsOnFailedSessionReadError(t *testing.T) {
repo := newMockRepository()
repo.tasks["task-123"] = &models.Task{ID: "task-123"}
repo.getTaskSessionFunc = func(context.Context, string) (*models.TaskSession, error) {
return nil, errors.New("temporary session read failure")
}
exec := newTestExecutor(t, &mockAgentManager{}, repo)
var taskStateUpdates []v1.TaskState
exec.SetOnTaskStateChange(func(ctx context.Context, taskID string, state v1.TaskState) error {
taskStateUpdates = append(taskStateUpdates, state)
return nil
})
exec.writeTaskReviewStateIfNoWorkingSessions(context.Background(), "task-123", "session-123")
if len(taskStateUpdates) != 0 {
t.Errorf("expected failed session read to block REVIEW reconcile, got %v", taskStateUpdates)
}
}
func TestWriteTaskReviewStateIfNoWorkingSessionsSkipsOfficeTask(t *testing.T) {
repo := newMockRepository()
repo.tasks["task-123"] = &models.Task{
ID: "task-123",
AssigneeAgentProfileID: "agent-profile-123",
}
repo.sessions["session-123"] = &models.TaskSession{
ID: "session-123", TaskID: "task-123", State: models.TaskSessionStateFailed,
}
exec := newTestExecutor(t, &mockAgentManager{}, repo)
var taskStateUpdates []v1.TaskState
exec.SetOnTaskStateChange(func(ctx context.Context, taskID string, state v1.TaskState) error {
taskStateUpdates = append(taskStateUpdates, state)
return nil
})
exec.writeTaskReviewStateIfNoWorkingSessions(context.Background(), "task-123", "session-123")
if len(taskStateUpdates) != 0 {
t.Errorf("expected office task to keep workflow state, got %v", taskStateUpdates)
}
}
func TestStartAgentProcessOnResumePromotesTaskAfterSuccess(t *testing.T) {
repo := newMockRepository()
session := &models.TaskSession{
ID: "session-123", TaskID: "task-123", State: models.TaskSessionStateStarting,
}
repo.sessions[session.ID] = session
taskStateCh := make(chan v1.TaskState, 1)
exec := newTestExecutor(t, &mockAgentManager{}, repo)
exec.SetOnTaskStateChange(func(ctx context.Context, taskID string, state v1.TaskState) error {
taskStateCh <- state
return nil
})
exec.startAgentProcessOnResume(context.Background(), "task-123", session, "exec-456")
select {
case state := <-taskStateCh:
if state != v1.TaskStateInProgress {
t.Fatalf("task state = %q, want %q", state, v1.TaskStateInProgress)
}
case <-time.After(5 * time.Second):
t.Fatal("expected resume process success to promote task to IN_PROGRESS")
}
}
func TestStartAgentProcessOnResumeSkipsOfficeTaskPromotion(t *testing.T) {
repo := newMockRepository()
repo.tasks["task-123"] = &models.Task{ID: "task-123", AssigneeAgentProfileID: "agent-profile-123"}
session := &models.TaskSession{
ID: "session-123", TaskID: "task-123", State: models.TaskSessionStateStarting,
}
repo.sessions[session.ID] = session
startedCh := make(chan struct{})
agentManager := &mockAgentManager{
startAgentProcessFunc: func(ctx context.Context, agentExecutionID string) error {
close(startedCh)
return nil
},
}
taskStateCh := make(chan v1.TaskState, 1)
exec := newTestExecutor(t, agentManager, repo)
exec.SetOnTaskStateChange(func(ctx context.Context, taskID string, state v1.TaskState) error {
taskStateCh <- state
return nil
})
exec.startAgentProcessOnResume(context.Background(), "task-123", session, "exec-456")
select {
case <-startedCh:
case <-time.After(5 * time.Second):
t.Fatal("expected resume process start")
}
select {
case state := <-taskStateCh:
t.Fatalf("office resume promoted task state to %q", state)
case <-time.After(100 * time.Millisecond):
}
}
func TestStartAgentProcessOnResumeSkipsCancelledSessionPromotion(t *testing.T) {
repo := newMockRepository()
session := &models.TaskSession{
ID: "session-123", TaskID: "task-123", State: models.TaskSessionStateStarting,
}
repo.sessions[session.ID] = session
startedCh := make(chan struct{})
agentManager := &mockAgentManager{
startAgentProcessFunc: func(ctx context.Context, agentExecutionID string) error {
if err := repo.UpdateTaskSessionState(ctx, session.ID, models.TaskSessionStateCancelled, "stopped"); err != nil {
return err
}
close(startedCh)
return nil
},
}
taskStateCh := make(chan v1.TaskState, 1)
exec := newTestExecutor(t, agentManager, repo)
exec.SetOnTaskStateChange(func(ctx context.Context, taskID string, state v1.TaskState) error {
taskStateCh <- state
return nil
})
exec.startAgentProcessOnResume(context.Background(), "task-123", session, "exec-456")
select {
case <-startedCh:
case <-time.After(5 * time.Second):
t.Fatal("expected resume process start")
}
select {
case state := <-taskStateCh:
t.Fatalf("cancelled resume promoted task state to %q", state)
case <-time.After(100 * time.Millisecond):
}
}
func TestStartAgentProcessOnResumeSkipsArchivedTaskPromotion(t *testing.T) {
repo := newMockRepository()
archivedAt := time.Now()
repo.tasks["task-123"] = &models.Task{ID: "task-123", ArchivedAt: &archivedAt}
session := &models.TaskSession{
ID: "session-123", TaskID: "task-123", State: models.TaskSessionStateStarting,
}
repo.sessions[session.ID] = session
startedCh := make(chan struct{})
agentManager := &mockAgentManager{
startAgentProcessFunc: func(ctx context.Context, agentExecutionID string) error {
close(startedCh)
return nil
},
}
taskStateCh := make(chan v1.TaskState, 1)
exec := newTestExecutor(t, agentManager, repo)
exec.SetOnTaskStateChange(func(ctx context.Context, taskID string, state v1.TaskState) error {
taskStateCh <- state
return nil
})
exec.startAgentProcessOnResume(context.Background(), "task-123", session, "exec-456")
select {
case <-startedCh:
case <-time.After(5 * time.Second):
t.Fatal("expected resume process start")
}
select {
case state := <-taskStateCh:
t.Fatalf("archived resume promoted task state to %q", state)
case <-time.After(100 * time.Millisecond):
}
}
func TestRunAgentProcessAsync_FreshStartEscalatesTaskState(t *testing.T) {
f := newRunAgentProcessAsyncFailureFixture(t)
f.exec.runAgentProcessAsync(context.Background(), "task-123", "session-123", "exec-456",
@@ -1268,7 +1573,9 @@ func TestPersistResumeState_SetsStartingState(t *testing.T) {
}
repo.sessions[session.ID] = session
executor.persistResumeState(context.Background(), "task-1", session, true)
if err := executor.persistResumeState(context.Background(), "task-1", session, true); err != nil {
t.Fatalf("persistResumeState: %v", err)
}
if session.State != models.TaskSessionStateStarting {
t.Errorf("expected state STARTING, got %s", session.State)
@@ -1278,6 +1585,35 @@ func TestPersistResumeState_SetsStartingState(t *testing.T) {
}
})
t.Run("defers task promotion when startAgent is true", func(t *testing.T) {
session := &models.TaskSession{
ID: "session-promote",
TaskID: "task-1",
State: models.TaskSessionStateWaitingForInput,
UpdatedAt: now,
}
repo.sessions[session.ID] = session
var gotPromoteTask *bool
executor.SetOnSessionStarting(func(ctx context.Context, taskID string, session *models.TaskSession, promoteTask bool) error {
gotPromoteTask = &promoteTask
return repo.UpdateTaskSession(ctx, session)
})
t.Cleanup(func() {
executor.SetOnSessionStarting(nil)
})
if err := executor.persistResumeState(context.Background(), "task-1", session, true); err != nil {
t.Fatalf("persistResumeState: %v", err)
}
if gotPromoteTask == nil {
t.Fatal("expected onSessionStarting callback")
}
if *gotPromoteTask {
t.Fatal("resume STARTING persistence must defer task promotion until process start succeeds")
}
})
t.Run("does not change state when startAgent is false", func(t *testing.T) {
session := &models.TaskSession{
ID: "session-2",
@@ -1287,7 +1623,9 @@ func TestPersistResumeState_SetsStartingState(t *testing.T) {
}
repo.sessions[session.ID] = session
executor.persistResumeState(context.Background(), "task-1", session, false)
if err := executor.persistResumeState(context.Background(), "task-1", session, false); err != nil {
t.Fatalf("persistResumeState: %v", err)
}
if session.State != models.TaskSessionStateWaitingForInput {
t.Errorf("expected state WAITING_FOR_INPUT, got %s", session.State)
@@ -92,6 +92,7 @@ type MessageCreator interface {
type TurnService interface {
StartTurn(ctx context.Context, sessionID string) (*models.Turn, error)
CompleteTurn(ctx context.Context, turnID string) error
GetTurn(ctx context.Context, turnID string) (*models.Turn, error)
GetActiveTurn(ctx context.Context, sessionID string) (*models.Turn, error)
UpdateTurn(ctx context.Context, turn *models.Turn) error
// AbandonOpenTurns buries any open turns for a session by setting
@@ -364,6 +365,19 @@ type Service struct {
// Active turns map: sessionID -> turnID
activeTurns sync.Map
// taskRuntimeStateMu serializes task-state flips derived from session
// runtime state. Without it, a completion/cancel path can check for active
// sibling sessions just before another handler marks one RUNNING, then
// clobber the task back to REVIEW while work is active.
taskRuntimeStateMu sync.Mutex
// completedExecutions records execution IDs that have reached a terminal
// agent lifecycle event. Buffered stream/tool events for these executions
// must not wake their session back to RUNNING after the terminal path makes
// it promptable again. Entries expire after a short grace window so the
// guard does not grow without bound in long-running backend processes.
completedExecutions sync.Map
// Session reset flags: sessionID -> true while resetAgentContext is restarting process.
// Used to suppress stale ready events and avoid draining queued prompts mid-reset.
resetInProgressSessions sync.Map
@@ -479,6 +493,12 @@ func NewService(
s.updateTaskSessionState(ctx, taskID, sessionID, state, errorMessage, true)
return nil
})
exec.SetOnSessionStarting(func(ctx context.Context, taskID string, session *models.TaskSession, promoteTask bool) error {
return s.setSessionStarting(ctx, taskID, session, promoteTask)
})
exec.SetOnTaskReviewStateReconcile(func(ctx context.Context, taskID, completedSessionID string) {
s.writeTaskReviewState(ctx, taskID, completedSessionID)
})
exec.SetOnLaunchFailed(s.handleSessionLaunchFailed)
exec.SetOnAgentStartFailed(s.handleAgentStartFailed)
if caps, ok := agentManager.(executor.ExecutorTypeCapabilities); ok {
@@ -0,0 +1,7 @@
package sessionstate
import "github.com/kandev/kandev/internal/task/models"
func IsWorking(state models.TaskSessionState) bool {
return state == models.TaskSessionStateRunning || state == models.TaskSessionStateStarting
}
@@ -2252,7 +2252,7 @@ func (s *Service) handlePromptError(ctx context.Context, taskID, sessionID strin
// in progress while it retries — so don't flap it to REVIEW here.
if !isTransientPromptError(err) && !errors.Is(err, lifecycle.ErrCancelEscalated) &&
!routingerr.IsTransientProviderError(err.Error()) {
_ = s.taskRepo.UpdateTaskState(ctx, taskID, v1.TaskStateReview)
s.writeTaskReviewState(ctx, taskID, sessionID)
}
s.completeTurnForSession(ctx, sessionID)
return err
@@ -2450,7 +2450,7 @@ func (s *Service) CancelAgent(ctx context.Context, sessionID string) error {
// cancelled turn is treated as finished work the user may want to review.
if session != nil {
s.updateTaskSessionState(ctx, session.TaskID, sessionID, models.TaskSessionStateWaitingForInput, "", true, session)
s.writeTaskReviewStateOnCancel(ctx, session.TaskID)
s.writeTaskReviewStateOnCancel(ctx, session.TaskID, sessionID)
}
// Record cancellation in the message history
@@ -813,9 +813,15 @@ func TestStartCreatedSession_EmptyProfileFallsBackToWorkflowDefault(t *testing.T
// mockMessageCreator implements MessageCreator for testing.
// Only CreateUserMessage is tracked; all other methods are no-op stubs.
type mockMessageCreator struct {
userMessages []mockUserMessage
sessionMessages []mockSessionMessage
userMessageErr error
userMessages []mockUserMessage
sessionMessages []mockSessionMessage
agentMessages []mockAgentMessage
agentMessageWrites int
agentStreamWrites int
thinkingWrites int
toolCallWrites int
toolUpdateWrites int
userMessageErr error
}
type mockUserMessage struct {
@@ -829,6 +835,10 @@ type mockSessionMessage struct {
requestsInput bool
}
type mockAgentMessage struct {
taskID, content, sessionID, turnID string
}
func (m *mockMessageCreator) CreateUserMessage(_ context.Context, taskID, content, sessionID, turnID string, metadata map[string]interface{}) error {
if m.userMessageErr != nil {
return m.userMessageErr
@@ -837,15 +847,19 @@ func (m *mockMessageCreator) CreateUserMessage(_ context.Context, taskID, conten
return nil
}
func (m *mockMessageCreator) CreateAgentMessage(context.Context, string, string, string, string) error {
func (m *mockMessageCreator) CreateAgentMessage(_ context.Context, taskID, content, sessionID, turnID string) error {
m.agentMessages = append(m.agentMessages, mockAgentMessage{taskID, content, sessionID, turnID})
m.agentMessageWrites++
return nil
}
func (m *mockMessageCreator) CreateToolCallMessage(context.Context, string, string, string, string, string, string, string, *streams.NormalizedPayload) error {
m.toolCallWrites++
return nil
}
func (m *mockMessageCreator) UpdateToolCallMessage(context.Context, string, string, string, string, string, string, string, string, string, *streams.NormalizedPayload) error {
m.toolUpdateWrites++
return nil
}
@@ -871,18 +885,22 @@ func (m *mockMessageCreator) UpdatePermissionMessage(context.Context, string, st
}
func (m *mockMessageCreator) CreateAgentMessageStreaming(context.Context, string, string, string, string, string) error {
m.agentStreamWrites++
return nil
}
func (m *mockMessageCreator) AppendAgentMessage(context.Context, string, string) error {
m.agentStreamWrites++
return nil
}
func (m *mockMessageCreator) CreateThinkingMessageStreaming(context.Context, string, string, string, string, string) error {
m.thinkingWrites++
return nil
}
func (m *mockMessageCreator) AppendThinkingMessage(context.Context, string, string) error {
m.thinkingWrites++
return nil
}
func (m *mockMessageCreator) InvalidateModelCache(string) {}
@@ -41,6 +41,10 @@ func (a *repoTurnService) CompleteTurn(ctx context.Context, turnID string) error
return a.repo.CompleteTurn(ctx, turnID)
}
func (a *repoTurnService) GetTurn(ctx context.Context, turnID string) (*models.Turn, error) {
return a.repo.GetTurn(ctx, turnID)
}
func (a *repoTurnService) GetActiveTurn(ctx context.Context, sessionID string) (*models.Turn, error) {
turn, err := a.repo.GetActiveTurnBySessionID(ctx, sessionID)
if errors.Is(err, sql.ErrNoRows) {
@@ -434,6 +434,7 @@ func TestProcessOnTurnCompleteViaEngine_WritesTaskStateReview(t *testing.T) {
agentMgr := &mockAgentManager{repoForExecutionLookup: repo}
taskRepo := newMockTaskRepo()
seedMockTaskState(taskRepo, "t1", v1.TaskStateInProgress)
svc := createEngineService(t, repo, sg, agentMgr)
svc.taskRepo = taskRepo
@@ -42,6 +42,27 @@ func (s *Service) GetLastAgentMessage(ctx context.Context, sessionID string) (st
return s.sessions.GetLastAgentMessage(ctx, sessionID)
}
// GetLastAgentMessageForTurn returns the most recent text agent message for a
// single turn. Used by the office comment bridge when a late terminal complete
// refers to a historical turn while a newer turn may already be active.
func (s *Service) GetLastAgentMessageForTurn(ctx context.Context, turnID string) (string, error) {
messages, err := s.messages.ListMessagesByTurnID(ctx, turnID)
if err != nil {
return "", err
}
for i := len(messages) - 1; i >= 0; i-- {
message := messages[i]
if message == nil ||
message.AuthorType != models.MessageAuthorAgent ||
message.Type != models.MessageTypeMessage ||
message.Content == "" {
continue
}
return message.Content, nil
}
return "", nil
}
// ListTaskTree returns a flat list of non-archived tasks for tree building.
func (s *Service) ListTaskTree(ctx context.Context, workspaceID string, filters models.TaskTreeFilters) ([]*models.Task, error) {
tasks, err := s.tasks.ListTaskTree(ctx, workspaceID, filters)
@@ -54,6 +54,11 @@ func (s *Service) StartTurn(ctx context.Context, sessionID string) (*models.Turn
return turn, nil
}
// GetTurn returns a turn by ID.
func (s *Service) GetTurn(ctx context.Context, turnID string) (*models.Turn, error) {
return s.turns.GetTurn(ctx, turnID)
}
// CompleteTurn marks a turn as completed and publishes the turn.completed event.
func (s *Service) CompleteTurn(ctx context.Context, turnID string) error {
if turnID == "" {