diff --git a/apps/backend/internal/backendapp/adapters.go b/apps/backend/internal/backendapp/adapters.go index d55f28710f..f9758e12c6 100644 --- a/apps/backend/internal/backendapp/adapters.go +++ b/apps/backend/internal/backendapp/adapters.go @@ -755,10 +755,15 @@ func wrapGitHubTaskIssueStoreError(err error) error { } // ProcessOnTurnStart forwards to the orchestrator service. -func (w *orchestratorWrapper) ProcessOnTurnStart(ctx context.Context, taskID, sessionID string) error { +func (w *orchestratorWrapper) ProcessOnTurnStart(ctx context.Context, taskID, sessionID string) (orchestrator.ProcessOnTurnStartResult, error) { return w.svc.ProcessOnTurnStart(ctx, taskID, sessionID) } +// QueueUserPrompt forwards a prompt that must wait for workflow admission. +func (w *orchestratorWrapper) QueueUserPrompt(ctx context.Context, taskID, sessionID, prompt, model string, planMode bool, attachments []v1.MessageAttachment, metadata map[string]interface{}, userMessageRecorded bool) error { + return w.svc.QueueUserPrompt(ctx, taskID, sessionID, prompt, model, planMode, attachments, metadata, userMessageRecorded) +} + // StepRequiresCompletionSignal forwards to the orchestrator service. func (w *orchestratorWrapper) StepRequiresCompletionSignal(ctx context.Context, taskID string) bool { return w.svc.StepRequiresCompletionSignal(ctx, taskID) diff --git a/apps/backend/internal/backendapp/boot_state_routes.go b/apps/backend/internal/backendapp/boot_state_routes.go index 50df9f8eae..5f309f63ec 100644 --- a/apps/backend/internal/backendapp/boot_state_routes.go +++ b/apps/backend/internal/backendapp/boot_state_routes.go @@ -574,6 +574,8 @@ func mapKanbanStepState(step taskdto.WorkflowStepDTO) map[string]any { "show_in_command_panel": step.ShowInCommandPanel, "agent_profile_id": nullString(step.AgentProfileID), "stage_type": nullString(step.StageType), + "wip_limit": step.WIPLimit, + "pull_from_step_id": nullString(step.PullFromStepID), } } @@ -606,6 +608,9 @@ func mapKanbanTaskState(task taskdto.TaskDTO) map[string]any { "primarySessionState": task.PrimarySessionState, "primarySessionPendingAction": task.PrimarySessionPendingAction, "taskPendingAction": task.TaskPendingAction, + "wipAdmitted": task.WIPAdmitted, + "queuedForStepId": nullString(task.QueuedForStepID), + "queuedAt": task.QueuedAt, "interrupted": task.Interrupted, "statusSummary": task.StatusSummary, "sessionCount": task.SessionCount, diff --git a/apps/backend/internal/backendapp/boot_state_routes_test.go b/apps/backend/internal/backendapp/boot_state_routes_test.go index 8bf0bd2527..67c11e1b59 100644 --- a/apps/backend/internal/backendapp/boot_state_routes_test.go +++ b/apps/backend/internal/backendapp/boot_state_routes_test.go @@ -4,10 +4,33 @@ import ( "encoding/json" "testing" + taskdto "github.com/kandev/kandev/internal/task/dto" userdto "github.com/kandev/kandev/internal/user/dto" usermodels "github.com/kandev/kandev/internal/user/models" ) +func TestMapKanbanStateIncludesWIPAdmissionFields(t *testing.T) { + step := mapKanbanStepState(taskdto.WorkflowStepDTO{ + ID: "step-review", + Name: "Review", + WIPLimit: 1, + PullFromStepID: "step-backlog", + }) + if step["wip_limit"] != 1 || step["pull_from_step_id"] != "step-backlog" { + t.Fatalf("kanban step WIP fields = %#v, want limit 1 and feeder step", step) + } + + task := mapKanbanTaskState(taskdto.TaskDTO{ + ID: "task-queued", + WorkflowStepID: "step-review", + WIPAdmitted: false, + QueuedForStepID: "step-review", + }) + if task["wipAdmitted"] != false || task["queuedForStepId"] != "step-review" { + t.Fatalf("kanban task WIP fields = %#v, want queued task metadata", task) + } +} + func TestMapUserSettingsStateIncludesAzureDevOpsBrowsePreferences(t *testing.T) { preferences := json.RawMessage(`{"workspace-1":{"mode":"board","filters":{"projectId":"project-2"},"board":{"teamId":"team-2","boardId":"board-2","focusedColumnId":"done"}}}`) state := mapUserSettingsState(userdto.UserSettingsResponse{ diff --git a/apps/backend/internal/mcp/handlers/handlers.go b/apps/backend/internal/mcp/handlers/handlers.go index 706ebfba8b..b467a0d984 100644 --- a/apps/backend/internal/mcp/handlers/handlers.go +++ b/apps/backend/internal/mcp/handlers/handlers.go @@ -142,7 +142,8 @@ type SessionLauncher interface { PromptTask(ctx context.Context, taskID, sessionID, prompt, model string, planMode bool, attachments []v1.MessageAttachment, dispatchOnly bool) (*orchestrator.PromptResult, error) StartCreatedSession(ctx context.Context, taskID, sessionID, agentProfileID, prompt string, skipMessageRecord, planMode, autoStart bool, attachments []v1.MessageAttachment, references []v1.EntityReference) (*executor.TaskExecution, error) ResumeTaskSession(ctx context.Context, taskID, sessionID string) (*executor.TaskExecution, error) - ProcessOnTurnStart(ctx context.Context, taskID, sessionID string) error + ProcessOnTurnStart(ctx context.Context, taskID, sessionID string) (orchestrator.ProcessOnTurnStartResult, error) + QueueUserPrompt(ctx context.Context, taskID, sessionID, prompt, model string, planMode bool, attachments []v1.MessageAttachment, metadata map[string]interface{}, userMessageRecorded bool) error GetMessageQueue() *messagequeue.Service // QueueAndInterruptForPeerMessage atomically queues prompt for sessionID // then interrupts the session's in-flight turn to dispatch it right @@ -2860,12 +2861,29 @@ func (h *Handlers) dispatchTaskMessage(ctx context.Context, taskID string, sessi h.restoreTaskReviewForTaskMessage(ctx, taskID, reviewRollback) return taskMessageDispatchResult{}, err } - session, err := h.prepareSessionForTaskMessage(ctx, taskID, session, pinnedTarget) + session, turnStartResult, err := h.prepareSessionForTaskMessage(ctx, taskID, session, pinnedTarget) if err != nil { h.restoreTaskReviewForTaskMessage(ctx, taskID, reviewRollback) return taskMessageDispatchResult{}, err } reviewRollback.captureSelectedSession(session) + if turnStartResult.Queued { + if err := h.sessionLauncher.QueueUserPrompt( + ctx, + taskID, + session.ID, + prompt, + "", + false, + nil, + metadata, + false, + ); err != nil { + h.restoreTaskReviewForTaskMessage(ctx, taskID, reviewRollback) + return taskMessageDispatchResult{}, fmt.Errorf("failed to queue prompt until workflow promotion: %w", err) + } + return taskMessageDispatchResult{status: taskMessageStatusQueued, sessionID: session.ID}, nil + } result, err := h.dispatchPreparedTaskMessage(ctx, taskID, session, prompt, metadata) if err != nil { h.restoreTaskReviewForTaskMessage(ctx, taskID, reviewRollback) @@ -3020,9 +3038,10 @@ func (h *Handlers) queueThenInterruptTaskMessage(ctx context.Context, taskID str return result, nil } -func (h *Handlers) prepareSessionForTaskMessage(ctx context.Context, taskID string, session *models.TaskSession, pinnedTarget bool) (*models.TaskSession, error) { - if err := h.sessionLauncher.ProcessOnTurnStart(ctx, taskID, session.ID); err != nil { - return nil, fmt.Errorf("failed to process on_turn_start for task message: %w", err) +func (h *Handlers) prepareSessionForTaskMessage(ctx context.Context, taskID string, session *models.TaskSession, pinnedTarget bool) (*models.TaskSession, orchestrator.ProcessOnTurnStartResult, error) { + turnStartResult, err := h.sessionLauncher.ProcessOnTurnStart(ctx, taskID, session.ID) + if err != nil { + return nil, orchestrator.ProcessOnTurnStartResult{}, fmt.Errorf("failed to process on_turn_start for task message: %w", err) } if pinnedTarget { // The caller addressed this exact session — never reroute to the @@ -3030,15 +3049,15 @@ func (h *Handlers) prepareSessionForTaskMessage(ctx context.Context, taskID stri // on_turn_start. reloaded, err := h.taskSvc.GetTaskSession(ctx, session.ID) if err != nil { - return nil, fmt.Errorf("failed to reload pinned target session after on_turn_start: %w", err) + return nil, orchestrator.ProcessOnTurnStartResult{}, fmt.Errorf("failed to reload pinned target session after on_turn_start: %w", err) } - return reloaded, nil + return reloaded, turnStartResult, nil } resolved, err := h.resolveSessionAfterTaskMessageTurnStart(ctx, taskID, session) if err != nil { - return nil, err + return nil, orchestrator.ProcessOnTurnStartResult{}, err } - return resolved, nil + return resolved, turnStartResult, nil } func (h *Handlers) ensureTaskInProgressForTaskMessage(ctx context.Context, taskID string) (taskMessageReviewRollback, error) { diff --git a/apps/backend/internal/mcp/handlers/handlers_test.go b/apps/backend/internal/mcp/handlers/handlers_test.go index 580dd528d2..d2fb908958 100644 --- a/apps/backend/internal/mcp/handlers/handlers_test.go +++ b/apps/backend/internal/mcp/handlers/handlers_test.go @@ -2114,7 +2114,10 @@ func (m *mockSessionLauncher) StartCreatedSession(context.Context, string, strin func (m *mockSessionLauncher) ResumeTaskSession(context.Context, string, string) (*executor.TaskExecution, error) { return nil, nil } -func (m *mockSessionLauncher) ProcessOnTurnStart(context.Context, string, string) error { +func (m *mockSessionLauncher) ProcessOnTurnStart(context.Context, string, string) (orchestrator.ProcessOnTurnStartResult, error) { + return orchestrator.ProcessOnTurnStartResult{}, nil +} +func (m *mockSessionLauncher) QueueUserPrompt(context.Context, string, string, string, string, bool, []v1.MessageAttachment, map[string]interface{}, bool) error { return nil } func (m *mockSessionLauncher) GetMessageQueue() *messagequeue.Service { return nil } diff --git a/apps/backend/internal/mcp/handlers/message_task_test.go b/apps/backend/internal/mcp/handlers/message_task_test.go index 6a08c229c4..49a4cc5f94 100644 --- a/apps/backend/internal/mcp/handlers/message_task_test.go +++ b/apps/backend/internal/mcp/handlers/message_task_test.go @@ -36,6 +36,7 @@ type fakeOrchestrator struct { resumeCalls int turnStartCalls []turnStartCall onTurnStart func(context.Context, string, string) error + turnStartResult orchestrator.ProcessOnTurnStartResult interruptCalls []interruptCall launchCalls []*orchestrator.LaunchSessionRequest launchErr error @@ -149,15 +150,20 @@ func (f *fakeOrchestrator) ResumeTaskSession(_ context.Context, _, _ string) (*e return &executor.TaskExecution{}, nil } -func (f *fakeOrchestrator) ProcessOnTurnStart(ctx context.Context, taskID, sessionID string) error { +func (f *fakeOrchestrator) ProcessOnTurnStart(ctx context.Context, taskID, sessionID string) (orchestrator.ProcessOnTurnStartResult, error) { f.mu.Lock() f.turnStartCalls = append(f.turnStartCalls, turnStartCall{taskID: taskID, sessionID: sessionID}) fn := f.onTurnStart f.mu.Unlock() if fn != nil { - return fn(ctx, taskID, sessionID) + return f.turnStartResult, fn(ctx, taskID, sessionID) } - return nil + return f.turnStartResult, nil +} + +func (f *fakeOrchestrator) QueueUserPrompt(ctx context.Context, taskID, sessionID, prompt, model string, planMode bool, attachments []v1.MessageAttachment, metadata map[string]interface{}, userMessageRecorded bool) error { + _, err := f.queue.QueueMessageWithMetadata(ctx, sessionID, taskID, prompt, model, messagequeue.QueuedByUser, planMode, nil, metadata) + return err } func (f *fakeOrchestrator) GetMessageQueue() *messagequeue.Service { return f.queue } @@ -509,6 +515,30 @@ func TestHandleMessageTask_IdleSiblingSession_PinsTargetSession(t *testing.T) { assert.Empty(t, senderMessages, "sender's own session must not receive the message") } +func TestHandleMessageTask_QueuesPromptWhenOnTurnStartQueuesTask(t *testing.T) { + svc, repo := newTestTaskService(t) + sender, target, session := seedTaskWithSession(t, svc, repo, models.TaskSessionStateWaitingForInput) + h, orch := newMessageTaskHandler(t, svc) + orch.turnStartResult = orchestrator.ProcessOnTurnStartResult{Queued: true} + + payload := senderPayload(target.ID, "wait for admission", sender.ID) + payload["session_id"] = session.ID + msg := makeWSMessage(t, ws.ActionMCPMessageTask, payload) + resp, err := h.handleMessageTask(context.Background(), msg) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, ws.MessageTypeResponse, resp.Type) + + var out map[string]interface{} + require.NoError(t, json.Unmarshal(resp.Payload, &out)) + assert.Equal(t, "queued", out["status"]) + assert.Equal(t, session.ID, out["session_id"]) + assert.Empty(t, orch.promptCalls, "a queued turn-start transition must not send immediately") + status := orch.queue.GetStatus(context.Background(), session.ID) + require.Len(t, status.Entries, 1) + assert.Contains(t, status.Entries[0].Content, "wait for admission") +} + func TestHandleMessageTask_SessionIDWrongTask_Rejected(t *testing.T) { svc, repo := newTestTaskService(t) sender, target, sess := seedTaskWithSession(t, svc, repo, models.TaskSessionStateRunning) diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow.go b/apps/backend/internal/orchestrator/event_handlers_workflow.go index 58e2fd5819..42dd8c40a6 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "sync" "time" "go.uber.org/zap" @@ -25,6 +26,24 @@ import ( type turnCompletionCause string +type taskMetadataKeyRemover interface { + RemoveTaskMetadataKey(context.Context, string, string) (bool, error) +} + +type taskMetadataKeySetter interface { + SetTaskMetadataKey(context.Context, string, string, interface{}) error +} + +type lifecycleTaskMetadataLister interface { + ListTasksWithMetadataKey(context.Context, string) ([]*models.Task, error) +} + +type taskMovedLifecyclePrerequisites struct { + session *models.TaskSession + fromStep *wfmodels.WorkflowStep + targetStep *wfmodels.WorkflowStep +} + const ( turnCompletionCauseAgentTurn turnCompletionCause = "agent_turn" turnCompletionCauseUserCancellation turnCompletionCause = "user_cancellation" @@ -261,23 +280,29 @@ func (s *Service) processOnTurnStart(ctx context.Context, task *models.Task, ses return true } +// ProcessOnTurnStartResult reports whether the initiating prompt must wait for +// WIP admission before it can be delivered. +type ProcessOnTurnStartResult struct { + Queued bool +} + // ProcessOnTurnStart is the public API for triggering on_turn_start events. // Called by message handlers before sending a prompt to the agent. -func (s *Service) ProcessOnTurnStart(ctx context.Context, taskID, sessionID string) error { +func (s *Service) ProcessOnTurnStart(ctx context.Context, taskID, sessionID string) (ProcessOnTurnStartResult, error) { lock, release := s.acquireCancelInFlightGuard(sessionID) defer release() lock.Lock() defer lock.Unlock() if err := s.waitForCancellationWithGuard(ctx, sessionID, lock.Unlock, lock.Lock); err != nil { - return err + return ProcessOnTurnStartResult{}, err } session, err := s.repo.GetTaskSession(ctx, sessionID) if err != nil { - return fmt.Errorf("load session for on_turn_start: %w", err) + return ProcessOnTurnStartResult{}, fmt.Errorf("load session for on_turn_start: %w", err) } if isTerminalSessionState(session.State) { - return &executor.SessionStateSupersededError{ + return ProcessOnTurnStartResult{}, &executor.SessionStateSupersededError{ SessionID: session.ID, State: session.State, } @@ -287,7 +312,15 @@ func (s *Service) ProcessOnTurnStart(ctx context.Context, taskID, sessionID stri // user is continuing the conversation; this step is no longer "done". s.clearPendingStepSignal(ctx, session) s.processOnTurnStartViaEngine(ctx, taskID, session) - return nil + task, err := s.repo.GetTask(ctx, taskID) + if err != nil { + // Do not let a read race turn an unknown admission state into an + // immediate prompt. The caller can retry after reconciliation. + return ProcessOnTurnStartResult{Queued: true}, fmt.Errorf("load task admission after on_turn_start: %w", err) + } + return ProcessOnTurnStartResult{ + Queued: task != nil && !task.WIPAdmitted && task.QueuedForStepID != "", + }, nil } // executeStepTransition moves a task/session from one step to another. @@ -313,12 +346,13 @@ func (s *Service) executeStepTransition(ctx context.Context, taskID, sessionID s s.setSessionWaitingForInput(ctx, taskID, sessionID) return } - // Atomically admit the target step before exit side effects. A capacity - // rejection must leave both the task and its source-step session state intact. + // Atomically admit the target step before exit side effects. A full target + // is represented as a durable destination queue entry instead of a failed + // transition. task.WorkflowStepID = toStepID task.UpdatedAt = time.Now().UTC() if err := s.updateTransitionTaskWithCapacity(ctx, task, targetStep); err != nil { - s.logger.Warn("workflow transition rejected or failed", + s.logger.Warn("workflow transition failed", zap.String("task_id", taskID), zap.String("from_step", fromStep.Name), zap.String("to_step", targetStep.Name), @@ -326,6 +360,7 @@ func (s *Service) executeStepTransition(ctx context.Context, taskID, sessionID s s.setSessionWaitingForInput(ctx, taskID, sessionID) return } + queued := task.QueuedForStepID != "" // Process on_exit only after the transition is durably admitted. Freshly // load the session since the caller may not have it (legacy path). @@ -340,7 +375,9 @@ func (s *Service) executeStepTransition(ctx context.Context, taskID, sessionID s // Publish task updated event via the task service so the payload carries // the full context (session counts, primary session, repositories). s.publishTaskUpdated(ctx, task) - s.processParentChildrenCompletedForTerminalStepMove(ctx, taskID, toStepID) + if !queued { + s.processParentChildrenCompletedForTerminalStepMove(ctx, taskID, toStepID) + } s.logger.Info("workflow transition completed", zap.String("task_id", taskID), @@ -352,6 +389,10 @@ func (s *Service) executeStepTransition(ctx context.Context, taskID, sessionID s if s.workflowStore != nil { s.workflowStore.pullNextTaskOnVacate(ctx, fromStep.ID, taskID) } + if queued { + s.setSessionWaitingForInput(ctx, taskID, sessionID) + return + } // ADR 0015 — record the audit row before the pending signal (if any) // is cleared below. Only the triggerOnEnter=true (legacy @@ -417,20 +458,15 @@ func (s *Service) updateTransitionTaskWithCapacity( task *models.Task, targetStep *wfmodels.WorkflowStep, ) error { - if targetStep == nil || targetStep.WIPLimit <= 0 { + if targetStep == nil { return s.repo.UpdateTask(ctx, task) } - limitedRepo, ok := s.repo.(workflowLimitedMoveRepository) + admissionRepo, ok := s.repo.(workflowMoveAdmissionRepository) if !ok { - return fmt.Errorf("WIP limit cannot be checked for workflow step %s", targetStep.ID) + return fmt.Errorf("workflow step admission repository unavailable for step %s", targetStep.ID) } - return limitedRepo.UpdateTaskIfWorkflowStepHasCapacity( - ctx, - task, - targetStep.ID, - task.ID, - targetStep.WIPLimit, - ) + _, err := admissionRepo.UpdateTaskWithWorkflowStepAdmission(ctx, task, targetStep.ID, targetStep.WIPLimit) + return err } // handleTaskMoved handles manual task step changes (drag-and-drop, stepper "Move here"). @@ -448,18 +484,124 @@ func (s *Service) handleTaskMoved(ctx context.Context, data watcher.TaskMovedEve if s.workflowStepGetter == nil { return } - - s.processParentChildrenCompletedForTerminalStepMove(ctx, data.TaskID, data.ToStepID) - - // No session yet — check if we need to create one via auto-start - if data.SessionID == "" { - s.handleTaskMovedNoSession(ctx, data) + task, err := s.repo.GetTask(ctx, data.TaskID) + if err != nil || task == nil { + return + } + queued := data.QueuedForStepID != "" && !data.WIPAdmitted + prerequisites, ok := s.loadTaskMovedLifecyclePrerequisites(ctx, data, queued) + if !ok { + return + } + if queued { + if task.WorkflowStepID != data.ToStepID || task.QueuedForStepID != data.QueuedForStepID || task.WIPAdmitted { + return + } + if !s.ensureQueuedMoveExitDescriptor(ctx, task, data.FromStepID) { + return + } + } else if task.WorkflowStepID != data.ToStepID { + // The move event is published after the task write. A replay for a + // later move must not re-run the older destination lifecycle. + return + } + if data.QueuePromotion && !s.claimTaskEventMetadata(ctx, task, models.MetaKeyQueuePromotionPending) { return } + if !queued { + s.processParentChildrenCompletedForTerminalStepMove(ctx, data.TaskID, data.ToStepID) + } + + // No session yet — check if we need to create one via auto-start + if data.SessionID == "" { + if queued { + // A task without a session has no source-session side effect to + // run. Persist the completed no-op so promotion is not blocked by + // the queued-move barrier. + if s.persistQueuedMoveExitCompletion(ctx, task.ID) { + s.continueQueuedMoveLifecycle(ctx, task.ID, data.FromStepID) + } + return + } + if prerequisites != nil && prerequisites.targetStep != nil { + s.autoStartTaskForLoadedStep(ctx, task, prerequisites.targetStep, "task.moved", data.QueuePromotion) + } else { + s.handleTaskMovedNoSession(ctx, data) + } + return + } + + if prerequisites != nil && prerequisites.session != nil { + s.handleTaskMovedWithLoadedSession(ctx, data, prerequisites.session, prerequisites.fromStep, prerequisites.targetStep) + return + } s.handleTaskMovedWithSession(ctx, data) } +func (s *Service) loadTaskMovedLifecyclePrerequisites( + ctx context.Context, + data watcher.TaskMovedEventData, + queued bool, +) (*taskMovedLifecyclePrerequisites, bool) { + if !queued && !data.QueuePromotion { + return nil, true + } + prerequisites := &taskMovedLifecyclePrerequisites{} + if data.QueuePromotion { + targetStep, ok := s.loadTaskMovedPromotionTarget(ctx, data) + if !ok { + return nil, false + } + prerequisites.targetStep = targetStep + } + if data.SessionID == "" { + return prerequisites, true + } + session, fromStep, ok := s.loadTaskMovedSessionPrerequisites(ctx, data) + if !ok { + return nil, false + } + prerequisites.session = session + prerequisites.fromStep = fromStep + return prerequisites, true +} + +func (s *Service) loadTaskMovedPromotionTarget(ctx context.Context, data watcher.TaskMovedEventData) (*wfmodels.WorkflowStep, bool) { + targetStep, err := s.workflowStepGetter.GetStep(ctx, data.ToStepID) + if err != nil || targetStep == nil { + s.logger.Warn("task.moved: failed to load promotion target step", + zap.String("task_id", data.TaskID), + zap.String("step_id", data.ToStepID), + zap.Error(err)) + return nil, false + } + return targetStep, true +} + +func (s *Service) loadTaskMovedSessionPrerequisites( + ctx context.Context, + data watcher.TaskMovedEventData, +) (*models.TaskSession, *wfmodels.WorkflowStep, bool) { + session, err := s.repo.GetTaskSession(ctx, data.SessionID) + if err != nil || session == nil { + s.logger.Warn("task.moved: failed to load lifecycle session", + zap.String("task_id", data.TaskID), + zap.String("session_id", data.SessionID), + zap.Error(err)) + return nil, nil, false + } + fromStep, err := s.workflowStepGetter.GetStep(ctx, data.FromStepID) + if err != nil || fromStep == nil { + s.logger.Warn("task.moved: failed to load lifecycle source step", + zap.String("task_id", data.TaskID), + zap.String("step_id", data.FromStepID), + zap.Error(err)) + return nil, nil, false + } + return session, fromStep, true +} + // handleTaskMovedNoSession handles the case where a task is moved but has no session. // If the target step has auto_start_agent, it creates a session and starts the agent // using agent/executor profile IDs from the task's metadata. @@ -473,7 +615,260 @@ func (s *Service) handleTaskQueuePromoted(ctx context.Context, data watcher.Task s.logger.Warn("task.queue_promoted: failed to load task", zap.String("task_id", data.TaskID), zap.Error(err)) return } - s.autoStartTaskForStep(ctx, task.ID, task.WorkflowStepID, "task.queue_promoted") + if task.QueuedForStepID != "" || !task.WIPAdmitted { + return + } + if queuedMoveExitPending(task) { + // A queued manual move has not finished its source-step exit yet. + // Keep the promotion token durable; source-exit completion will trigger + // queue reconciliation and retry destination entry. + return + } + targetStep, err := s.workflowStepGetter.GetStep(ctx, task.WorkflowStepID) + if err != nil || targetStep == nil { + s.logger.Warn("task.queue_promoted: failed to load target step", + zap.String("task_id", task.ID), zap.String("step_id", task.WorkflowStepID), zap.Error(err)) + return + } + if err := s.syncTaskStateForQueuePromotion(ctx, task, targetStep); err != nil { + s.logger.Warn("task.queue_promoted: failed to synchronize task state", + zap.String("task_id", task.ID), zap.Error(err)) + return + } + session, sessionErr := s.repo.GetActiveTaskSessionByTaskID(ctx, task.ID) + if sessionErr != nil { + s.logger.Warn("task.queue_promoted: failed to load active session", + zap.String("task_id", task.ID), zap.Error(sessionErr)) + return + } + if !s.claimTaskEventMetadata(ctx, task, models.MetaKeyQueuePromotionPending) { + return + } + if remover, ok := s.repo.(taskMetadataKeyRemover); ok { + _, _ = remover.RemoveTaskMetadataKey(ctx, task.ID, models.MetaKeyQueuedMoveExitCompleted) + } + s.processParentChildrenCompletedForTerminalStepMove(ctx, task.ID, targetStep.ID) + if session != nil { + go func() { + if err := s.finalizeStepEnter(context.WithoutCancel(ctx), task.ID, session.ID, targetStep, task.Description, targetStep.HasOnEnterAction(wfmodels.OnEnterAutoStartAgent)); err != nil { + s.restoreTaskLifecycleToken(context.WithoutCancel(ctx), task.ID, models.MetaKeyQueuePromotionPending, "task.queue_promoted") + return + } + if s.onTaskQueuePromotionEntryComplete != nil { + s.onTaskQueuePromotionEntryComplete() + } + }() + return + } + s.autoStartTaskForLoadedStep(ctx, task, targetStep, "task.queue_promoted", true) +} + +func (s *Service) claimTaskEventMetadata(ctx context.Context, task *models.Task, key string) bool { + if task == nil || task.Metadata == nil { + return false + } + if _, marked := task.Metadata[key]; !marked { + // One-shot lifecycle events use metadata removal as their claim. Its + // absence means another delivery already claimed the event. + return false + } + remover, ok := s.repo.(taskMetadataKeyRemover) + if !ok { + s.logger.Warn("task lifecycle event cannot be claimed: repository lacks metadata-key removal", + zap.String("task_id", task.ID), zap.String("metadata_key", key)) + return false + } + claimed, err := remover.RemoveTaskMetadataKey(ctx, task.ID, key) + if err != nil { + s.logger.Warn("failed to claim task lifecycle event", zap.String("task_id", task.ID), zap.String("metadata_key", key), zap.Error(err)) + return false + } + return claimed +} + +func (s *Service) restoreTaskLifecycleToken(ctx context.Context, taskID, key, eventName string) { + setter, ok := s.repo.(taskMetadataKeySetter) + if !ok { + s.logger.Warn(eventName+": repository cannot restore lifecycle token", + zap.String("task_id", taskID), zap.String("metadata_key", key)) + return + } + if err := setter.SetTaskMetadataKey(ctx, taskID, key, true); err != nil { + s.logger.Warn(eventName+": failed to restore lifecycle token", + zap.String("task_id", taskID), zap.String("metadata_key", key), zap.Error(err)) + } +} + +// reconcileTaskLifecycleTokens scans durable lifecycle markers at startup. +// A small fixed worker pool and bounded per-task attempts prevent a corrupted +// or repeatedly failing row from creating an unbounded goroutine storm. +func (s *Service) reconcileTaskLifecycleTokens(ctx context.Context) { + lister, ok := s.repo.(lifecycleTaskMetadataLister) + if !ok { + return + } + pending, err := lister.ListTasksWithMetadataKey(ctx, models.MetaKeyQueuedMoveExitPending) + if err != nil { + s.logger.Warn("failed to list queued move exit tokens for recovery", zap.Error(err)) + return + } + promotions, err := lister.ListTasksWithMetadataKey(ctx, models.MetaKeyQueuePromotionPending) + if err != nil { + s.logger.Warn("failed to list queue promotion tokens for recovery", zap.Error(err)) + return + } + jobs := make(map[string]struct{}, len(pending)+len(promotions)) + for _, task := range pending { + if task != nil { + jobs[task.ID] = struct{}{} + } + } + for _, task := range promotions { + if task != nil { + jobs[task.ID] = struct{}{} + } + } + if len(jobs) == 0 { + return + } + jobIDs := make(chan string) + workerCount := 4 + if len(jobs) < workerCount { + workerCount = len(jobs) + } + var wg sync.WaitGroup + for i := 0; i < workerCount; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for taskID := range jobIDs { + s.recoverTaskLifecycleToken(ctx, taskID) + } + }() + } + for taskID := range jobs { + jobIDs <- taskID + } + close(jobIDs) + wg.Wait() +} + +func (s *Service) recoverTaskLifecycleToken(ctx context.Context, taskID string) { + const maxAttempts = 3 + for attempt := 0; attempt < maxAttempts; attempt++ { + if !s.recoverTaskLifecycleAttempt(ctx, taskID) { + return + } + if attempt+1 == maxAttempts { + return + } + if !waitForLifecycleRecovery(ctx) { + return + } + } +} + +func (s *Service) recoverTaskLifecycleAttempt(ctx context.Context, taskID string) bool { + task, err := s.repo.GetTask(ctx, taskID) + if err != nil || task == nil { + return false + } + if queuedMoveExitPending(task) { + if !s.recoverQueuedMoveExit(ctx, task) { + return false + } + task, err = s.repo.GetTask(ctx, taskID) + if err != nil || task == nil { + return false + } + } + if _, pending := task.Metadata[models.MetaKeyQueuePromotionPending]; pending { + s.handleTaskQueuePromoted(ctx, watcher.TaskEventData{TaskID: taskID}) + } + latest, err := s.repo.GetTask(ctx, taskID) + if err != nil || latest == nil { + return false + } + return queuedMoveExitPending(latest) || hasQueuePromotionPending(latest) +} + +func (s *Service) recoverQueuedMoveExit(ctx context.Context, task *models.Task) bool { + sourceStepID := queuedMoveExitSourceStep(task) + session, err := s.repo.GetActiveTaskSessionByTaskID(ctx, task.ID) + if sourceStepID == "" || err != nil || session == nil || s.workflowStepGetter == nil { + return false + } + fromStep, err := s.workflowStepGetter.GetStep(ctx, sourceStepID) + if err != nil || fromStep == nil { + return false + } + // Startup recovery is already running in a bounded worker. Run this + // serialized lifecycle operation directly so the next attempt observes + // its durable completion instead of racing an unbounded detached goroutine. + s.processQueuedMoveExit(ctx, task.ID, session, fromStep, sourceStepID) + return true +} + +func hasQueuePromotionPending(task *models.Task) bool { + if task == nil || task.Metadata == nil { + return false + } + _, pending := task.Metadata[models.MetaKeyQueuePromotionPending] + return pending +} + +func waitForLifecycleRecovery(ctx context.Context) bool { + timer := time.NewTimer(25 * time.Millisecond) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-timer.C: + return true + } +} + +func queuedMoveExitSourceStep(task *models.Task) string { + if task == nil || task.Metadata == nil { + return "" + } + value, ok := task.Metadata[models.MetaKeyQueuedMoveExitPending] + if !ok { + return "" + } + if descriptor, ok := value.(map[string]interface{}); ok { + source, _ := descriptor["from_step_id"].(string) + return source + } + return "" +} + +func (s *Service) syncTaskStateForQueuePromotion(ctx context.Context, task *models.Task, targetStep *wfmodels.WorkflowStep) error { + if targetStep == nil { + return nil + } + next, err := s.workflowStepGetter.GetNextStepByPosition(ctx, targetStep.WorkflowID, targetStep.Position) + if err != nil { + return fmt.Errorf("load next step after promoted step %s: %w", targetStep.ID, err) + } + oldState := task.State + if wfmodels.IsTerminalStep(targetStep, next) { + if !models.IsTerminalTaskState(task.State) { + task.State = v1.TaskStateCompleted + } + } else if task.State == v1.TaskStateCompleted { + task.State = v1.TaskStateTODO + } + if oldState == task.State { + return nil + } + task.UpdatedAt = time.Now().UTC() + if err := s.repo.UpdateTask(ctx, task); err != nil { + return fmt.Errorf("persist promoted task state: %w", err) + } + s.publishTaskUpdated(ctx, task) + s.publishTaskStateChanged(ctx, task, oldState) + return nil } func (s *Service) autoStartTaskForStep(ctx context.Context, taskID, stepID, eventName string) { @@ -506,10 +901,20 @@ func (s *Service) autoStartTaskForStep(ctx context.Context, taskID, stepID, even zap.Error(err)) return } + s.autoStartTaskForLoadedStep(ctx, task, step, eventName, false) +} + +func (s *Service) autoStartTaskForLoadedStep(ctx context.Context, task *models.Task, step *wfmodels.WorkflowStep, eventName string, restoreQueuePromotion bool) { + if task == nil || task.QueuedForStepID != "" || step == nil { + return + } + if s.launchDeferredTask(ctx, task, eventName) { + return + } if step == nil || !step.HasOnEnterAction(wfmodels.OnEnterAutoStartAgent) { s.logger.Debug(eventName+": target step has no auto-start", - zap.String("task_id", taskID), - zap.String("to_step_id", stepID)) + zap.String("task_id", task.ID), + zap.String("to_step_id", step.ID)) return } @@ -523,8 +928,8 @@ func (s *Service) autoStartTaskForStep(ctx context.Context, taskID, stepID, even planMode := step.HasOnEnterAction(wfmodels.OnEnterEnablePlanMode) s.logger.Info(eventName+": starting task (no session, auto-start step)", - zap.String("task_id", taskID), - zap.String("to_step_id", stepID), + zap.String("task_id", task.ID), + zap.String("to_step_id", step.ID), zap.String("agent_profile_id", agentProfileID), zap.String("executor_id", executorID), zap.String("executor_profile_id", executorProfileID), @@ -542,7 +947,7 @@ func (s *Service) autoStartTaskForStep(ctx context.Context, taskID, stepID, even if _, hasGuard := task.Metadata[models.MetaKeyAutoStartGuard]; hasGuard { if !s.claimAutoStart(asyncCtx, task.ID, eventName) { s.logger.Debug(eventName+": auto-start claim lost; watcher path will launch", - zap.String("task_id", taskID)) + zap.String("task_id", task.ID)) return } } @@ -551,12 +956,15 @@ func (s *Service) autoStartTaskForStep(ctx context.Context, taskID, stepID, even if workflowAgentProfileID != "" { startAgentProfileID = "" } - _, err := s.StartTask(asyncCtx, task.ID, startAgentProfileID, executorID, executorProfileID, "", task.Description, stepID, planMode, true, nil) + _, err := s.StartTask(asyncCtx, task.ID, startAgentProfileID, executorID, executorProfileID, "", task.Description, step.ID, planMode, true, nil) if err != nil { s.logger.Error(eventName+": failed to auto-start task", - zap.String("task_id", taskID), + zap.String("task_id", task.ID), zap.Error(err)) s.restoreAutoStartClaim(asyncCtx, task.ID, eventName) + if restoreQueuePromotion { + s.restoreTaskLifecycleToken(asyncCtx, task.ID, models.MetaKeyQueuePromotionPending, eventName) + } } }() } @@ -622,9 +1030,7 @@ func (s *Service) launchDeferredTask(ctx context.Context, task *models.Task, eve } func (s *Service) claimDeferredLaunch(ctx context.Context, taskID, eventName string) (bool, bool) { - remover, ok := s.repo.(interface { - RemoveTaskMetadataKey(context.Context, string, string) (bool, error) - }) + remover, ok := s.repo.(taskMetadataKeyRemover) if !ok { return false, true } @@ -660,9 +1066,7 @@ func (s *Service) restoreDeferredLaunch(ctx context.Context, taskID string, raw // only the winner proceeds to StartTask. When the token is absent (ordinary // non-watcher auto-start), the caller skips the claim and launches as before. func (s *Service) claimAutoStart(ctx context.Context, taskID, eventName string) bool { - remover, ok := s.repo.(interface { - RemoveTaskMetadataKey(context.Context, string, string) (bool, error) - }) + remover, ok := s.repo.(taskMetadataKeyRemover) if !ok { return true // repo doesn't support atomic removal; allow launch } @@ -706,8 +1110,176 @@ func (s *Service) handleTaskMovedWithSession(ctx context.Context, data watcher.T zap.Error(err)) return } + s.fromStepAndTargetForTaskMoved(ctx, data, session, nil, nil) +} - go s.processStepExitAndEnter(context.WithoutCancel(ctx), data.TaskID, session, data.FromStepID, data.ToStepID, data.TaskDescription) +func (s *Service) handleTaskMovedWithLoadedSession( + ctx context.Context, + data watcher.TaskMovedEventData, + session *models.TaskSession, + fromStep, targetStep *wfmodels.WorkflowStep, +) { + s.fromStepAndTargetForTaskMoved(ctx, data, session, fromStep, targetStep) +} + +func (s *Service) fromStepAndTargetForTaskMoved( + ctx context.Context, + data watcher.TaskMovedEventData, + session *models.TaskSession, + fromStep, targetStep *wfmodels.WorkflowStep, +) { + if session == nil { + return + } + + if data.QueuedForStepID != "" && !data.WIPAdmitted { + go s.processQueuedMoveExit( + context.WithoutCancel(ctx), data.TaskID, session, fromStep, data.FromStepID, + ) + return + } + go s.processStepExitAndEnterWithSteps( + context.WithoutCancel(ctx), data.TaskID, session, fromStep, targetStep, + data.FromStepID, data.ToStepID, data.TaskDescription, data.QueuePromotion, + ) +} + +func (s *Service) processStepExit(ctx context.Context, taskID string, session *models.TaskSession, fromStepID string) { + fromStep, err := s.workflowStepGetter.GetStep(ctx, fromStepID) + if err != nil || fromStep == nil { + s.logger.Warn("failed to load from-step for queued move on_exit", + zap.String("step_id", fromStepID), zap.Error(err)) + return + } + s.processStepExitWithStep(ctx, taskID, session, fromStep, fromStepID) +} + +func (s *Service) processStepExitWithStep(ctx context.Context, taskID string, session *models.TaskSession, fromStep *wfmodels.WorkflowStep, fromStepID string) { + if fromStep == nil { + var err error + fromStep, err = s.workflowStepGetter.GetStep(ctx, fromStepID) + if err != nil || fromStep == nil { + s.logger.Warn("failed to load from-step for queued move on_exit", + zap.String("step_id", fromStepID), zap.Error(err)) + return + } + } + s.processOnExit(ctx, taskID, session, fromStep) +} + +// ensureQueuedMoveExitDescriptor records the source step on the durable +// queued-move token. Older rows may contain only true; the event payload lets +// the first delivery upgrade those rows before the asynchronous side effect. +func (s *Service) ensureQueuedMoveExitDescriptor(ctx context.Context, task *models.Task, fromStepID string) bool { + if task == nil || fromStepID == "" || task.Metadata == nil { + return false + } + if _, completed := task.Metadata[models.MetaKeyQueuedMoveExitCompleted]; completed { + return false + } + value, marked := task.Metadata[models.MetaKeyQueuedMoveExitPending] + if !marked { + return false + } + if descriptor, ok := value.(map[string]interface{}); ok { + if source, _ := descriptor["from_step_id"].(string); source == fromStepID { + return true + } + } + setter, ok := s.repo.(taskMetadataKeySetter) + if !ok { + return false + } + if err := setter.SetTaskMetadataKey(ctx, task.ID, models.MetaKeyQueuedMoveExitPending, map[string]interface{}{ + "from_step_id": fromStepID, + }); err != nil { + s.logger.Warn("failed to persist queued move source step", + zap.String("task_id", task.ID), zap.Error(err)) + return false + } + return true +} + +func queuedMoveExitCompleted(task *models.Task) bool { + if task == nil || task.Metadata == nil { + return false + } + _, completed := task.Metadata[models.MetaKeyQueuedMoveExitCompleted] + return completed +} + +// processQueuedMoveExit runs the source-step side effect exactly once per +// task. The in-memory lock serializes duplicate event deliveries, while the +// pending/completed metadata pair makes the ordering recoverable after a +// process restart. +func (s *Service) processQueuedMoveExit( + ctx context.Context, + taskID string, + session *models.TaskSession, + fromStep *wfmodels.WorkflowStep, + fromStepID string, +) { + lockValue, _ := s.queuedMoveLifecycleLocks.LoadOrStore(taskID, &sync.Mutex{}) + lock := lockValue.(*sync.Mutex) + lock.Lock() + defer lock.Unlock() + + task, err := s.repo.GetTask(ctx, taskID) + if err != nil || task == nil || queuedMoveExitCompleted(task) { + return + } + if _, pending := task.Metadata[models.MetaKeyQueuedMoveExitPending]; !pending { + return + } + if sourceStepID := queuedMoveExitSourceStep(task); sourceStepID != "" && sourceStepID != fromStepID { + // A stale delivery from an earlier move must not execute against the + // marker for a newer queued move on the same task. + return + } + if s.onQueuedMoveExitStart != nil { + s.onQueuedMoveExitStart() + } + s.processStepExitWithStep(ctx, taskID, session, fromStep, fromStepID) + if !s.persistQueuedMoveExitCompletion(ctx, taskID) { + return + } + if s.onQueuedMoveExitComplete != nil { + s.onQueuedMoveExitComplete() + } + s.continueQueuedMoveLifecycle(ctx, taskID, fromStepID) +} + +func (s *Service) persistQueuedMoveExitCompletion(ctx context.Context, taskID string) bool { + setter, ok := s.repo.(taskMetadataKeySetter) + if !ok { + s.logger.Warn("queued move source-exit completion cannot be persisted", + zap.String("task_id", taskID)) + return false + } + if err := setter.SetTaskMetadataKey(ctx, taskID, models.MetaKeyQueuedMoveExitCompleted, true); err != nil { + s.logger.Warn("failed to persist queued move source-exit completion", + zap.String("task_id", taskID), zap.Error(err)) + return false + } + if remover, ok := s.repo.(taskMetadataKeyRemover); ok { + if _, err := remover.RemoveTaskMetadataKey(ctx, taskID, models.MetaKeyQueuedMoveExitPending); err != nil { + s.logger.Warn("failed to clear queued move source-exit token", + zap.String("task_id", taskID), zap.Error(err)) + } + } + return true +} + +func (s *Service) continueQueuedMoveLifecycle(ctx context.Context, taskID, vacatedStepID string) { + latest, err := s.repo.GetTask(ctx, taskID) + if err != nil || latest == nil { + return + } + if hasQueuePromotionPending(latest) { + s.handleTaskQueuePromoted(ctx, watcher.TaskEventData{TaskID: taskID}) + } else if latest.QueuedForStepID != "" && s.workflowStore != nil && vacatedStepID != "" { + s.workflowStore.pullNextTaskOnVacate(ctx, vacatedStepID, "") + } } // processStepExitAndEnter runs the on_exit → clear review → reload session → on_enter @@ -715,36 +1287,57 @@ func (s *Service) handleTaskMovedWithSession(ctx context.Context, data watcher.T // already persisted the step change in the DB). func (s *Service) processStepExitAndEnter(ctx context.Context, taskID string, session *models.TaskSession, fromStepID, toStepID, taskDescription string) { // Process on_exit for the step we're leaving - fromStep, err := s.workflowStepGetter.GetStep(ctx, fromStepID) - if err != nil || fromStep == nil { - s.logger.Warn("failed to load from-step for on_exit", - zap.String("step_id", fromStepID), - zap.Error(err)) + s.processStepExitAndEnterWithSteps(ctx, taskID, session, nil, nil, fromStepID, toStepID, taskDescription, false) +} + +func (s *Service) processStepExitAndEnterWithSteps( + ctx context.Context, + taskID string, + session *models.TaskSession, + fromStep, targetStep *wfmodels.WorkflowStep, + fromStepID, toStepID, taskDescription string, queuePromotion bool, +) { + if fromStep == nil { + var err error + fromStep, err = s.workflowStepGetter.GetStep(ctx, fromStepID) + if err != nil || fromStep == nil { + s.logger.Warn("failed to load from-step for on_exit", + zap.String("step_id", fromStepID), + zap.Error(err)) + } else { + s.processOnExit(ctx, taskID, session, fromStep) + } } else { s.processOnExit(ctx, taskID, session, fromStep) } - targetStep, err := s.workflowStepGetter.GetStep(ctx, toStepID) - if err != nil || targetStep == nil { - s.logger.Warn("failed to load target step for on_enter", - zap.String("step_id", toStepID), - zap.Error(err)) - return + if targetStep == nil { + var err error + targetStep, err = s.workflowStepGetter.GetStep(ctx, toStepID) + if err != nil || targetStep == nil { + s.logger.Warn("failed to load target step for on_enter", + zap.String("step_id", toStepID), + zap.Error(err)) + return + } } clearReview := targetStep.HasOnEnterAction(wfmodels.OnEnterAutoStartAgent) - s.finalizeStepEnter(ctx, taskID, session.ID, targetStep, taskDescription, clearReview) + if err := s.finalizeStepEnter(ctx, taskID, session.ID, targetStep, taskDescription, clearReview); err != nil && queuePromotion { + s.restoreTaskLifecycleToken(ctx, taskID, models.MetaKeyQueuePromotionPending, "task.moved") + } } // finalizeStepEnter optionally clears review status, reloads the session, and // processes on_enter actions for the target step. Shared by executeStepTransition // and processStepExitAndEnter. -func (s *Service) finalizeStepEnter(ctx context.Context, taskID, sessionID string, targetStep *wfmodels.WorkflowStep, taskDescription string, clearReview bool) { +func (s *Service) finalizeStepEnter(ctx context.Context, taskID, sessionID string, targetStep *wfmodels.WorkflowStep, taskDescription string, clearReview bool) error { if clearReview { if err := s.repo.UpdateSessionReviewStatus(ctx, sessionID, ""); err != nil { s.logger.Warn("failed to clear session review status", zap.String("session_id", sessionID), zap.Error(err)) + return fmt.Errorf("clear session review status: %w", err) } } @@ -754,10 +1347,11 @@ func (s *Service) finalizeStepEnter(ctx context.Context, taskID, sessionID strin s.logger.Warn("failed to load session for on_enter", zap.String("session_id", sessionID), zap.Error(err)) s.setSessionWaitingForInput(ctx, taskID, sessionID) - return + return fmt.Errorf("load session for on_enter: %w", err) } s.processOnEnter(ctx, taskID, session, targetStep, taskDescription) + return nil } // resolveStepPlanMode determines whether plan mode should be active for a step. @@ -1379,6 +1973,12 @@ func (s *Service) applyPendingMove(ctx context.Context, taskID, sessionID string zap.String("session_id", sessionID), zap.String("from_step_id", fromStepID), zap.String("to_step_id", move.WorkflowStepID)) + if stored, loadErr := s.repo.GetTask(ctx, taskID); loadErr == nil && stored != nil && stored.QueuedForStepID == move.WorkflowStepID && !stored.WIPAdmitted { + // The destination is visible and queued, but its entry lifecycle is + // deferred until promotion. The source exit still runs once now. + go s.processStepExit(context.WithoutCancel(ctx), taskID, session, fromStepID) + return + } s.syncTaskStateForPendingMove(ctx, taskID, fromStepID, move.WorkflowStepID) @@ -2816,6 +3416,14 @@ func (s *Service) applyEngineTransition( } } + queuedTask, queuedErr := s.repo.GetTask(ctx, taskID) + if queuedErr == nil && queuedTask != nil && queuedTask.QueuedForStepID == result.ToStepID && !queuedTask.WIPAdmitted { + // The source transition is committed, but destination state and + // on_enter behavior wait for the queue promotion event. + s.setSessionWaitingForInput(ctx, taskID, session.ID, session) + return true + } + if terminalTarget { s.markTaskCompletedForTerminalStep(ctx, taskID, targetStep.ID) } diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow_test.go b/apps/backend/internal/orchestrator/event_handlers_workflow_test.go index 90c3c4ef8d..141218b186 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow_test.go @@ -4,13 +4,16 @@ import ( "context" "errors" "sync" + "sync/atomic" "testing" "time" "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" wfmodels "github.com/kandev/kandev/internal/workflow/models" + v1 "github.com/kandev/kandev/pkg/api/v1" ) // mockEventBus captures published events for assertion. @@ -19,7 +22,7 @@ type mockEventBus struct { events []publishedEvent } -func TestUpdateTransitionTaskWithCapacity_RejectsFullLimitedStep(t *testing.T) { +func TestUpdateTransitionTaskWithCapacity_QueuesFullLimitedStep(t *testing.T) { ctx := context.Background() repo := setupTestRepo(t) seedSession(t, repo, "t1", "s1", "step1") @@ -44,19 +47,19 @@ func TestUpdateTransitionTaskWithCapacity_RejectsFullLimitedStep(t *testing.T) { target := &wfmodels.WorkflowStep{ID: "step2", WorkflowID: "wf1", WIPLimit: 1} err = svc.updateTransitionTaskWithCapacity(ctx, task, target) - if !errors.Is(err, wfmodels.ErrWIPLimitExceeded) { - t.Fatalf("error=%v, want typed WIP rejection", err) + if err != nil { + t.Fatalf("updateTransitionTaskWithCapacity: %v", err) } stored, err := repo.GetTask(ctx, "t1") if err != nil { t.Fatalf("reload task: %v", err) } - if stored.WorkflowStepID != "step1" { - t.Fatalf("task moved despite full target, got %q", stored.WorkflowStepID) + if stored.WorkflowStepID != "step2" || stored.WIPAdmitted || stored.QueuedForStepID != "step2" { + t.Fatalf("queued task placement: step=%q admitted=%t queue=%q", stored.WorkflowStepID, stored.WIPAdmitted, stored.QueuedForStepID) } } -func TestExecuteStepTransition_FullTargetLeavesOnExitStateIntact(t *testing.T) { +func TestExecuteStepTransition_FullTargetRunsExitAndDefersEntry(t *testing.T) { ctx := context.Background() repo := setupTestRepo(t) seedSession(t, repo, "t1", "s1", "step1") @@ -93,15 +96,329 @@ func TestExecuteStepTransition_FullTargetLeavesOnExitStateIntact(t *testing.T) { if err != nil { t.Fatalf("load task: %v", err) } - if task.WorkflowStepID != "step1" { - t.Fatalf("task moved despite full target, got %q", task.WorkflowStepID) + if task.WorkflowStepID != "step2" || task.WIPAdmitted || task.QueuedForStepID != "step2" { + t.Fatalf("queued task placement: step=%q admitted=%t queue=%q", task.WorkflowStepID, task.WIPAdmitted, task.QueuedForStepID) } session, err := repo.GetTaskSession(ctx, "s1") if err != nil { t.Fatalf("load session: %v", err) } - if enabled, _ := session.Metadata["plan_mode"].(bool); !enabled { - t.Fatal("capacity rejection must not run source step's on_exit actions") + if enabled, _ := session.Metadata["plan_mode"].(bool); enabled { + t.Fatal("queued transition must run source step's on_exit actions") + } +} + +func TestClaimTaskEventMetadataIsOneShot(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + now := time.Now().UTC() + if err := repo.CreateTask(ctx, &models.Task{ + ID: "queued-lifecycle", + WorkspaceID: "ws1", + WorkflowID: "wf1", + WorkflowStepID: "step2", + Title: "Queued lifecycle", + State: "TODO", + CreatedAt: now, + UpdatedAt: now, + Metadata: map[string]interface{}{ + models.MetaKeyQueuePromotionPending: true, + }, + }); err != nil { + t.Fatalf("create task: %v", err) + } + + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + task, err := repo.GetTask(ctx, "queued-lifecycle") + if err != nil { + t.Fatalf("load task: %v", err) + } + if !svc.claimTaskEventMetadata(ctx, task, models.MetaKeyQueuePromotionPending) { + t.Fatal("first lifecycle event claim was rejected") + } + + claimedTask, err := repo.GetTask(ctx, "queued-lifecycle") + if err != nil { + t.Fatalf("reload task: %v", err) + } + if svc.claimTaskEventMetadata(ctx, claimedTask, models.MetaKeyQueuePromotionPending) { + t.Fatal("replayed lifecycle event was claimed twice") + } +} + +func TestQueuedMoveLifecycleTokenRemainsPendingWhenPrerequisitesFail(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "queued-move-retry", "queued-move-session", "source-step") + task, err := repo.GetTask(ctx, "queued-move-retry") + if err != nil { + t.Fatalf("load task: %v", err) + } + task.WorkflowStepID = "destination-step" + task.WIPAdmitted = false + task.QueuedForStepID = "destination-step" + task.Metadata = map[string]interface{}{models.MetaKeyQueuedMoveExitPending: true} + if err := repo.UpdateTask(ctx, task); err != nil { + t.Fatalf("queue task: %v", err) + } + + // The source-step lookup fails before the one-shot token is claimed. A + // replay after the workflow snapshot is available must still be able to run + // the source on_exit action. + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + svc.handleTaskMoved(ctx, watcher.TaskMovedEventData{ + TaskID: task.ID, + SessionID: "queued-move-session", + FromStepID: "source-step", + ToStepID: "destination-step", + WIPAdmitted: false, + QueuedForStepID: "destination-step", + }) + + stored, err := repo.GetTask(ctx, task.ID) + if err != nil { + t.Fatalf("reload task: %v", err) + } + if _, ok := stored.Metadata[models.MetaKeyQueuedMoveExitPending]; !ok { + t.Fatal("queued move lifecycle token was consumed before prerequisites succeeded") + } +} + +func TestQueuePromotionTokenRemainsPendingWhenTargetLookupFails(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + if err := repo.CreateTask(ctx, &models.Task{ + ID: "promotion-retry", WorkspaceID: "ws1", WorkflowID: "wf1", WorkflowStepID: "missing-target", + Title: "Promotion retry", State: "TODO", WIPAdmitted: true, + Metadata: map[string]interface{}{models.MetaKeyQueuePromotionPending: true}, + }); err != nil { + t.Fatalf("create task: %v", err) + } + + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + svc.handleTaskQueuePromoted(ctx, watcher.TaskEventData{TaskID: "promotion-retry"}) + + stored, err := repo.GetTask(ctx, "promotion-retry") + if err != nil { + t.Fatalf("reload task: %v", err) + } + if _, ok := stored.Metadata[models.MetaKeyQueuePromotionPending]; !ok { + t.Fatal("queue promotion token was consumed before target lookup succeeded") + } +} + +func TestQueuedMoveWithoutSessionCompletesSourceExitBarrier(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + now := time.Now().UTC() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws1", Name: "Test", CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatalf("create workspace: %v", err) + } + if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf1", WorkspaceID: "ws1", Name: "Test", CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatalf("create workflow: %v", err) + } + if err := repo.CreateTask(ctx, &models.Task{ + ID: "queued-no-session", WorkspaceID: "ws1", WorkflowID: "wf1", + WorkflowStepID: "destination-step", Title: "Queued", State: v1.TaskStateTODO, + WIPAdmitted: false, QueuedForStepID: "destination-step", + Metadata: map[string]interface{}{ + models.MetaKeyQueuedMoveExitPending: map[string]interface{}{"from_step_id": "source-step"}, + }, + }); err != nil { + t.Fatalf("create queued task: %v", err) + } + steps := newMockStepGetter() + steps.steps["destination-step"] = &wfmodels.WorkflowStep{ID: "destination-step", WorkflowID: "wf1", Name: "Destination"} + svc := createTestService(repo, steps, newMockTaskRepo()) + svc.handleTaskMoved(ctx, watcher.TaskMovedEventData{ + TaskID: "queued-no-session", FromStepID: "source-step", ToStepID: "destination-step", + WIPAdmitted: false, QueuedForStepID: "destination-step", + }) + + stored, err := repo.GetTask(ctx, "queued-no-session") + if err != nil { + t.Fatalf("reload queued task: %v", err) + } + if queuedMoveExitPending(stored) || !queuedMoveExitCompleted(stored) { + t.Fatalf("source-exit barrier metadata = %#v, want completed", stored.Metadata) + } +} + +func TestQueuedMovePromotionWaitsForSourceExitCompletion(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "queued-barrier", "queued-barrier-session", "source-step") + if err := repo.SetSessionMetadataKey(ctx, "queued-barrier-session", "plan_mode", true); err != nil { + t.Fatalf("seed source plan mode: %v", err) + } + task, err := repo.GetTask(ctx, "queued-barrier") + if err != nil { + t.Fatalf("load task: %v", err) + } + task.WorkflowStepID = "destination-step" + task.WIPAdmitted = false + task.QueuedForStepID = "destination-step" + task.Metadata = map[string]interface{}{ + models.MetaKeyQueuedMoveExitPending: map[string]interface{}{"from_step_id": "source-step"}, + } + if err := repo.UpdateTask(ctx, task); err != nil { + t.Fatalf("queue task: %v", err) + } + + steps := newMockStepGetter() + steps.steps["source-step"] = &wfmodels.WorkflowStep{ + ID: "source-step", WorkflowID: "wf1", Name: "Source", + Events: wfmodels.StepEvents{OnExit: []wfmodels.OnExitAction{{Type: wfmodels.OnExitDisablePlanMode}}}, + } + steps.steps["destination-step"] = &wfmodels.WorkflowStep{ + ID: "destination-step", WorkflowID: "wf1", Name: "Destination", + Events: wfmodels.StepEvents{OnEnter: []wfmodels.OnEnterAction{{ + Type: wfmodels.OnEnterSetSessionMode, + Config: map[string]interface{}{"mode": "destination"}, + }}}, + } + svc := createTestService(repo, steps, newMockTaskRepo()) + exitStarted := make(chan struct{}) + releaseExit := make(chan struct{}) + exitCompleted := make(chan struct{}) + entryCompleted := make(chan struct{}) + svc.onQueuedMoveExitStart = func() { + close(exitStarted) + <-releaseExit + } + svc.onQueuedMoveExitComplete = func() { close(exitCompleted) } + svc.onTaskQueuePromotionEntryComplete = func() { close(entryCompleted) } + + svc.handleTaskMoved(ctx, watcher.TaskMovedEventData{ + TaskID: "queued-barrier", SessionID: "queued-barrier-session", + FromStepID: "source-step", ToStepID: "destination-step", + WIPAdmitted: false, QueuedForStepID: "destination-step", + }) + select { + case <-exitStarted: + case <-time.After(time.Second): + t.Fatal("source exit did not start") + } + + promoted, err := repo.GetTask(ctx, "queued-barrier") + if err != nil { + t.Fatalf("reload queued task: %v", err) + } + promoted.WIPAdmitted = true + promoted.QueuedForStepID = "" + promoted.Metadata[models.MetaKeyQueuePromotionPending] = true + if err := repo.UpdateTask(ctx, promoted); err != nil { + t.Fatalf("persist promoted task while exit is blocked: %v", err) + } + svc.handleTaskQueuePromoted(ctx, watcher.TaskEventData{TaskID: promoted.ID}) + select { + case <-entryCompleted: + t.Fatal("destination entry started before source exit completed") + default: + } + + close(releaseExit) + select { + case <-exitCompleted: + case <-time.After(time.Second): + t.Fatal("source exit did not complete") + } + svc.handleTaskQueuePromoted(ctx, watcher.TaskEventData{TaskID: promoted.ID}) + select { + case <-entryCompleted: + case <-time.After(time.Second): + t.Fatal("destination entry did not run after source exit completed") + } + + session, err := repo.GetTaskSession(ctx, "queued-barrier-session") + if err != nil { + t.Fatalf("reload barrier session: %v", err) + } + if session.Metadata[models.SessionMetaKeySessionMode] != "destination" { + t.Fatalf("session mode = %v, want destination", session.Metadata[models.SessionMetaKeySessionMode]) + } +} + +type failOnceLifecycleRepo struct { + sessionExecutorStore + failGetTaskSession bool + failureObserved chan struct{} + restorationObserved chan struct{} +} + +func (r *failOnceLifecycleRepo) GetTaskSession(ctx context.Context, id string) (*models.TaskSession, error) { + if r.failGetTaskSession { + r.failGetTaskSession = false + close(r.failureObserved) + return nil, errors.New("destination entry failed once") + } + return r.sessionExecutorStore.GetTaskSession(ctx, id) +} + +func (r *failOnceLifecycleRepo) SetTaskMetadataKey(ctx context.Context, taskID, key string, value interface{}) error { + err := r.sessionExecutorStore.SetTaskMetadataKey(ctx, taskID, key, value) + if err == nil && key == models.MetaKeyQueuePromotionPending && r.restorationObserved != nil { + close(r.restorationObserved) + } + return err +} + +func (r *failOnceLifecycleRepo) ListTasksWithMetadataKey(ctx context.Context, key string) ([]*models.Task, error) { + return r.sessionExecutorStore.(interface { + ListTasksWithMetadataKey(context.Context, string) ([]*models.Task, error) + }).ListTasksWithMetadataKey(ctx, key) +} + +func TestReconcileTaskLifecycleTokensRetriesDestinationEntryOnce(t *testing.T) { + ctx := context.Background() + baseRepo := setupTestRepo(t) + seedSession(t, baseRepo, "promotion-recovery", "promotion-recovery-session", "destination-step") + if err := baseRepo.SetTaskMetadataKey(ctx, "promotion-recovery", models.MetaKeyQueuePromotionPending, true); err != nil { + t.Fatalf("seed promotion token: %v", err) + } + steps := newMockStepGetter() + steps.steps["destination-step"] = &wfmodels.WorkflowStep{ID: "destination-step", WorkflowID: "wf1", Name: "Destination"} + svc := createTestService(baseRepo, steps, newMockTaskRepo()) + recoveryRepo := &failOnceLifecycleRepo{ + sessionExecutorStore: baseRepo, + failGetTaskSession: true, + failureObserved: make(chan struct{}), + restorationObserved: make(chan struct{}), + } + svc.repo = recoveryRepo + var entryCalls atomic.Int32 + entryCompleted := make(chan struct{}, 2) + svc.onTaskQueuePromotionEntryComplete = func() { + entryCalls.Add(1) + entryCompleted <- struct{}{} + } + + svc.handleTaskQueuePromoted(ctx, watcher.TaskEventData{TaskID: "promotion-recovery"}) + select { + case <-recoveryRepo.failureObserved: + case <-time.After(time.Second): + t.Fatal("initial destination-entry failure did not occur") + } + select { + case <-recoveryRepo.restorationObserved: + case <-time.After(time.Second): + t.Fatal("failed destination entry did not restore its lifecycle token") + } + svc.reconcileTaskLifecycleTokens(ctx) + select { + case <-entryCompleted: + case <-time.After(2 * time.Second): + t.Fatal("startup lifecycle recovery did not retry destination entry") + } + if got := entryCalls.Load(); got != 1 { + t.Fatalf("destination entry calls = %d, want exactly 1 successful retry", got) + } + stored, err := baseRepo.GetTask(ctx, "promotion-recovery") + if err != nil { + t.Fatalf("reload recovered task: %v", err) + } + if _, pending := stored.Metadata[models.MetaKeyQueuePromotionPending]; pending { + t.Fatal("queue promotion token remained after recovery") } } diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index 0ed9836749..5d5fa2952b 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -412,6 +412,14 @@ type Service struct { // onProcessOnEnterComplete is a package-test hook for synchronizing with // applyEngineTransition's asynchronous processOnEnter goroutine. onProcessOnEnterComplete func() + // queuedMoveExitStart and queuedMoveExitComplete are package-test hooks + // for the durable source-exit barrier. + onQueuedMoveExitStart func() + onQueuedMoveExitComplete func() + onTaskQueuePromotionEntryComplete func() + // queuedMoveLifecycleLocks serializes source-exit work per task. The + // completion marker remains durable so a restart can safely resume work. + queuedMoveLifecycleLocks sync.Map // engineOptions are applied each time initWorkflowEngine runs. Wired // from cmd/kandev (Phase 3.2) to plug Phase 2 ADR-0004 dependencies // — RunQueueAdapter, ParticipantStore, DecisionStore, and the CEO / @@ -1130,6 +1138,10 @@ func (s *Service) publishTaskMoved(ctx context.Context, task *models.Task, fromW if s.eventBus == nil || task == nil { return } + queuePromotion := false + if task.Metadata != nil { + _, queuePromotion = task.Metadata[models.MetaKeyQueuePromotionPending] + } data := map[string]interface{}{ "task_id": task.ID, "from_workflow_id": fromWorkflowID, @@ -1141,6 +1153,14 @@ func (s *Service) publishTaskMoved(ctx context.Context, task *models.Task, fromW "task_description": task.Description, "parent_id": task.ParentID, "assignee_agent_profile_id": task.AssigneeAgentProfileID, + "wip_admitted": task.WIPAdmitted, + "queued_for_step_id": task.QueuedForStepID, + "queue_promotion": queuePromotion, + } + if task.QueuedAt != nil { + data["queued_at"] = task.QueuedAt.Format(time.RFC3339) + } else { + data["queued_at"] = nil } event := bus.NewEvent(events.TaskMoved, "orchestrator", data) if err := s.eventBus.Publish(ctx, events.TaskMoved, event); err != nil { @@ -1245,7 +1265,7 @@ func (s *Service) initWorkflowEngine() { if s.workflowStepGetter == nil { return } - store := newWorkflowStore(s.repo, s.workflowStepGetter, s.agentManager, s.publishTaskUpdated, s.logger, s.publishTaskMoved, s.publishTaskQueuePromoted, s.stepHistoryRecorder) + store := newWorkflowStore(s.repo, s.workflowStepGetter, s.agentManager, s.publishTaskUpdated, s.logger, s.publishTaskMoved, s.publishTaskQueuePromoted, s.publishTaskStateChanged, s.stepHistoryRecorder) callbacks := buildWorkflowCallbacks(s) s.workflowStore = store s.workflowEngine = engine.New(store, callbacks, s.engineOptions...) @@ -1658,6 +1678,7 @@ func (s *Service) Start(ctx context.Context) error { if s.workflowStore != nil { s.workflowStore.ReconcileQueuedTasks(ctx) } + s.reconcileTaskLifecycleTokens(ctx) // Chains whose predecessor completed while the process was down: the // dependencies_resolved event is in-memory and is not replayed, so without // this sweep a chain stalls silently across a restart. Runs after the WIP @@ -2522,6 +2543,44 @@ func (s *Service) GetMessageQueue() *messagequeue.Service { return s.messageQueue } +// QueueUserPrompt persists a prompt that must wait for workflow WIP admission. +// The user message row is already written by the WebSocket handler, so the +// queue marker prevents the drain path from creating a duplicate row. +func (s *Service) QueueUserPrompt( + ctx context.Context, + taskID, sessionID, prompt, model string, + planMode bool, + attachments []v1.MessageAttachment, + metadata map[string]interface{}, + userMessageRecorded bool, +) error { + if s.messageQueue == nil { + return errors.New("message queue is not configured") + } + queueMetadata := make(map[string]interface{}, len(metadata)+1) + for key, value := range metadata { + queueMetadata[key] = value + } + if userMessageRecorded { + queueMetadata[metaKeyUserMessageRecorded] = true + } + if _, err := s.messageQueue.QueueMessageWithMetadata( + ctx, + sessionID, + taskID, + prompt, + model, + messagequeue.QueuedByUser, + planMode, + toQueuedAttachments(attachments), + queueMetadata, + ); err != nil { + return fmt.Errorf("queue user prompt: %w", err) + } + s.publishQueueStatusEvent(ctx, sessionID) + return nil +} + // GetEventBus returns the event bus func (s *Service) GetEventBus() bus.EventBus { return s.eventBus diff --git a/apps/backend/internal/orchestrator/watcher/watcher.go b/apps/backend/internal/orchestrator/watcher/watcher.go index 58cfdf7d44..c893b81023 100644 --- a/apps/backend/internal/orchestrator/watcher/watcher.go +++ b/apps/backend/internal/orchestrator/watcher/watcher.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "sync" + "time" "go.uber.org/zap" @@ -18,10 +19,13 @@ import ( // TaskEventData contains data from task events type TaskEventData struct { - TaskID string `json:"task_id"` - Task *v1.Task `json:"task,omitempty"` - OldState *v1.TaskState `json:"old_state,omitempty"` - NewState *v1.TaskState `json:"new_state,omitempty"` + TaskID string `json:"task_id"` + Task *v1.Task `json:"task,omitempty"` + OldState *v1.TaskState `json:"old_state,omitempty"` + NewState *v1.TaskState `json:"new_state,omitempty"` + WIPAdmitted bool `json:"wip_admitted"` + QueuedForStepID string `json:"queued_for_step_id,omitempty"` + QueuedAt *time.Time `json:"queued_at,omitempty"` } // AgentEventData contains data from agent events @@ -65,12 +69,16 @@ type GitEventData = lifecycle.GitEventPayload // TaskMovedEventData contains data from task.moved events (manual step changes). type TaskMovedEventData struct { - TaskID string `json:"task_id"` - FromStepID string `json:"from_step_id"` - ToStepID string `json:"to_step_id"` - SessionID string `json:"session_id"` - WorkflowID string `json:"workflow_id"` - TaskDescription string `json:"task_description"` + TaskID string `json:"task_id"` + FromStepID string `json:"from_step_id"` + ToStepID string `json:"to_step_id"` + SessionID string `json:"session_id"` + WorkflowID string `json:"workflow_id"` + TaskDescription string `json:"task_description"` + WIPAdmitted bool `json:"wip_admitted"` + QueuedForStepID string `json:"queued_for_step_id,omitempty"` + QueuedAt *time.Time `json:"queued_at,omitempty"` + QueuePromotion bool `json:"queue_promotion,omitempty"` } // ContextWindowData contains data from context window events diff --git a/apps/backend/internal/orchestrator/workflow_store.go b/apps/backend/internal/orchestrator/workflow_store.go index 2190119277..d5932496ed 100644 --- a/apps/backend/internal/orchestrator/workflow_store.go +++ b/apps/backend/internal/orchestrator/workflow_store.go @@ -14,6 +14,7 @@ import ( "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/workflow/engine" wfmodels "github.com/kandev/kandev/internal/workflow/models" + v1 "github.com/kandev/kandev/pkg/api/v1" ) // taskUpdatedPublisher is the minimal hook the workflow store needs to emit @@ -23,6 +24,7 @@ type taskUpdatedPublisher func(ctx context.Context, task *models.Task, oldWorkfl type taskMovedPublisher func(ctx context.Context, task *models.Task, fromWorkflowID, fromStepID, toStepID, sessionID string) type taskQueuePromotedPublisher func(ctx context.Context, task *models.Task) +type taskStateChangedPublisher func(ctx context.Context, task *models.Task, oldState v1.TaskState) type workflowMoveLimitsRepository interface { CountTasksByWorkflowStepExcludingTask(ctx context.Context, stepID, excludeTaskID string) (int, error) @@ -36,6 +38,10 @@ type workflowLimitedMoveRepository interface { UpdateTaskIfWorkflowStepHasCapacity(ctx context.Context, task *models.Task, targetStepID, excludeTaskID string, limit int) error } +type workflowMoveAdmissionRepository interface { + UpdateTaskWithWorkflowStepAdmission(ctx context.Context, task *models.Task, targetStepID string, limit int) (bool, error) +} + type workflowQueuedTaskPromoter interface { PromoteQueuedTaskIfWorkflowStepHasCapacity(ctx context.Context, task *models.Task, fromStepID, destinationStepID string, limit int) (bool, error) } @@ -56,6 +62,17 @@ type queuedTaskLister interface { ListQueuedTasks(ctx context.Context) ([]*models.Task, error) } +func queuedMoveExitPending(task *models.Task) bool { + if task == nil || task.Metadata == nil { + return false + } + if _, pending := task.Metadata[models.MetaKeyQueuedMoveExitPending]; !pending { + return false + } + _, completed := task.Metadata[models.MetaKeyQueuedMoveExitCompleted] + return !completed +} + // workflowStore implements engine.TransitionStore by delegating to the // orchestrator's existing repositories and services. type workflowStore struct { @@ -65,6 +82,7 @@ type workflowStore struct { publishTaskUpdated taskUpdatedPublisher publishTaskMoved taskMovedPublisher publishTaskPromoted taskQueuePromotedPublisher + publishStateChanged taskStateChangedPublisher logger *logger.Logger stepHistoryRecorder StepHistoryRecorder appliedOps sync.Map @@ -80,6 +98,7 @@ func newWorkflowStore( ) *workflowStore { var moved taskMovedPublisher var promoted taskQueuePromotedPublisher + var stateChanged taskStateChangedPublisher var history StepHistoryRecorder for _, publisher := range publishers { switch value := publisher.(type) { @@ -95,6 +114,10 @@ func newWorkflowStore( history = value case func(context.Context, *models.Task): promoted = taskQueuePromotedPublisher(value) + case taskStateChangedPublisher: + stateChanged = value + case func(context.Context, *models.Task, v1.TaskState): + stateChanged = taskStateChangedPublisher(value) } } return &workflowStore{ @@ -104,6 +127,7 @@ func newWorkflowStore( publishTaskUpdated: publishTaskUpdated, publishTaskMoved: moved, publishTaskPromoted: promoted, + publishStateChanged: stateChanged, logger: log, stepHistoryRecorder: history, } @@ -180,6 +204,11 @@ func (s *workflowStore) ApplyTransition(ctx context.Context, taskID, sessionID, task.WIPAdmitted = true task.QueuedForStepID = "" task.QueuedAt = nil + if task.Metadata != nil { + delete(task.Metadata, models.MetaKeyQueuedMoveExitPending) + delete(task.Metadata, models.MetaKeyQueuedMoveExitCompleted) + delete(task.Metadata, models.MetaKeyQueuePromotionPending) + } task.UpdatedAt = time.Now().UTC() if err := s.updateTransitionTask(ctx, task, targetStep); err != nil { return fmt.Errorf("update task workflow step: %w", err) @@ -191,10 +220,12 @@ func (s *workflowStore) ApplyTransition(ctx context.Context, taskID, sessionID, // instead of leaving a stale duplicate until reload. s.publishTaskUpdated(ctx, task, oldWorkflowID) - if err := s.repo.UpdateSessionReviewStatus(ctx, sessionID, ""); err != nil { - s.logger.Warn("failed to clear session review status", - zap.String("session_id", sessionID), - zap.Error(err)) + if task.QueuedForStepID == "" { + if err := s.repo.UpdateSessionReviewStatus(ctx, sessionID, ""); err != nil { + s.logger.Warn("failed to clear session review status", + zap.String("session_id", sessionID), + zap.Error(err)) + } } s.logger.Info("workflow transition applied", @@ -209,14 +240,15 @@ func (s *workflowStore) ApplyTransition(ctx context.Context, taskID, sessionID, } func (s *workflowStore) updateTransitionTask(ctx context.Context, task *models.Task, targetStep *wfmodels.WorkflowStep) error { - if targetStep == nil || targetStep.WIPLimit <= 0 { + if targetStep == nil { return s.repo.UpdateTask(ctx, task) } - limitedRepo, ok := s.repo.(workflowLimitedMoveRepository) + admissionRepo, ok := s.repo.(workflowMoveAdmissionRepository) if !ok { - return fmt.Errorf("WIP limit cannot be checked for workflow step %s", targetStep.ID) + return fmt.Errorf("workflow step admission repository unavailable for step %s", targetStep.ID) } - return limitedRepo.UpdateTaskIfWorkflowStepHasCapacity(ctx, task, targetStep.ID, task.ID, targetStep.WIPLimit) + _, err := admissionRepo.UpdateTaskWithWorkflowStepAdmission(ctx, task, targetStep.ID, targetStep.WIPLimit) + return err } func (s *workflowStore) pullNextTaskOnVacate(ctx context.Context, vacatedStepID, excludeTaskID string) { @@ -356,6 +388,10 @@ func (s *workflowStore) pullOneFeederTask( if candidate == nil { return false } + if queuedMoveExitPending(candidate) { + skipped[candidate.ID] = struct{}{} + continue + } if s.feederCandidateBlocked(ctx, candidate.ID) { skipped[candidate.ID] = struct{}{} continue @@ -364,7 +400,18 @@ func (s *workflowStore) pullOneFeederTask( fromStepID := candidate.WorkflowStepID candidate.WorkflowID = vacatedStep.WorkflowID candidate.WorkflowStepID = vacatedStep.ID + if candidate.Metadata == nil { + candidate.Metadata = make(map[string]interface{}) + } + candidate.Metadata[models.MetaKeyQueuePromotionPending] = true candidate.Position = position + oldState, stateChanged, err := s.syncQueuedPromotionState(ctx, candidate, vacatedStep) + if err != nil { + s.logger.Warn("skipping feeder task: failed to prepare promotion state", + zap.String("task_id", candidate.ID), zap.Error(err)) + skipped[candidate.ID] = struct{}{} + continue + } candidate.UpdatedAt = time.Now().UTC() if promoter, ok := s.repo.(workflowQueuedTaskPromoter); ok { claimed, err := promoter.PromoteQueuedTaskIfWorkflowStepHasCapacity(ctx, candidate, fromStepID, vacatedStep.ID, vacatedStep.WIPLimit) @@ -376,6 +423,17 @@ func (s *workflowStore) pullOneFeederTask( skipped[candidate.ID] = struct{}{} continue } + } else if admissionRepo, ok := s.repo.(workflowMoveAdmissionRepository); ok { + claimed, err := admissionRepo.UpdateTaskWithWorkflowStepAdmission(ctx, candidate, vacatedStep.ID, vacatedStep.WIPLimit) + if err != nil { + s.logger.Warn("failed to promote feeder task", zap.String("task_id", candidate.ID), zap.Error(err)) + skipped[candidate.ID] = struct{}{} + continue + } + if !claimed { + skipped[candidate.ID] = struct{}{} + continue + } } else if err := limitedRepo.UpdateTaskIfWorkflowStepHasCapacity(ctx, candidate, vacatedStep.ID, candidate.ID, vacatedStep.WIPLimit); err != nil { skipped[candidate.ID] = struct{}{} s.logger.Warn("skipping feeder task that could not be pulled", @@ -383,6 +441,9 @@ func (s *workflowStore) pullOneFeederTask( continue } s.publishTaskUpdated(ctx, candidate) + if stateChanged && s.publishStateChanged != nil { + s.publishStateChanged(ctx, candidate, oldState) + } sessionID := "" if session, err := s.repo.GetActiveTaskSessionByTaskID(ctx, candidate.ID); err == nil && session != nil { sessionID = session.ID @@ -401,30 +462,79 @@ func (s *workflowStore) pullOneFeederTask( func (s *workflowStore) promoteSameStepTask(ctx context.Context, candidate *models.Task, step *wfmodels.WorkflowStep, position int, skipped map[string]struct{}, pullRepo workflowPullRepository, limitedRepo workflowLimitedMoveRepository) bool { fromStepID := candidate.WorkflowStepID + if candidate.Metadata == nil { + candidate.Metadata = make(map[string]interface{}) + } candidate.WIPAdmitted = true candidate.QueuedForStepID = "" candidate.QueuedAt = nil + candidate.Metadata[models.MetaKeyQueuePromotionPending] = true candidate.Position = position candidate.UpdatedAt = time.Now().UTC() + oldState, stateChanged, err := s.syncQueuedPromotionState(ctx, candidate, step) + if err != nil { + s.logger.Warn("skipping queued task: failed to prepare promotion state", + zap.String("task_id", candidate.ID), zap.Error(err)) + skipped[candidate.ID] = struct{}{} + return s.pullOneFeederTask(ctx, pullRepo, limitedRepo, step, position, skipped) + } if promoter, ok := s.repo.(workflowQueuedTaskPromoter); ok { claimed, err := promoter.PromoteQueuedTaskIfWorkflowStepHasCapacity(ctx, candidate, fromStepID, step.ID, step.WIPLimit) if err != nil { + s.logger.Warn("failed to promote same-step queued task", zap.String("task_id", candidate.ID), zap.Error(err)) return false } if !claimed { skipped[candidate.ID] = struct{}{} return s.pullOneFeederTask(ctx, pullRepo, limitedRepo, step, position, skipped) } + } else if admissionRepo, ok := s.repo.(workflowMoveAdmissionRepository); ok { + claimed, err := admissionRepo.UpdateTaskWithWorkflowStepAdmission(ctx, candidate, step.ID, step.WIPLimit) + if err != nil { + s.logger.Warn("failed to promote same-step queued task", zap.String("task_id", candidate.ID), zap.Error(err)) + skipped[candidate.ID] = struct{}{} + return s.pullOneFeederTask(ctx, pullRepo, limitedRepo, step, position, skipped) + } + if !claimed { + skipped[candidate.ID] = struct{}{} + return s.pullOneFeederTask(ctx, pullRepo, limitedRepo, step, position, skipped) + } } else if err := limitedRepo.UpdateTaskIfWorkflowStepHasCapacity(ctx, candidate, step.ID, candidate.ID, step.WIPLimit); err != nil { - return false + s.logger.Warn("failed to promote same-step queued task", zap.String("task_id", candidate.ID), zap.Error(err)) + skipped[candidate.ID] = struct{}{} + return s.pullOneFeederTask(ctx, pullRepo, limitedRepo, step, position, skipped) } s.publishTaskUpdated(ctx, candidate) + if stateChanged && s.publishStateChanged != nil { + s.publishStateChanged(ctx, candidate, oldState) + } if s.publishTaskPromoted != nil { s.publishTaskPromoted(ctx, candidate) } return true } +func (s *workflowStore) syncQueuedPromotionState(ctx context.Context, task *models.Task, targetStep *wfmodels.WorkflowStep) (v1.TaskState, bool, error) { + if targetStep == nil || s.workflowStepGetter == nil { + return task.State, false, nil + } + next, err := s.workflowStepGetter.GetNextStepByPosition(ctx, targetStep.WorkflowID, targetStep.Position) + if err != nil { + return task.State, false, fmt.Errorf("load next step after %s: %w", targetStep.ID, err) + } + oldState := task.State + if wfmodels.IsTerminalStep(targetStep, next) { + if !models.IsTerminalTaskState(task.State) { + task.State = v1.TaskStateCompleted + } + return oldState, oldState != task.State, nil + } + if task.State == v1.TaskStateCompleted { + task.State = v1.TaskStateTODO + } + return oldState, oldState != task.State, nil +} + func (s *workflowStore) nextQueuedSameStepTask(ctx context.Context, stepID string, skipped map[string]struct{}) *models.Task { lister, ok := s.repo.(workflowStepTaskLister) if !ok { @@ -439,6 +549,9 @@ func (s *workflowStore) nextQueuedSameStepTask(ctx context.Context, stepID strin if candidate == nil || candidate.WIPAdmitted || candidate.QueuedForStepID != stepID { continue } + if queuedMoveExitPending(candidate) { + continue + } if _, seen := skipped[candidate.ID]; seen { continue } diff --git a/apps/backend/internal/orchestrator/workflow_store_test.go b/apps/backend/internal/orchestrator/workflow_store_test.go index e48e525b47..ca7a1d0326 100644 --- a/apps/backend/internal/orchestrator/workflow_store_test.go +++ b/apps/backend/internal/orchestrator/workflow_store_test.go @@ -2,7 +2,6 @@ package orchestrator import ( "context" - "strings" "testing" "github.com/kandev/kandev/internal/task/models" @@ -239,7 +238,7 @@ func TestWorkflowStore_ApplyTransitionPublishesOldWorkflowIDOnCrossWorkflowMove( } } -func TestWorkflowStore_ApplyTransitionRejectsFullWIPLimitedTarget(t *testing.T) { +func TestWorkflowStore_ApplyTransitionQueuesFullWIPLimitedTarget(t *testing.T) { ctx := context.Background() repo := setupTestRepo(t) seedSession(t, repo, "t1", "s1", "step1") @@ -262,19 +261,16 @@ func TestWorkflowStore_ApplyTransitionRejectsFullWIPLimitedTarget(t *testing.T) store := newWorkflowStore(repo, stepGetter, nil, noopPublisher, testLogger()) err := store.ApplyTransition(ctx, "t1", "s1", "step1", "step2", "on_turn_complete") - if err == nil { - t.Fatalf("expected WIP-limited transition to be rejected") - } - if !strings.Contains(err.Error(), "WIP limit") { - t.Fatalf("error = %q, want WIP limit rejection", err.Error()) + if err != nil { + t.Fatalf("ApplyTransition: %v", err) } task, err := repo.GetTask(ctx, "t1") if err != nil { t.Fatalf("failed to get task: %v", err) } - if task.WorkflowStepID != "step1" { - t.Fatalf("task moved despite WIP limit: %s", task.WorkflowStepID) + if task.WorkflowStepID != "step2" || task.WIPAdmitted || task.QueuedForStepID != "step2" { + t.Fatalf("queued task placement: step=%q admitted=%t queue=%q", task.WorkflowStepID, task.WIPAdmitted, task.QueuedForStepID) } } diff --git a/apps/backend/internal/task/handlers/message_handlers.go b/apps/backend/internal/task/handlers/message_handlers.go index 956dc15ec4..2109076651 100644 --- a/apps/backend/internal/task/handlers/message_handlers.go +++ b/apps/backend/internal/task/handlers/message_handlers.go @@ -31,7 +31,8 @@ type OrchestratorService interface { PromptTask(ctx context.Context, taskID, sessionID, prompt, model string, planMode bool, attachments []v1.MessageAttachment, dispatchOnly bool) (*orchestrator.PromptResult, error) ResumeTaskSession(ctx context.Context, taskID, taskSessionID string) error StartCreatedSession(ctx context.Context, taskID, sessionID, agentProfileID, prompt string, skipMessageRecord, planMode, autoStart bool, attachments []v1.MessageAttachment, references []v1.EntityReference) error - ProcessOnTurnStart(ctx context.Context, taskID, sessionID string) error + ProcessOnTurnStart(ctx context.Context, taskID, sessionID string) (orchestrator.ProcessOnTurnStartResult, error) + QueueUserPrompt(ctx context.Context, taskID, sessionID, prompt, model string, planMode bool, attachments []v1.MessageAttachment, metadata map[string]interface{}, userMessageRecorded bool) error StepRequiresCompletionSignal(ctx context.Context, taskID string) bool // ForegroundActivity is already filtered by the orchestrator's runtime flag // and persisted provider identity. Background therefore means this exact @@ -362,6 +363,8 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to get task", nil) } + var turnStartResult orchestrator.ProcessOnTurnStartResult + // Run on_turn_start synchronously BEFORE wrapping the prompt with the // Kandev MCP system block. A workflow step transition fired by // on_turn_start changes which step's `auto_advance_requires_signal` @@ -370,11 +373,13 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w // first turn. dispatchPromptAsync no longer calls ProcessOnTurnStart; // it forwards the (now correctly-wrapped) prompt to the agent. if h.orchestrator != nil { - if err := h.orchestrator.ProcessOnTurnStart(ctx, req.TaskID, req.TaskSessionID); err != nil { + var turnStartErr error + turnStartResult, turnStartErr = h.orchestrator.ProcessOnTurnStart(ctx, req.TaskID, req.TaskSessionID) + if turnStartErr != nil { h.logger.Warn("failed to process on_turn_start", zap.String("task_id", req.TaskID), zap.String("session_id", req.TaskSessionID), - zap.Error(err)) + zap.Error(turnStartErr)) } var err error sessionResp, err = h.resolveSessionAfterTurnStart(ctx, req.TaskID, req.TaskSessionID, sessionResp) @@ -476,6 +481,25 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w h.logger.Error("failed to create message", zap.Error(err)) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to create message", nil) } + if turnStartResult.Queued { + if err := h.orchestrator.QueueUserPrompt( + ctx, + req.TaskID, + req.TaskSessionID, + req.Content, + req.Model, + req.PlanMode, + req.Attachments, + meta.ToMap(), + true, + ); err != nil { + h.logger.Warn("failed to queue prompt until workflow promotion", + zap.String("task_id", req.TaskID), + zap.String("session_id", req.TaskSessionID), + zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to queue prompt", nil) + } + } apiMsg := message.ToAPI() response, err := ws.NewResponse(msg.ID, msg.Action, apiMsg) @@ -486,7 +510,7 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w // Auto-forward message as prompt to running agent if orchestrator is available. // This runs async so the WS request can respond immediately. // Use context.WithoutCancel so the prompt continues even if the WebSocket client disconnects. - if h.orchestrator != nil { + if h.orchestrator != nil && !turnStartResult.Queued { h.dispatchPromptAsync(ctx, req, sessionResp.Session.AgentProfileID, startCreatedSession, steer) } diff --git a/apps/backend/internal/task/handlers/message_handlers_blocked_test.go b/apps/backend/internal/task/handlers/message_handlers_blocked_test.go index f32aff4a60..6ccaddecf7 100644 --- a/apps/backend/internal/task/handlers/message_handlers_blocked_test.go +++ b/apps/backend/internal/task/handlers/message_handlers_blocked_test.go @@ -101,7 +101,14 @@ type capturedFirstTurn struct { } type firstTurnCaptureOrchestrator struct { - started chan capturedFirstTurn + started chan capturedFirstTurn + turnStartResult orchestrator.ProcessOnTurnStartResult + queuedPromptCall *queuedPromptCall +} + +type queuedPromptCall struct { + taskID, sessionID, prompt string + userMessageRecorded bool } func (o *firstTurnCaptureOrchestrator) PromptTask( @@ -128,7 +135,12 @@ func (o *firstTurnCaptureOrchestrator) StartCreatedSession( return nil } -func (o *firstTurnCaptureOrchestrator) ProcessOnTurnStart(context.Context, string, string) error { +func (o *firstTurnCaptureOrchestrator) ProcessOnTurnStart(context.Context, string, string) (orchestrator.ProcessOnTurnStartResult, error) { + return o.turnStartResult, nil +} + +func (o *firstTurnCaptureOrchestrator) QueueUserPrompt(_ context.Context, taskID, sessionID, prompt, _ string, _ bool, _ []v1.MessageAttachment, _ map[string]interface{}, userMessageRecorded bool) error { + o.queuedPromptCall = &queuedPromptCall{taskID: taskID, sessionID: sessionID, prompt: prompt, userMessageRecorded: userMessageRecorded} return nil } @@ -543,11 +555,15 @@ func (o *switchingTurnStartOrchestrator) StartCreatedSession( return nil } -func (o *switchingTurnStartOrchestrator) ProcessOnTurnStart(context.Context, string, string) error { +func (o *switchingTurnStartOrchestrator) ProcessOnTurnStart(context.Context, string, string) (orchestrator.ProcessOnTurnStartResult, error) { o.repo.sessions["s1"].State = models.TaskSessionStateCompleted if o.switchPrimary { o.repo.primaryID = "s2" } + return orchestrator.ProcessOnTurnStartResult{}, nil +} + +func (*switchingTurnStartOrchestrator) QueueUserPrompt(context.Context, string, string, string, string, bool, []v1.MessageAttachment, map[string]interface{}, bool) error { return nil } @@ -628,6 +644,48 @@ func TestWSAddMessageUsesSessionSelectedByOnTurnStart(t *testing.T) { assert.Empty(t, orch.getForwardedSession()) } +func TestWSAddMessage_QueuesPromptWhenOnTurnStartQueuesTask(t *testing.T) { + now := time.Now().UTC() + repo := &messageAddSwitchRepo{ + tasks: map[string]*models.Task{ + "t1": {ID: "t1", State: v1.TaskStateInProgress, UpdatedAt: now}, + }, + sessions: map[string]*models.TaskSession{ + "s1": {ID: "s1", TaskID: "t1", State: models.TaskSessionStateWaitingForInput, UpdatedAt: now}, + }, + primaryID: "s1", + } + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + require.NoError(t, err) + svc := service.NewService(service.Repos{ + Workspaces: repo, Tasks: repo, TaskRepos: repo, + Workflows: repo, Messages: repo, Turns: repo, + Sessions: repo, GitSnapshots: repo, RepoEntities: repo, + Executors: repo, Environments: repo, TaskEnvironments: repo, + Reviews: repo, + }, nil, log, service.RepositoryDiscoveryConfig{}) + orch := &firstTurnCaptureOrchestrator{ + turnStartResult: orchestrator.ProcessOnTurnStartResult{Queued: true}, + } + h := NewMessageHandlers(svc, orch, log) + + req, err := ws.NewRequest("req-queued", ws.ActionMessageAdd, map[string]interface{}{ + "task_id": "t1", + "session_id": "s1", + "content": "wait for admission", + }) + require.NoError(t, err) + resp, err := h.wsAddMessage(context.Background(), req) + require.NoError(t, err) + require.Equal(t, ws.MessageTypeResponse, resp.Type) + require.NotNil(t, orch.queuedPromptCall) + assert.Equal(t, "t1", orch.queuedPromptCall.taskID) + assert.Equal(t, "s1", orch.queuedPromptCall.sessionID) + assert.Equal(t, "wait for admission", orch.queuedPromptCall.prompt) + assert.True(t, orch.queuedPromptCall.userMessageRecorded) + assert.Len(t, repo.messages, 1, "the initiating user message is persisted once") +} + func TestWSAddMessageRetryAcceptsMessagePersistedAfterSessionSwitch(t *testing.T) { now := time.Now().UTC() repo := &messageAddSwitchRepo{ @@ -765,7 +823,12 @@ func (o fgActivityOrchestrator) ResumeTaskSession(context.Context, string, strin func (o fgActivityOrchestrator) StartCreatedSession(context.Context, string, string, string, string, bool, bool, bool, []v1.MessageAttachment, []v1.EntityReference) error { return nil } -func (o fgActivityOrchestrator) ProcessOnTurnStart(context.Context, string, string) error { return nil } +func (o fgActivityOrchestrator) ProcessOnTurnStart(context.Context, string, string) (orchestrator.ProcessOnTurnStartResult, error) { + return orchestrator.ProcessOnTurnStartResult{}, nil +} +func (o fgActivityOrchestrator) QueueUserPrompt(context.Context, string, string, string, string, bool, []v1.MessageAttachment, map[string]interface{}, bool) error { + return nil +} func (o fgActivityOrchestrator) StepRequiresCompletionSignal(context.Context, string) bool { return false } @@ -794,7 +857,10 @@ func (*recordingAdmissionOrchestrator) ResumeTaskSession(context.Context, string func (*recordingAdmissionOrchestrator) StartCreatedSession(context.Context, string, string, string, string, bool, bool, bool, []v1.MessageAttachment, []v1.EntityReference) error { return nil } -func (*recordingAdmissionOrchestrator) ProcessOnTurnStart(context.Context, string, string) error { +func (*recordingAdmissionOrchestrator) ProcessOnTurnStart(context.Context, string, string) (orchestrator.ProcessOnTurnStartResult, error) { + return orchestrator.ProcessOnTurnStartResult{}, nil +} +func (*recordingAdmissionOrchestrator) QueueUserPrompt(context.Context, string, string, string, string, bool, []v1.MessageAttachment, map[string]interface{}, bool) error { return nil } func (*recordingAdmissionOrchestrator) StepRequiresCompletionSignal(context.Context, string) bool { @@ -837,7 +903,10 @@ func (*steerRecordingOrchestrator) ResumeTaskSession(context.Context, string, st func (*steerRecordingOrchestrator) StartCreatedSession(context.Context, string, string, string, string, bool, bool, bool, []v1.MessageAttachment, []v1.EntityReference) error { return nil } -func (*steerRecordingOrchestrator) ProcessOnTurnStart(context.Context, string, string) error { +func (*steerRecordingOrchestrator) ProcessOnTurnStart(context.Context, string, string) (orchestrator.ProcessOnTurnStartResult, error) { + return orchestrator.ProcessOnTurnStartResult{}, nil +} +func (*steerRecordingOrchestrator) QueueUserPrompt(context.Context, string, string, string, string, bool, []v1.MessageAttachment, map[string]interface{}, bool) error { return nil } func (*steerRecordingOrchestrator) StepRequiresCompletionSignal(context.Context, string) bool { diff --git a/apps/backend/internal/task/handlers/message_handlers_resume_readiness_test.go b/apps/backend/internal/task/handlers/message_handlers_resume_readiness_test.go index d8008def15..11b9924cf6 100644 --- a/apps/backend/internal/task/handlers/message_handlers_resume_readiness_test.go +++ b/apps/backend/internal/task/handlers/message_handlers_resume_readiness_test.go @@ -71,7 +71,11 @@ func (o *resumeRetryOrchestrator) StartCreatedSession( return nil } -func (o *resumeRetryOrchestrator) ProcessOnTurnStart(context.Context, string, string) error { +func (o *resumeRetryOrchestrator) ProcessOnTurnStart(context.Context, string, string) (orchestrator.ProcessOnTurnStartResult, error) { + return orchestrator.ProcessOnTurnStartResult{}, nil +} + +func (*resumeRetryOrchestrator) QueueUserPrompt(context.Context, string, string, string, string, bool, []v1.MessageAttachment, map[string]interface{}, bool) error { return nil } diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index 7f471cca68..0eba61e867 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -95,6 +95,18 @@ const ( MetaKeyExecutorID = "executor_id" MetaKeyExecutorProfileID = "executor_profile_id" MetaKeyDeferredLaunch = "deferred_launch" + // MetaKeyQueuedMoveExitPending identifies a queued manual move whose + // source-step on_exit side effect is not yet complete. Its value records the + // source step so recovery can resume the work after a restart. + MetaKeyQueuedMoveExitPending = "queued_move_exit_pending" + // MetaKeyQueuedMoveExitCompleted is durable evidence that the source-step + // on_exit side effect for a queued manual move finished. Promotion must not + // enter the destination until this marker exists. + MetaKeyQueuedMoveExitCompleted = "queued_move_exit_completed" + // MetaKeyQueuePromotionPending is a one-shot token for destination entry + // after queue promotion. It prevents duplicate task.queue_promoted events + // from repeating on_enter or auto-start behavior. + MetaKeyQueuePromotionPending = "queue_promotion_pending" // DeferredLaunchStartWhenUnblockedKey marks a deferred launch intent as // belonging to a task dependency chain rather than to WIP overflow. The // record itself is identical; the flag lets dependency resolution recognise diff --git a/apps/backend/internal/task/repository/sqlite/task.go b/apps/backend/internal/task/repository/sqlite/task.go index f752908863..762abf0b08 100644 --- a/apps/backend/internal/task/repository/sqlite/task.go +++ b/apps/backend/internal/task/repository/sqlite/task.go @@ -336,7 +336,7 @@ func (r *Repository) insertTaskTx(ctx context.Context, tx *sql.Tx, task *models. return err } if task.AssigneeAgentProfileID != "" && task.WorkflowStepID != "" { - return upsertRunnerInTx(ctx, tx, task.WorkflowStepID, task.ID, task.AssigneeAgentProfileID) + return upsertRunnerInTx(ctx, tx, r.db.Rebind, task.WorkflowStepID, task.ID, task.AssigneeAgentProfileID) } return nil } @@ -403,53 +403,53 @@ func lockWorkflowStepForCapacity(ctx context.Context, tx *sql.Tx, driver string, // upsertRunnerInTx writes (or replaces) a 'runner' participant row for // (stepID, taskID) inside the provided transaction. Mirrors // workflow.Repository.SetTaskRunner but reuses the caller's tx. -func upsertRunnerInTx(ctx context.Context, tx *sql.Tx, stepID, taskID, agentProfileID string) error { +func upsertRunnerInTx(ctx context.Context, tx *sql.Tx, rebind func(string) string, stepID, taskID, agentProfileID string) error { if stepID == "" || taskID == "" || agentProfileID == "" { return nil } var existing string - err := tx.QueryRowContext(ctx, `SELECT id FROM workflow_step_participants + err := tx.QueryRowContext(ctx, rebind(`SELECT id FROM workflow_step_participants WHERE step_id = ? AND task_id = ? AND role = 'runner' LIMIT 1`, - stepID, taskID).Scan(&existing) + ), stepID, taskID).Scan(&existing) if err == nil { - _, uerr := tx.ExecContext(ctx, + _, uerr := tx.ExecContext(ctx, rebind( `UPDATE workflow_step_participants SET agent_profile_id = ? WHERE id = ?`, - agentProfileID, existing) + ), agentProfileID, existing) return uerr } if err != sql.ErrNoRows { return err } id := uuid.New().String() - _, ierr := tx.ExecContext(ctx, `INSERT INTO workflow_step_participants + _, ierr := tx.ExecContext(ctx, rebind(`INSERT INTO workflow_step_participants (id, step_id, task_id, role, agent_profile_id, decision_required, position) VALUES (?, ?, ?, 'runner', ?, 0, 0)`, - id, stepID, taskID, agentProfileID) + ), id, stepID, taskID, agentProfileID) return ierr } // clearRunnerInTx removes any 'runner' participant row for (stepID, taskID). -func clearRunnerInTx(ctx context.Context, tx *sql.Tx, stepID, taskID string) error { +func clearRunnerInTx(ctx context.Context, tx *sql.Tx, rebind func(string) string, stepID, taskID string) error { if stepID == "" || taskID == "" { return nil } - _, err := tx.ExecContext(ctx, + _, err := tx.ExecContext(ctx, rebind( `DELETE FROM workflow_step_participants WHERE step_id = ? AND task_id = ? AND role = 'runner'`, - stepID, taskID) + ), stepID, taskID) return err } // syncRunnerInTx upserts the runner participant when agentProfileID is set, // otherwise clears it. No-op when stepID is empty. -func syncRunnerInTx(ctx context.Context, tx *sql.Tx, stepID, taskID, agentProfileID string) error { +func syncRunnerInTx(ctx context.Context, tx *sql.Tx, rebind func(string) string, stepID, taskID, agentProfileID string) error { if stepID == "" { return nil } if agentProfileID != "" { - return upsertRunnerInTx(ctx, tx, stepID, taskID, agentProfileID) + return upsertRunnerInTx(ctx, tx, rebind, stepID, taskID, agentProfileID) } - return clearRunnerInTx(ctx, tx, stepID, taskID) + return clearRunnerInTx(ctx, tx, rebind, stepID, taskID) } // GetTask retrieves a task by ID @@ -480,6 +480,14 @@ func (r *Repository) UpdateTask(ctx context.Context, task *models.Task) error { } defer func() { _ = tx.Rollback() }() + if err := r.updateTaskTx(ctx, tx, task, metadata); err != nil { + return err + } + + return tx.Commit() +} + +func (r *Repository) updateTaskTx(ctx context.Context, tx *sql.Tx, task *models.Task, metadata []byte) error { updateQuery := ` UPDATE tasks SET workspace_id = ?, workflow_id = ?, workflow_step_id = ?, title = ?, description = ?, state = ?, priority = ?, position = ?, wip_admitted = ?, queued_for_step_id = ?, queued_at = ?, metadata = ?, parent_id = ?, updated_at = ?, origin = ?, project_id = ?, labels = ?, identifier = ? WHERE id = ? @@ -501,17 +509,101 @@ func (r *Repository) UpdateTask(ctx context.Context, task *models.Task) error { if err != nil { return err } - rows, _ := result.RowsAffected() if rows == 0 { return fmt.Errorf("%w: %s", ErrTaskNotFound, task.ID) } + return syncRunnerInTx(ctx, tx, r.db.Rebind, task.WorkflowStepID, task.ID, task.AssigneeAgentProfileID) +} - if err := syncRunnerInTx(ctx, tx, task.WorkflowStepID, task.ID, task.AssigneeAgentProfileID); err != nil { - return err +// UpdateTaskWithWorkflowStepAdmission atomically moves a task into a workflow +// step. A limited full target stores the task in that destination as queued; +// it never rejects the move for WIP capacity. +func (r *Repository) UpdateTaskWithWorkflowStepAdmission( + ctx context.Context, + task *models.Task, + targetStepID string, + limit int, +) (bool, error) { + return r.updateTaskWithWorkflowStepAdmission(ctx, task, targetStepID, limit, nil, false) +} + +// UpdateTaskWithWorkflowStepAdmissionAndState is the manual-move variant of +// UpdateTaskWithWorkflowStepAdmission. It keeps the destination admission, +// the state that applies after admission, and the queued source-exit marker +// in one transaction so a later full-row update cannot strand the move. +func (r *Repository) UpdateTaskWithWorkflowStepAdmissionAndState( + ctx context.Context, + task *models.Task, + targetStepID string, + limit int, + admittedState *v1.TaskState, + queueExitPending bool, +) (bool, error) { + return r.updateTaskWithWorkflowStepAdmission(ctx, task, targetStepID, limit, admittedState, queueExitPending) +} + +func (r *Repository) updateTaskWithWorkflowStepAdmission( + ctx context.Context, + task *models.Task, + targetStepID string, + limit int, + admittedState *v1.TaskState, + queueExitPending bool, +) (bool, error) { + now := time.Now().UTC() + task.UpdatedAt = now + if task.Metadata == nil { + task.Metadata = map[string]interface{}{} } - return tx.Commit() + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return false, err + } + defer func() { _ = tx.Rollback() }() + + if err := lockWorkflowStepForCapacity(ctx, tx, r.db.DriverName(), r.db.Rebind, targetStepID); err != nil { + return false, err + } + occupants, err := r.countAdmittedInTx(ctx, tx, targetStepID, task.ID) + if err != nil { + return false, err + } + admitted := task.IsEphemeral || limit <= 0 || occupants < limit + task.WorkflowStepID = targetStepID + if admitted { + task.WIPAdmitted = !task.IsEphemeral + task.QueuedForStepID = "" + task.QueuedAt = nil + if admittedState != nil { + task.State = *admittedState + } + } else { + task.WIPAdmitted = false + task.QueuedForStepID = targetStepID + task.QueuedAt = &now + } + if queueExitPending { + if admitted { + delete(task.Metadata, models.MetaKeyQueuedMoveExitPending) + } else { + if _, exists := task.Metadata[models.MetaKeyQueuedMoveExitPending]; !exists { + task.Metadata[models.MetaKeyQueuedMoveExitPending] = true + } + } + } + metadata, err := json.Marshal(task.Metadata) + if err != nil { + metadata = []byte("{}") + } + if err := r.updateTaskTx(ctx, tx, task, metadata); err != nil { + return false, err + } + if err := tx.Commit(); err != nil { + return false, err + } + return admitted, nil } // RemoveTaskMetadataKey removes one metadata key without replacing concurrent @@ -826,7 +918,7 @@ func (r *Repository) UpdateTaskIfWorkflowStepHasCapacity(ctx context.Context, ta if rows == 0 { return fmt.Errorf("%w: %s", ErrTaskNotFound, task.ID) } - if err := syncRunnerInTx(ctx, tx, task.WorkflowStepID, task.ID, task.AssigneeAgentProfileID); err != nil { + if err := syncRunnerInTx(ctx, tx, r.db.Rebind, task.WorkflowStepID, task.ID, task.AssigneeAgentProfileID); err != nil { return err } return tx.Commit() @@ -873,15 +965,30 @@ func (r *Repository) PromoteQueuedTaskIfWorkflowStepHasCapacity( return false, nil } } + queuePredicate := ` + AND workflow_step_id = ? + AND (queued_for_step_id = ? OR queued_for_step_id = '' OR queued_for_step_id IS NULL)` + predicateArgs := []interface{}{fromStepID, destinationStepID} + if fromStepID == destinationStepID { + // A same-step promotion is a claim on a visible queue row. Once the + // first reconciler admits it, the row has no queue marker and must not + // satisfy a second promotion attempt. The empty-marker form remains + // reserved for legacy feeder rows below. + queuePredicate = ` + AND workflow_step_id = ? + AND wip_admitted = 0 + AND queued_for_step_id = ?` + } result, err := tx.ExecContext(ctx, r.db.Rebind(` UPDATE tasks SET workspace_id = ?, workflow_id = ?, workflow_step_id = ?, title = ?, description = ?, state = ?, priority = ?, position = ?, wip_admitted = ?, queued_for_step_id = ?, queued_at = ?, metadata = ?, parent_id = ?, updated_at = ?, origin = ?, project_id = ?, labels = ?, identifier = ? WHERE id = ? - AND workflow_step_id = ? - AND (queued_for_step_id = ? OR queued_for_step_id = '' OR queued_for_step_id IS NULL) + `+queuePredicate+` AND archived_at IS NULL AND is_ephemeral = 0`+andNotAutomationOrigin+` - `), task.WorkspaceID, task.WorkflowID, task.WorkflowStepID, task.Title, task.Description, task.State, task.Priority, task.Position, dialect.BoolToInt(task.WIPAdmitted), task.QueuedForStepID, task.QueuedAt, string(metadata), task.ParentID, task.UpdatedAt, task.Origin, task.ProjectID, task.Labels, task.Identifier, task.ID, fromStepID, destinationStepID) + `), append([]interface{}{ + task.WorkspaceID, task.WorkflowID, task.WorkflowStepID, task.Title, task.Description, task.State, task.Priority, task.Position, dialect.BoolToInt(task.WIPAdmitted), task.QueuedForStepID, task.QueuedAt, string(metadata), task.ParentID, task.UpdatedAt, task.Origin, task.ProjectID, task.Labels, task.Identifier, task.ID, + }, predicateArgs...)...) if err != nil { return false, err } @@ -889,7 +996,7 @@ func (r *Repository) PromoteQueuedTaskIfWorkflowStepHasCapacity( if rows == 0 { return false, nil } - if err := syncRunnerInTx(ctx, tx, task.WorkflowStepID, task.ID, task.AssigneeAgentProfileID); err != nil { + if err := syncRunnerInTx(ctx, tx, r.db.Rebind, task.WorkflowStepID, task.ID, task.AssigneeAgentProfileID); err != nil { return false, err } if err := tx.Commit(); err != nil { @@ -1234,6 +1341,34 @@ func (r *Repository) ListQueuedTasks(ctx context.Context) ([]*models.Task, error return r.scanTasks(rows) } +// ListTasksWithMetadataKey returns active, non-ephemeral tasks carrying a +// named metadata key. It is used by startup lifecycle recovery, where queue +// destination columns alone cannot find already-admitted work whose entry or +// source-exit side effect still needs to run. +func (r *Repository) ListTasksWithMetadataKey(ctx context.Context, key string) ([]*models.Task, error) { + var predicate, path string + if dialect.IsPostgres(r.ro.DriverName()) { + predicate = "jsonb_extract_path(CASE WHEN t.metadata IS NULL OR t.metadata = 'null' OR t.metadata = '' THEN '{}'::jsonb ELSE t.metadata::jsonb END, ?) IS NOT NULL" + path = key + } else { + predicate = "json_type(CASE WHEN t.metadata IS NULL OR t.metadata = 'null' OR t.metadata = '' THEN '{}' ELSE t.metadata END, ?) IS NOT NULL" + path = jsonPath(key) + } + rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` + SELECT `+taskSelectColumns("t")+` + FROM tasks t + WHERE `+predicate+` + AND t.archived_at IS NULL + AND t.is_ephemeral = 0`+andNotAutomationOriginT+` + ORDER BY t.updated_at ASC, t.created_at ASC, t.id ASC + `), path) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + return r.scanTasks(rows) +} + // ListTasksByWorkspace returns paginated tasks for a workspace with total count // If query is non-empty, filters by task title, description, repository name, or repository path // If includeArchived is false, archived tasks are excluded. If onlyArchived @@ -2086,7 +2221,7 @@ func (r *Repository) RestoreTaskMessageRollbackIfSessionState( if rows == 0 { return false, tx.Commit() } - if err := syncRunnerInTx(ctx, tx, task.WorkflowStepID, task.ID, task.AssigneeAgentProfileID); err != nil { + if err := syncRunnerInTx(ctx, tx, r.db.Rebind, task.WorkflowStepID, task.ID, task.AssigneeAgentProfileID); err != nil { return false, err } if err := tx.Commit(); err != nil { diff --git a/apps/backend/internal/task/repository/sqlite/task_wip_postgres_test.go b/apps/backend/internal/task/repository/sqlite/task_wip_postgres_test.go new file mode 100644 index 0000000000..390795d1b8 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/task_wip_postgres_test.go @@ -0,0 +1,97 @@ +package sqlite + +import ( + "context" + "fmt" + "sync" + "testing" + + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/testutil" +) + +// TestPostgresUpdateTaskWithWorkflowStepAdmission_ConcurrentLastSlot exercises +// the PostgreSQL row lock used by workflow-step admission. SQLite coverage is +// not sufficient here because the dialect-specific lock is what serializes two +// connections that both observe the final available slot. +// Skips unless KANDEV_TEST_POSTGRES_DSN is set. +func TestPostgresUpdateTaskWithWorkflowStepAdmission_ConcurrentLastSlot(t *testing.T) { + const ( + concurrency = 8 + targetStep = "postgres-wip-target" + ) + db := openIsolatedPostgresMultiConn(t, testutil.PostgresDSNFromEnv(t), concurrency) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + ctx := context.Background() + if _, err := db.Exec(db.Rebind(` + INSERT INTO workflow_steps (id, workflow_id, name, position) + VALUES (?, ?, ?, ?) + `), targetStep, "postgres-wip-workflow", "Postgres WIP target", 0); err != nil { + t.Fatalf("seed target workflow step: %v", err) + } + if err := repo.CreateTask(ctx, &models.Task{ + ID: "postgres-wip-occupant", WorkspaceID: "postgres-wip-workspace", WorkflowID: "postgres-wip-workflow", + WorkflowStepID: targetStep, Title: "Occupant", WIPAdmitted: true, + }); err != nil { + t.Fatalf("seed occupant: %v", err) + } + + candidates := make([]*models.Task, concurrency) + for i := range candidates { + candidates[i] = &models.Task{ + ID: fmt.Sprintf("postgres-wip-candidate-%d", i), WorkspaceID: "postgres-wip-workspace", + WorkflowID: "postgres-wip-workflow", WorkflowStepID: "postgres-wip-source", + Title: "Candidate", WIPAdmitted: true, + } + if err := repo.CreateTask(ctx, candidates[i]); err != nil { + t.Fatalf("seed candidate %d: %v", i, err) + } + } + + start := make(chan struct{}) + results := make(chan struct { + admitted bool + err error + }, concurrency) + var wg sync.WaitGroup + for _, candidate := range candidates { + wg.Add(1) + go func(task *models.Task) { + defer wg.Done() + <-start + admitted, err := repo.UpdateTaskWithWorkflowStepAdmission(ctx, task, targetStep, 2) + results <- struct { + admitted bool + err error + }{admitted: admitted, err: err} + }(candidate) + } + close(start) + wg.Wait() + close(results) + + admitted, queued := 0, 0 + for result := range results { + if result.err != nil { + t.Fatalf("concurrent admission: %v", result.err) + } + if result.admitted { + admitted++ + } else { + queued++ + } + } + if admitted != 1 || queued != concurrency-1 { + t.Fatalf("admitted=%d queued=%d, want admitted=1 queued=%d", admitted, queued, concurrency-1) + } + occupants, err := repo.CountAdmittedTasksByWorkflowStep(ctx, targetStep) + if err != nil { + t.Fatalf("count admitted occupants: %v", err) + } + if occupants != 2 { + t.Fatalf("admitted occupants=%d, want 2", occupants) + } +} diff --git a/apps/backend/internal/task/repository/task_wip_test.go b/apps/backend/internal/task/repository/task_wip_test.go index f89967c773..29d6c64a37 100644 --- a/apps/backend/internal/task/repository/task_wip_test.go +++ b/apps/backend/internal/task/repository/task_wip_test.go @@ -7,6 +7,7 @@ import ( "strings" "sync" "testing" + "time" "github.com/kandev/kandev/internal/task/models" wfmodels "github.com/kandev/kandev/internal/workflow/models" @@ -21,6 +22,14 @@ type workflowStepAdmissionCreator interface { CreateTaskWithWorkflowStepAdmission(context.Context, *models.Task, string, int, string, int) error } +type workflowStepMoveAdmissionRepository interface { + UpdateTaskWithWorkflowStepAdmission(context.Context, *models.Task, string, int) (bool, error) +} + +type workflowStepMoveAdmissionWithStateRepository interface { + UpdateTaskWithWorkflowStepAdmissionAndState(context.Context, *models.Task, string, int, *v1.TaskState, bool) (bool, error) +} + type queuedTaskPromoter interface { PromoteQueuedTaskIfWorkflowStepHasCapacity(context.Context, *models.Task, string, string, int) (bool, error) } @@ -171,6 +180,236 @@ func TestCreateTaskWithWorkflowStepAdmission_QueuesOverflowInPlace(t *testing.T) } } +func TestUpdateTaskWithWorkflowStepAdmission_QueuesOverflowInPlace(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + + mover, ok := any(repo).(workflowStepMoveAdmissionRepository) + if !ok { + t.Fatal("task repository does not implement atomic workflow-step move admission") + } + ctx := context.Background() + const targetStepID = "move-target" + + if err := repo.CreateTask(ctx, &models.Task{ + ID: "move-occupant", WorkspaceID: "wip-workspace", WorkflowID: "wip-workflow", + WorkflowStepID: targetStepID, Title: "Occupant", State: v1.TaskStateCreated, + }); err != nil { + t.Fatalf("seed occupant: %v", err) + } + candidate := &models.Task{ + ID: "move-candidate", WorkspaceID: "wip-workspace", WorkflowID: "wip-workflow", + WorkflowStepID: "move-source", Title: "Candidate", State: v1.TaskStateCreated, + WIPAdmitted: true, + } + if err := repo.CreateTask(ctx, candidate); err != nil { + t.Fatalf("seed candidate: %v", err) + } + + admitted, err := mover.UpdateTaskWithWorkflowStepAdmission(ctx, candidate, targetStepID, 1) + if err != nil { + t.Fatalf("move candidate: %v", err) + } + if admitted { + t.Fatal("full target unexpectedly admitted candidate") + } + if candidate.WorkflowStepID != targetStepID || candidate.WIPAdmitted || candidate.QueuedForStepID != targetStepID { + t.Fatalf("candidate placement: step=%q admitted=%t queued=%q", candidate.WorkflowStepID, candidate.WIPAdmitted, candidate.QueuedForStepID) + } + if candidate.QueuedAt == nil { + t.Fatal("queued candidate has no queue timestamp") + } + stored, err := repo.GetTask(ctx, candidate.ID) + if err != nil { + t.Fatalf("reload candidate: %v", err) + } + if stored.WorkflowStepID != targetStepID || stored.WIPAdmitted || stored.QueuedForStepID != targetStepID { + t.Fatalf("stored candidate placement: step=%q admitted=%t queued=%q", stored.WorkflowStepID, stored.WIPAdmitted, stored.QueuedForStepID) + } +} + +func TestUpdateTaskWithWorkflowStepAdmission_UnlimitedClearsQueue(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + + mover, ok := any(repo).(workflowStepMoveAdmissionRepository) + if !ok { + t.Fatal("task repository does not implement atomic workflow-step move admission") + } + ctx := context.Background() + queuedAt := time.Now().UTC().Add(-time.Minute) + candidate := &models.Task{ + ID: "move-unlimited", WorkspaceID: "wip-workspace", WorkflowID: "wip-workflow", + WorkflowStepID: "move-source", Title: "Candidate", State: v1.TaskStateCreated, + WIPAdmitted: false, QueuedForStepID: "old-target", QueuedAt: &queuedAt, + } + if err := repo.CreateTask(ctx, candidate); err != nil { + t.Fatalf("seed candidate: %v", err) + } + + admitted, err := mover.UpdateTaskWithWorkflowStepAdmission(ctx, candidate, "unlimited-target", 0) + if err != nil || !admitted { + t.Fatalf("move candidate admitted=%t err=%v, want admitted", admitted, err) + } + if candidate.WorkflowStepID != "unlimited-target" || !candidate.WIPAdmitted || candidate.QueuedForStepID != "" || candidate.QueuedAt != nil { + t.Fatalf("candidate placement: step=%q admitted=%t queued=%q queued_at=%v", candidate.WorkflowStepID, candidate.WIPAdmitted, candidate.QueuedForStepID, candidate.QueuedAt) + } + stored, err := repo.GetTask(ctx, candidate.ID) + if err != nil { + t.Fatalf("reload unlimited candidate: %v", err) + } + if stored.WorkflowStepID != "unlimited-target" || !stored.WIPAdmitted || stored.QueuedForStepID != "" || stored.QueuedAt != nil { + t.Fatalf("stored candidate placement: step=%q admitted=%t queued=%q queued_at=%v", stored.WorkflowStepID, stored.WIPAdmitted, stored.QueuedForStepID, stored.QueuedAt) + } +} + +func TestUpdateTaskWithWorkflowStepAdmissionAndState_PersistsMoveLifecycleAtomically(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + + mover, ok := any(repo).(workflowStepMoveAdmissionWithStateRepository) + if !ok { + t.Fatal("task repository does not implement atomic workflow-step move admission with state") + } + ctx := context.Background() + const targetStepID = "atomic-move-target" + if err := repo.CreateTask(ctx, &models.Task{ + ID: "atomic-move-occupant", WorkspaceID: "wip-workspace", WorkflowID: "wip-workflow", + WorkflowStepID: targetStepID, Title: "Occupant", State: v1.TaskStateCreated, + }); err != nil { + t.Fatalf("seed occupant: %v", err) + } + candidate := &models.Task{ + ID: "atomic-move-candidate", WorkspaceID: "wip-workspace", WorkflowID: "wip-workflow", + WorkflowStepID: "atomic-move-source", Title: "Candidate", State: v1.TaskStateTODO, + WIPAdmitted: true, + } + if err := repo.CreateTask(ctx, candidate); err != nil { + t.Fatalf("seed candidate: %v", err) + } + admittedState := v1.TaskStateCompleted + admitted, err := mover.UpdateTaskWithWorkflowStepAdmissionAndState(ctx, candidate, targetStepID, 1, &admittedState, true) + if err != nil { + t.Fatalf("move candidate: %v", err) + } + if admitted { + t.Fatal("full target unexpectedly admitted candidate") + } + stored, err := repo.GetTask(ctx, candidate.ID) + if err != nil { + t.Fatalf("reload candidate: %v", err) + } + if stored.State != v1.TaskStateTODO { + t.Fatalf("queued move state = %q, want original TODO", stored.State) + } + if _, ok := stored.Metadata[models.MetaKeyQueuedMoveExitPending]; !ok { + t.Fatalf("queued move metadata = %#v, want %q marker", stored.Metadata, models.MetaKeyQueuedMoveExitPending) + } + + if err := repo.CreateTask(ctx, &models.Task{ + ID: "atomic-admitted-candidate", WorkspaceID: "wip-workspace", WorkflowID: "wip-workflow", + WorkflowStepID: "atomic-move-source", Title: "Admitted candidate", State: v1.TaskStateTODO, + WIPAdmitted: true, + Metadata: map[string]interface{}{models.MetaKeyQueuedMoveExitPending: true}, + }); err != nil { + t.Fatalf("seed unlimited candidate: %v", err) + } + admittedCandidate, err := repo.GetTask(ctx, "atomic-admitted-candidate") + if err != nil { + t.Fatalf("load unlimited candidate: %v", err) + } + admitted, err = mover.UpdateTaskWithWorkflowStepAdmissionAndState(ctx, admittedCandidate, "atomic-unlimited-target", 0, &admittedState, true) + if err != nil || !admitted { + t.Fatalf("unlimited move admitted=%t err=%v", admitted, err) + } + stored, err = repo.GetTask(ctx, admittedCandidate.ID) + if err != nil { + t.Fatalf("reload admitted candidate: %v", err) + } + if stored.State != v1.TaskStateCompleted { + t.Fatalf("admitted move state = %q, want COMPLETED", stored.State) + } + if _, ok := stored.Metadata[models.MetaKeyQueuedMoveExitPending]; ok { + t.Fatalf("admitted move retained queued lifecycle marker: %#v", stored.Metadata) + } +} + +func TestUpdateTaskWithWorkflowStepAdmission_ConcurrentLastSlot(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + + mover, ok := any(repo).(workflowStepMoveAdmissionRepository) + if !ok { + t.Fatal("task repository does not implement atomic workflow-step move admission") + } + ctx := context.Background() + const ( + targetStepID = "concurrent-move-target" + workerCount = 8 + ) + if err := repo.CreateTask(ctx, &models.Task{ + ID: "concurrent-move-occupant", WorkspaceID: "wip-workspace", WorkflowID: "wip-workflow", + WorkflowStepID: targetStepID, Title: "Occupant", State: v1.TaskStateCreated, + }); err != nil { + t.Fatalf("seed occupant: %v", err) + } + candidates := make([]*models.Task, workerCount) + for i := range candidates { + candidates[i] = &models.Task{ + ID: fmt.Sprintf("concurrent-move-%d", i), WorkspaceID: "wip-workspace", WorkflowID: "wip-workflow", + WorkflowStepID: "concurrent-move-source", Title: "Candidate", State: v1.TaskStateCreated, + WIPAdmitted: true, + } + if err := repo.CreateTask(ctx, candidates[i]); err != nil { + t.Fatalf("seed candidate %d: %v", i, err) + } + } + + start := make(chan struct{}) + results := make(chan struct { + admitted bool + err error + }, workerCount) + var wg sync.WaitGroup + for _, candidate := range candidates { + wg.Add(1) + go func(task *models.Task) { + defer wg.Done() + <-start + admitted, err := mover.UpdateTaskWithWorkflowStepAdmission(ctx, task, targetStepID, 2) + results <- struct { + admitted bool + err error + }{admitted: admitted, err: err} + }(candidate) + } + close(start) + wg.Wait() + close(results) + + admitted, queued := 0, 0 + for result := range results { + if result.err != nil { + t.Fatalf("concurrent move: %v", result.err) + } + if result.admitted { + admitted++ + } else { + queued++ + } + } + if admitted != 1 || queued != workerCount-1 { + t.Fatalf("admitted=%d queued=%d, want admitted=1 queued=%d", admitted, queued, workerCount-1) + } + occupants, err := repo.CountAdmittedTasksByWorkflowStep(ctx, targetStepID) + if err != nil { + t.Fatalf("count admitted occupants: %v", err) + } + if occupants != 2 { + t.Fatalf("admitted occupants=%d, want 2", occupants) + } +} + func TestCreateTaskWithWorkflowStepAdmission_UsesFeederAndStopsAtFullFeeder(t *testing.T) { repo, cleanup := createTestSQLiteRepo(t) defer cleanup() @@ -256,6 +495,67 @@ func TestPromoteQueuedTaskIfWorkflowStepHasCapacity_ClaimsOnce(t *testing.T) { } } +func TestPromoteQueuedTaskIfWorkflowStepHasCapacity_SameStepConcurrentClaim(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + promoter, ok := any(repo).(queuedTaskPromoter) + if !ok { + t.Fatal("task repository does not implement atomic queued-task promotion") + } + ctx := context.Background() + queued := &models.Task{ + ID: "same-step-queued", WorkspaceID: "wip-workspace", WorkflowID: "wip-workflow", + WorkflowStepID: "target", Title: "Same-step queued", State: v1.TaskStateCreated, + WIPAdmitted: false, QueuedForStepID: "target", + } + if err := repo.CreateTask(ctx, queued); err != nil { + t.Fatalf("create same-step queued task: %v", err) + } + + // Both reconcilers select the same row before either attempts its atomic + // claim. The database predicate, rather than caller timing, must decide the + // winner. + start := make(chan struct{}) + results := make(chan bool, 2) + errs := make(chan error, 2) + for i := 0; i < 2; i++ { + candidate := *queued + candidate.Metadata = map[string]interface{}{ + models.MetaKeyQueuePromotionPending: true, + } + candidate.WIPAdmitted = true + candidate.QueuedForStepID = "" + go func(task *models.Task) { + <-start + claimed, err := promoter.PromoteQueuedTaskIfWorkflowStepHasCapacity(ctx, task, "target", "target", 1) + results <- claimed + errs <- err + }(&candidate) + } + close(start) + + claimedCount := 0 + for i := 0; i < 2; i++ { + if err := <-errs; err != nil { + t.Fatalf("concurrent promotion %d: %v", i, err) + } + if <-results { + claimedCount++ + } + } + if claimedCount != 1 { + t.Fatalf("concurrent same-step claims = %d, want exactly 1", claimedCount) + } + + got, err := repo.GetTask(ctx, queued.ID) + if err != nil { + t.Fatalf("reload same-step promoted task: %v", err) + } + if got.WorkflowStepID != "target" || got.QueuedForStepID != "" || !got.WIPAdmitted { + t.Fatalf("same-step promoted task: step=%q queue=%q admitted=%t", got.WorkflowStepID, got.QueuedForStepID, got.WIPAdmitted) + } +} + func TestTaskMetadataKeyHelpersRoundTripNestedValue(t *testing.T) { repo, cleanup := createTestSQLiteRepo(t) defer cleanup() diff --git a/apps/backend/internal/task/service/service_events.go b/apps/backend/internal/task/service/service_events.go index bdc641def8..5fc9ab9a70 100644 --- a/apps/backend/internal/task/service/service_events.go +++ b/apps/backend/internal/task/service/service_events.go @@ -709,6 +709,10 @@ func (s *Service) publishTaskMovedEvent(ctx context.Context, task *models.Task, if s.eventBus == nil { return } + queuePromotion := false + if task.Metadata != nil { + _, queuePromotion = task.Metadata[models.MetaKeyQueuePromotionPending] + } data := map[string]interface{}{ "task_id": task.ID, "from_workflow_id": fromWorkflowID, @@ -720,6 +724,14 @@ func (s *Service) publishTaskMovedEvent(ctx context.Context, task *models.Task, "task_description": task.Description, "parent_id": task.ParentID, "assignee_agent_profile_id": task.AssigneeAgentProfileID, + "wip_admitted": task.WIPAdmitted, + "queued_for_step_id": task.QueuedForStepID, + "queue_promotion": queuePromotion, + } + if task.QueuedAt != nil { + data["queued_at"] = task.QueuedAt.Format(time.RFC3339) + } else { + data["queued_at"] = nil } event := bus.NewEvent(events.TaskMoved, "task-service", data) if err := s.eventBus.Publish(ctx, events.TaskMoved, event); err != nil { diff --git a/apps/backend/internal/task/service/service_workflow.go b/apps/backend/internal/task/service/service_workflow.go index f51200fb7a..b4dfac3e90 100644 --- a/apps/backend/internal/task/service/service_workflow.go +++ b/apps/backend/internal/task/service/service_workflow.go @@ -424,6 +424,21 @@ type workflowLimitedMoveRepository interface { UpdateTaskIfWorkflowStepHasCapacity(ctx context.Context, task *models.Task, targetStepID, excludeTaskID string, limit int) error } +type workflowMoveAdmissionRepository interface { + UpdateTaskWithWorkflowStepAdmission(ctx context.Context, task *models.Task, targetStepID string, limit int) (bool, error) +} + +type workflowMoveAdmissionWithStateRepository interface { + UpdateTaskWithWorkflowStepAdmissionAndState( + ctx context.Context, + task *models.Task, + targetStepID string, + limit int, + admittedState *v1.TaskState, + queueExitPending bool, + ) (bool, error) +} + type workflowQueuedTaskPromoter interface { PromoteQueuedTaskIfWorkflowStepHasCapacity(ctx context.Context, task *models.Task, fromStepID, destinationStepID string, limit int) (bool, error) } @@ -472,24 +487,40 @@ func (s *Service) MoveTaskWithOptions( oldStepID := task.WorkflowStepID oldState := task.State stepChanged := oldStepID != workflowStepID + stateAfterAdmission := *task + if stepChanged { + if err := s.syncTaskStateForWorkflowMove(ctx, &stateAfterAdmission, oldStepID, workflowStepID); err != nil { + return nil, fmt.Errorf("failed to sync task state for workflow move: %w", err) + } + } task.WorkflowID = workflowID task.WorkflowStepID = workflowStepID task.Position = position if stepChanged { + if task.Metadata == nil { + task.Metadata = make(map[string]interface{}) + } task.WIPAdmitted = true task.QueuedForStepID = "" task.QueuedAt = nil + task.Metadata[models.MetaKeyQueuedMoveExitPending] = map[string]interface{}{ + "from_step_id": oldStepID, + } + delete(task.Metadata, models.MetaKeyQueuedMoveExitCompleted) + delete(task.Metadata, models.MetaKeyQueuePromotionPending) if !opts.PreserveDeferredLaunch { models.DropWIPDeferredLaunch(task) } } - if err := s.syncTaskStateForWorkflowMove(ctx, task, oldStepID, workflowStepID); err != nil { - return nil, fmt.Errorf("failed to sync task state for workflow move: %w", err) - } task.UpdatedAt = time.Now().UTC() - if err := s.updateMovedTask(ctx, task, oldStepID, targetStep); err != nil { + var admittedState *v1.TaskState + if stepChanged { + admittedState = &stateAfterAdmission.State + } + _, err = s.updateMovedTask(ctx, task, oldStepID, targetStep, admittedState) + if err != nil { s.logger.Error("failed to move task", zap.String("task_id", id), zap.Error(err)) return nil, err } @@ -647,33 +678,69 @@ func (s *Service) promoteNextQueuedTask(ctx context.Context, targetStep *wfmodel skipped[candidate.ID] = struct{}{} return s.promoteNextQueuedTask(ctx, targetStep, position, skipped) } + if queuedMoveExitPending(candidate) { + skipped[candidate.ID] = struct{}{} + return s.promoteNextQueuedTask(ctx, targetStep, position, skipped) + } if candidate.WorkflowStepID == targetStep.ID { return s.promoteSameStepQueuedTask(ctx, candidate, fromStepID, targetStep, position, skipped) } return s.promoteFeederQueuedTask(ctx, candidate, fromStepID, oldWorkflowID, targetStep, position, skipped) } +func queuedMoveExitPending(task *models.Task) bool { + if task == nil || task.Metadata == nil { + return false + } + _, pending := task.Metadata[models.MetaKeyQueuedMoveExitPending] + if !pending { + return false + } + _, completed := task.Metadata[models.MetaKeyQueuedMoveExitCompleted] + return !completed +} + func (s *Service) promoteSameStepQueuedTask(ctx context.Context, candidate *models.Task, fromStepID string, targetStep *wfmodels.WorkflowStep, position int, skipped map[string]struct{}) bool { + oldState := candidate.State + if candidate.Metadata == nil { + candidate.Metadata = make(map[string]interface{}) + } candidate.WIPAdmitted = true candidate.QueuedForStepID = "" candidate.QueuedAt = nil candidate.Position = position + candidate.Metadata[models.MetaKeyQueuePromotionPending] = true + if err := s.syncTaskStateForQueuePromotion(ctx, candidate, targetStep); err != nil { + s.logger.Warn("failed to prepare same-step queued promotion", zap.String("task_id", candidate.ID), zap.Error(err)) + skipped[candidate.ID] = struct{}{} + return s.promoteNextQueuedTask(ctx, targetStep, position, skipped) + } supported, claimed, err := promoteQueuedTaskAtomically(ctx, s.tasks, candidate, fromStepID, targetStep.ID, targetStep.WIPLimit) if supported { - return s.finishAtomicQueuedPromotion(ctx, candidate, targetStep, position, skipped, claimed, err) - } else if limitedRepo, ok := s.tasks.(workflowLimitedMoveRepository); ok && targetStep.WIPLimit > 0 { - if err := limitedRepo.UpdateTaskIfWorkflowStepHasCapacity(ctx, candidate, targetStep.ID, candidate.ID, targetStep.WIPLimit); err != nil { - return false + return s.finishAtomicQueuedPromotion(ctx, candidate, targetStep, position, skipped, claimed, err, oldState) + } else if admissionRepo, ok := s.tasks.(workflowMoveAdmissionRepository); ok { + claimed, err := admissionRepo.UpdateTaskWithWorkflowStepAdmission(ctx, candidate, targetStep.ID, targetStep.WIPLimit) + if err != nil { + s.logger.Warn("failed to promote same-step queued task", zap.String("task_id", candidate.ID), zap.Error(err)) + skipped[candidate.ID] = struct{}{} + return s.promoteNextQueuedTask(ctx, targetStep, position, skipped) + } + if !claimed { + skipped[candidate.ID] = struct{}{} + return s.promoteNextQueuedTask(ctx, targetStep, position, skipped) } } else if err := s.tasks.UpdateTask(ctx, candidate); err != nil { return false } s.publishTaskEvent(ctx, events.TaskUpdated, candidate, nil) + if oldState != candidate.State { + s.publishTaskEvent(ctx, events.TaskStateChanged, candidate, &oldState) + } s.publishTaskEvent(ctx, events.TaskQueuePromoted, candidate, nil) return true } -func (s *Service) finishAtomicQueuedPromotion(ctx context.Context, candidate *models.Task, targetStep *wfmodels.WorkflowStep, position int, skipped map[string]struct{}, claimed bool, err error) bool { +func (s *Service) finishAtomicQueuedPromotion(ctx context.Context, candidate *models.Task, targetStep *wfmodels.WorkflowStep, position int, skipped map[string]struct{}, claimed bool, err error, oldState v1.TaskState) bool { if err != nil { s.logger.Warn("failed to promote same-step queued task", zap.String("task_id", candidate.ID), zap.Error(err)) return false @@ -683,6 +750,9 @@ func (s *Service) finishAtomicQueuedPromotion(ctx context.Context, candidate *mo return s.promoteNextQueuedTask(ctx, targetStep, position, skipped) } s.publishTaskEvent(ctx, events.TaskUpdated, candidate, nil) + if oldState != candidate.State { + s.publishTaskEvent(ctx, events.TaskStateChanged, candidate, &oldState) + } s.publishTaskEvent(ctx, events.TaskQueuePromoted, candidate, nil) return true } @@ -697,12 +767,22 @@ func promoteQueuedTaskAtomically(ctx context.Context, tasks interface{}, task *m } func (s *Service) promoteFeederQueuedTask(ctx context.Context, candidate *models.Task, fromStepID, oldWorkflowID string, targetStep *wfmodels.WorkflowStep, position int, skipped map[string]struct{}) bool { + oldState := candidate.State + if candidate.Metadata == nil { + candidate.Metadata = make(map[string]interface{}) + } candidate.WIPAdmitted = true candidate.QueuedForStepID = "" candidate.QueuedAt = nil + candidate.Metadata[models.MetaKeyQueuePromotionPending] = true candidate.Position = position candidate.WorkflowID = targetStep.WorkflowID candidate.WorkflowStepID = targetStep.ID + if err := s.syncTaskStateForQueuePromotion(ctx, candidate, targetStep); err != nil { + s.logger.Warn("failed to prepare feeder queued promotion", zap.String("task_id", candidate.ID), zap.Error(err)) + skipped[candidate.ID] = struct{}{} + return s.promoteNextQueuedTask(ctx, targetStep, position, skipped) + } if promoter, ok := s.tasks.(workflowQueuedTaskPromoter); ok { claimed, err := promoter.PromoteQueuedTaskIfWorkflowStepHasCapacity(ctx, candidate, fromStepID, targetStep.ID, targetStep.WIPLimit) if err != nil { @@ -714,6 +794,27 @@ func (s *Service) promoteFeederQueuedTask(ctx context.Context, candidate *models return s.promoteNextQueuedTask(ctx, targetStep, position, skipped) } s.publishTaskEvent(ctx, events.TaskUpdated, candidate, nil, oldWorkflowID) + if oldState != candidate.State { + s.publishTaskEvent(ctx, events.TaskStateChanged, candidate, &oldState) + } + s.recordQueuedPromotion(ctx, candidate.ID, fromStepID, targetStep.ID) + s.publishTaskMovedEvent(ctx, candidate, oldWorkflowID, fromStepID, targetStep.ID, "") + return true + } else if admissionRepo, ok := s.tasks.(workflowMoveAdmissionRepository); ok { + claimed, err := admissionRepo.UpdateTaskWithWorkflowStepAdmission(ctx, candidate, targetStep.ID, targetStep.WIPLimit) + if err != nil { + s.logger.Warn("failed to promote feeder queued task", zap.String("task_id", candidate.ID), zap.Error(err)) + skipped[candidate.ID] = struct{}{} + return s.promoteNextQueuedTask(ctx, targetStep, position, skipped) + } + if !claimed { + skipped[candidate.ID] = struct{}{} + return s.promoteNextQueuedTask(ctx, targetStep, position, skipped) + } + s.publishTaskEvent(ctx, events.TaskUpdated, candidate, nil, oldWorkflowID) + if oldState != candidate.State { + s.publishTaskEvent(ctx, events.TaskStateChanged, candidate, &oldState) + } s.recordQueuedPromotion(ctx, candidate.ID, fromStepID, targetStep.ID) s.publishTaskMovedEvent(ctx, candidate, oldWorkflowID, fromStepID, targetStep.ID, "") return true @@ -783,7 +884,7 @@ func (s *Service) nextQueuedCandidate(ctx context.Context, targetStep *wfmodels. if sameStep == nil { return feederTask, nil } - if feederTask == nil || queuedTaskBefore(sameStep, feederTask) { + if sameStep != nil { return sameStep, nil } return feederTask, nil @@ -868,15 +969,63 @@ func skippedTaskIDs(skipped map[string]struct{}) []string { return ids } -func (s *Service) updateMovedTask(ctx context.Context, task *models.Task, oldStepID string, targetStep *wfmodels.WorkflowStep) error { - if targetStep == nil || targetStep.WIPLimit <= 0 || oldStepID == targetStep.ID { - return s.tasks.UpdateTask(ctx, task) +func (s *Service) updateMovedTask(ctx context.Context, task *models.Task, oldStepID string, targetStep *wfmodels.WorkflowStep, admittedState *v1.TaskState) (bool, error) { + if targetStep == nil || oldStepID == targetStep.ID { + if err := s.tasks.UpdateTask(ctx, task); err != nil { + return false, err + } + return task.WIPAdmitted, nil } - limitedRepo, ok := s.tasks.(workflowLimitedMoveRepository) + admissionRepo, ok := s.tasks.(workflowMoveAdmissionRepository) if !ok { - return fmt.Errorf("WIP limit cannot be checked for workflow step %s", targetStep.ID) + return false, fmt.Errorf("workflow step admission repository unavailable for step %s", targetStep.ID) } - return limitedRepo.UpdateTaskIfWorkflowStepHasCapacity(ctx, task, targetStep.ID, task.ID, targetStep.WIPLimit) + if admissionWithState, ok := s.tasks.(workflowMoveAdmissionWithStateRepository); ok { + return admissionWithState.UpdateTaskWithWorkflowStepAdmissionAndState( + ctx, task, targetStep.ID, targetStep.WIPLimit, admittedState, true, + ) + } + + // Keep compatibility with narrow test/dry-run repositories that expose + // only the original admission method. Production repositories implement the + // atomic variant above, so this fallback is never used for real moves. + admitted, err := admissionRepo.UpdateTaskWithWorkflowStepAdmission(ctx, task, targetStep.ID, targetStep.WIPLimit) + if err != nil { + return false, err + } + if admitted && admittedState != nil { + task.State = *admittedState + delete(task.Metadata, models.MetaKeyQueuedMoveExitPending) + } else if !admitted { + if task.Metadata == nil { + task.Metadata = make(map[string]interface{}) + } + task.Metadata[models.MetaKeyQueuedMoveExitPending] = true + } + if err := s.tasks.UpdateTask(ctx, task); err != nil { + return false, err + } + return admitted, nil +} + +func (s *Service) syncTaskStateForQueuePromotion(ctx context.Context, task *models.Task, targetStep *wfmodels.WorkflowStep) error { + if targetStep == nil { + return nil + } + terminal, err := s.terminalWorkflowStep(ctx, targetStep.ID) + if err != nil { + return fmt.Errorf("sync promoted task state for %s: %w", task.ID, err) + } + if terminal { + if !models.IsTerminalTaskState(task.State) { + task.State = v1.TaskStateCompleted + } + return nil + } + if task.State == v1.TaskStateCompleted { + task.State = v1.TaskStateTODO + } + return nil } func (s *Service) validateTaskMove(ctx context.Context, task *models.Task, workflowID, workflowStepID string, opts MoveTaskOptions) (*wfmodels.WorkflowStep, error) { @@ -903,30 +1052,9 @@ func (s *Service) validateTaskMove(ctx context.Context, task *models.Task, workf if targetStep.WorkflowID != workflowID { return nil, fmt.Errorf("target workflow step does not belong to target workflow") } - if err := s.validateMoveWIPLimit(ctx, task, targetStep); err != nil { - return nil, err - } return targetStep, nil } -func (s *Service) validateMoveWIPLimit(ctx context.Context, task *models.Task, targetStep *wfmodels.WorkflowStep) error { - if targetStep == nil || targetStep.WIPLimit <= 0 || task.WorkflowStepID == targetStep.ID { - return nil - } - limitsRepo, ok := s.tasks.(workflowMoveLimitsRepository) - if !ok { - return fmt.Errorf("WIP limit cannot be checked for workflow step %s", targetStep.ID) - } - occupants, err := limitsRepo.CountTasksByWorkflowStepExcludingTask(ctx, targetStep.ID, task.ID) - if err != nil { - return fmt.Errorf("failed to count target workflow step tasks: %w", err) - } - if occupants >= targetStep.WIPLimit { - return wfmodels.NewWIPLimitError(targetStep.ID, targetStep.WIPLimit, occupants) - } - return nil -} - func (s *Service) validateMoveSessions(ctx context.Context, taskID string, opts MoveTaskOptions) error { sessions, err := s.sessions.ListTaskSessions(ctx, taskID) if err != nil { @@ -1052,10 +1180,6 @@ func (s *Service) BulkMoveSelectedTasks(ctx context.Context, taskIDs []string, t if err != nil { return nil, err } - if err := s.validateBulkMoveWIPCapacity(ctx, tasks, targetStepID); err != nil { - return nil, err - } - nextPosition, err := s.tasks.CountTasksByWorkflowStep(ctx, targetStepID) if err != nil { return nil, fmt.Errorf("failed to count target workflow step tasks: %w", err) @@ -1108,40 +1232,6 @@ func (s *Service) validateSelectedMoveBatch(ctx context.Context, taskIDs []strin return tasks, nil } -func (s *Service) validateBulkMoveWIPCapacity(ctx context.Context, tasks []*models.Task, targetStepID string) error { - if s.workflowStepGetter == nil { - return nil - } - targetStep, err := s.workflowStepGetter.GetStep(ctx, targetStepID) - if err != nil { - return fmt.Errorf("failed to get target workflow step: %w", err) - } - if targetStep.WIPLimit <= 0 { - return nil - } - incoming := 0 - for _, task := range tasks { - if task.WorkflowStepID != targetStepID { - incoming++ - } - } - if incoming == 0 { - return nil - } - limitsRepo, ok := s.tasks.(workflowMoveLimitsRepository) - if !ok { - return fmt.Errorf("WIP limit cannot be checked for workflow step %s", targetStep.ID) - } - occupants, err := limitsRepo.CountTasksByWorkflowStepExcludingTask(ctx, targetStep.ID, "") - if err != nil { - return fmt.Errorf("failed to count target workflow step tasks: %w", err) - } - if occupants+incoming > targetStep.WIPLimit { - return fmt.Errorf("%w: moving %d task(s)", wfmodels.NewWIPLimitError(targetStep.ID, targetStep.WIPLimit, occupants), incoming) - } - return nil -} - // BulkMoveTasks moves all tasks from a source workflow/step to a target workflow/step. // If sourceStepID is empty, all tasks in the source workflow are moved. func (s *Service) BulkMoveTasks(ctx context.Context, sourceWorkflowID, sourceStepID, targetWorkflowID, targetStepID string) (*BulkMoveTasksResult, error) { @@ -1160,10 +1250,6 @@ func (s *Service) BulkMoveTasks(ctx context.Context, sourceWorkflowID, sourceSte if len(tasks) == 0 { return &BulkMoveTasksResult{MovedCount: 0}, nil } - if err := s.validateBulkMoveWIPCapacity(ctx, tasks, targetStepID); err != nil { - return nil, err - } - for i, task := range tasks { if _, err := s.MoveTask(ctx, task.ID, targetWorkflowID, targetStepID, i); err != nil { return nil, fmt.Errorf("failed to move task %s: %w", task.ID, err) diff --git a/apps/backend/internal/task/service/service_workflow_test.go b/apps/backend/internal/task/service/service_workflow_test.go index b8a8547081..714132ad1b 100644 --- a/apps/backend/internal/task/service/service_workflow_test.go +++ b/apps/backend/internal/task/service/service_workflow_test.go @@ -3,7 +3,6 @@ package service import ( "context" "errors" - "strings" "testing" "time" @@ -406,8 +405,8 @@ func TestService_MoveTaskWithOptionsAllowsRunningPrimarySession(t *testing.T) { } } -func TestService_MoveTaskRejectsFullWIPLimitedTarget(t *testing.T) { - svc, _, repo := createTestService(t) +func TestService_MoveTaskQueuesFullWIPLimitedTarget(t *testing.T) { + svc, eventBus, repo := createTestService(t) ctx := context.Background() seedMoveWorkflows(t, ctx, repo) seedMoveSteps(svc) @@ -418,25 +417,44 @@ func TestService_MoveTaskRejectsFullWIPLimitedTarget(t *testing.T) { createMoveTask(t, ctx, repo, "task-moving", "wf-source", "step-source", nil) createMoveTask(t, ctx, repo, "task-occupant", "wf-source", "step-full", nil) - _, err := svc.MoveTask(ctx, "task-moving", "wf-source", "step-full", 0) - if err == nil { - t.Fatalf("expected WIP-limited move to be rejected") + moved, err := svc.MoveTask(ctx, "task-moving", "wf-source", "step-full", 0) + if err != nil { + t.Fatalf("MoveTask: %v", err) } - if !strings.Contains(err.Error(), "WIP limit") { - t.Fatalf("error = %q, want WIP limit rejection", err.Error()) + if moved.Task.WIPAdmitted { + t.Fatal("overflow move consumed WIP capacity") + } + if moved.Task.QueuedForStepID != "step-full" { + t.Fatalf("queued_for_step_id = %q, want step-full", moved.Task.QueuedForStepID) + } + if moved.Task.QueuedAt == nil { + t.Fatal("overflow move did not record queued_at") } task, err := repo.GetTask(ctx, "task-moving") if err != nil { t.Fatalf("GetTask: %v", err) } - if task.WorkflowStepID != "step-source" { - t.Fatalf("task moved despite WIP limit: %s", task.WorkflowStepID) + if task.WorkflowStepID != "step-full" { + t.Fatalf("workflow_step_id = %s, want step-full", task.WorkflowStepID) + } + event := findPublishedEvent(t, eventBus.GetPublishedEvents(), events.TaskMoved) + data, ok := event.Data.(map[string]interface{}) + if !ok { + t.Fatalf("event data type = %T, want map[string]interface{}", event.Data) + } + if admitted, _ := data["wip_admitted"].(bool); admitted { + t.Fatal("task.moved event marked queued move as admitted") + } + if got := data["queued_for_step_id"]; got != "step-full" { + t.Fatalf("task.moved queued_for_step_id = %v, want step-full", got) + } + if data["queued_at"] == nil { + t.Fatal("task.moved event omitted queued_at") } - } -func TestService_ApproveSessionRejectsFullWIPLimitedTarget(t *testing.T) { +func TestService_ApproveSessionQueuesFullWIPLimitedTarget(t *testing.T) { svc, _, repo := createTestService(t) ctx := context.Background() seedMoveWorkflows(t, ctx, repo) @@ -457,28 +475,34 @@ func TestService_ApproveSessionRejectsFullWIPLimitedTarget(t *testing.T) { createMoveTask(t, ctx, repo, "task-occupant", "wf-source", "step-full", nil) createMoveSession(t, ctx, repo, "session-approve", "task-approve", models.TaskSessionStateWaitingForInput, models.ReviewStatusPending) - _, err := svc.ApproveSession(ctx, "session-approve") - if err == nil { - t.Fatalf("expected approval transition to be rejected") + result, err := svc.ApproveSession(ctx, "session-approve") + if err != nil { + t.Fatalf("ApproveSession: %v", err) } - if !strings.Contains(err.Error(), "WIP limit") { - t.Fatalf("error = %q, want WIP limit rejection", err.Error()) + if result.Task == nil { + t.Fatal("approval result did not include task") + } + if result.Task.WIPAdmitted { + t.Fatal("approval overflow move consumed WIP capacity") + } + if result.Task.QueuedForStepID != "step-full" { + t.Fatalf("queued_for_step_id = %q, want step-full", result.Task.QueuedForStepID) } task, err := repo.GetTask(ctx, "task-approve") if err != nil { t.Fatalf("GetTask: %v", err) } - if task.WorkflowStepID != "step-source" { - t.Fatalf("task moved despite WIP limit: %s", task.WorkflowStepID) + if task.WorkflowStepID != "step-full" { + t.Fatalf("workflow_step_id = %s, want step-full", task.WorkflowStepID) } session, err := repo.GetTaskSession(ctx, "session-approve") if err != nil { t.Fatalf("GetTaskSession: %v", err) } - if session.ReviewStatus != models.ReviewStatusPending { - t.Fatalf("review status = %q, want pending after rejected approval", session.ReviewStatus) + if session.ReviewStatus != models.ReviewStatusApproved { + t.Fatalf("review status = %q, want approved after queued approval", session.ReviewStatus) } } @@ -797,7 +821,7 @@ func TestService_BulkMoveSelectedTasksValidatesBatchBeforeMoving(t *testing.T) { } } -func TestService_BulkMoveSelectedTasksRejectsOverCapacityBeforeMoving(t *testing.T) { +func TestService_BulkMoveSelectedTasksQueuesOverCapacity(t *testing.T) { svc, _, repo := createTestService(t) ctx := context.Background() seedMoveWorkflows(t, ctx, repo) @@ -808,21 +832,32 @@ func TestService_BulkMoveSelectedTasksRejectsOverCapacityBeforeMoving(t *testing createMoveTask(t, ctx, repo, "task-batch-a", "wf-source", "step-source", nil) createMoveTask(t, ctx, repo, "task-batch-b", "wf-source", "step-source", nil) - _, err := svc.BulkMoveSelectedTasks(ctx, []string{"task-batch-a", "task-batch-b"}, "wf-source", "step-full") - if err == nil { - t.Fatalf("expected selected batch move to be rejected") + result, err := svc.BulkMoveSelectedTasks(ctx, []string{"task-batch-a", "task-batch-b"}, "wf-source", "step-full") + if err != nil { + t.Fatalf("BulkMoveSelectedTasks: %v", err) } - if !strings.Contains(err.Error(), "WIP limit") { - t.Fatalf("error = %q, want WIP limit rejection", err.Error()) + if result.MovedCount != 2 { + t.Fatalf("moved_count = %d, want 2", result.MovedCount) } - for _, id := range []string{"task-batch-a", "task-batch-b"} { + for index, id := range []string{"task-batch-a", "task-batch-b"} { task, err := repo.GetTask(ctx, id) if err != nil { t.Fatalf("GetTask(%s): %v", id, err) } - if task.WorkflowStepID != "step-source" { - t.Fatalf("%s moved despite rejected batch: %s", id, task.WorkflowStepID) + if task.WorkflowStepID != "step-full" { + t.Fatalf("%s workflow_step_id = %s, want step-full", id, task.WorkflowStepID) + } + if index == 0 && !task.WIPAdmitted { + t.Fatal("first batch task was not admitted") + } + if index == 1 { + if task.WIPAdmitted { + t.Fatal("second batch task consumed WIP capacity") + } + if task.QueuedForStepID != "step-full" || task.QueuedAt == nil { + t.Fatalf("second batch task queue metadata = (%q, %v), want destination queue", task.QueuedForStepID, task.QueuedAt) + } } } } diff --git a/apps/web/components/kanban-card.tsx b/apps/web/components/kanban-card.tsx index ef5f463742..0b6ddbb8e4 100644 --- a/apps/web/components/kanban-card.tsx +++ b/apps/web/components/kanban-card.tsx @@ -44,6 +44,7 @@ export interface Task { title: string; workflowStepId: string; state?: TaskState; + priority?: string | number; description?: string; position?: number; repositoryId?: string; @@ -642,7 +643,6 @@ export function resolveTaskRepositoryChips( const byId = new Map(repositories.map((repo) => [repo.id, repo])); const seen = new Set(); const chips: RepositoryChip[] = []; - const push = (id: string | undefined) => { if (!id || seen.has(id)) return; const repo = byId.get(toRepositoryId(id)); @@ -655,13 +655,8 @@ export function resolveTaskRepositoryChips( ...(repo.local_path ? { path: formatUserHomePath(repo.local_path) } : {}), }); }; - push(task.repositoryId); const ordered = [...(task.repositories ?? [])].sort((a, b) => a.position - b.position); for (const link of ordered) push(link.repository_id); return chips; } - -export function resolveTaskRepositoryNames(task: Task, repositories: Repository[]): string[] { - return resolveTaskRepositoryChips(task, repositories).map((chip) => chip.label); -} diff --git a/apps/web/components/kanban-column.tsx b/apps/web/components/kanban-column.tsx index 95c651d793..b6ff2574dc 100644 --- a/apps/web/components/kanban-column.tsx +++ b/apps/web/components/kanban-column.tsx @@ -14,6 +14,7 @@ import { useAppStore } from "@/components/state-provider"; import type { KanbanExternalLinkAvailability } from "./kanban-external-link-availability"; import type { Repository } from "@/lib/types/http"; import { countAdmittedTasks, formatWipCount, isOverWipLimit } from "@/lib/kanban/wip-limit"; +import { partitionWipTasks } from "@/lib/kanban/wip-queue"; import { useTranslation } from "react-i18next"; export interface WorkflowStep { @@ -56,6 +57,7 @@ function ColumnHeader({ step, tasks }: { step: WorkflowStep; tasks: Task[] }) { const admittedTaskCount = countAdmittedTasks(tasks); const overWipLimit = isOverWipLimit(admittedTaskCount, step.wip_limit); const wipCountLabel = formatWipCount(admittedTaskCount, step.wip_limit); + const queuedCount = partitionWipTasks(tasks, step.id).queued.length; return (
@@ -76,6 +78,11 @@ function ColumnHeader({ step, tasks }: { step: WorkflowStep; tasks: Task[] }) { > {wipCountLabel} + {queuedCount > 0 && ( + + {t("kanban:queuedCount", { count: queuedCount })} + + )}
); @@ -102,6 +109,7 @@ export function KanbanColumn({ isMultiSelectMode, externalLinkAvailability, }: KanbanColumnProps) { + const { t } = useTranslation(); const { setNodeRef, isOver } = useDroppable({ id: step.id, }); @@ -116,7 +124,9 @@ export function KanbanColumn({ // Ordered ids of the cards rendered in this column — the source of truth for // shift-click range selection (matches exactly what the user sees). - const columnTaskIds = useMemo(() => tasks.map((t) => t.id), [tasks]); + const { admitted, queued } = useMemo(() => partitionWipTasks(tasks, step.id), [tasks, step.id]); + const orderedTasks = [...admitted, ...queued]; + const columnTaskIds = useMemo(() => orderedTasks.map((task) => task.id), [orderedTasks]); return (
- {tasks.map((task) => ( - onSelectRange(taskId, columnTaskIds) : undefined - } - isMultiSelectMode={isMultiSelectMode} - /> + {orderedTasks.map((task, index) => ( +
+ {queued.length > 0 && index === admitted.length && ( +
+ {t("kanban:queuedSection")} + {queued.length} +
+ )} + onSelectRange(taskId, columnTaskIds) : undefined + } + isMultiSelectMode={isMultiSelectMode} + /> +
))}
diff --git a/apps/web/components/settings/workflow-pipeline-editor-helpers.tsx b/apps/web/components/settings/workflow-pipeline-editor-helpers.tsx index c65d7dc43f..0581813972 100644 --- a/apps/web/components/settings/workflow-pipeline-editor-helpers.tsx +++ b/apps/web/components/settings/workflow-pipeline-editor-helpers.tsx @@ -1,6 +1,7 @@ "use client"; import { IconInfoCircle } from "@tabler/icons-react"; +import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip"; import type { WorkflowStep } from "@/lib/types/http"; @@ -28,7 +29,7 @@ export function HelpTip({ testId, ariaLabel, }: { - text: string; + text: ReactNode; testId?: string; ariaLabel?: string; }) { diff --git a/apps/web/components/settings/workflow-pipeline-editor-wip-controls.tsx b/apps/web/components/settings/workflow-pipeline-editor-wip-controls.tsx index 8ddbeb7561..03ba123061 100644 --- a/apps/web/components/settings/workflow-pipeline-editor-wip-controls.tsx +++ b/apps/web/components/settings/workflow-pipeline-editor-wip-controls.tsx @@ -33,6 +33,13 @@ export function StepWipControls({ const otherSteps = steps.filter((s) => s.id !== step.id); const pullFromValue = step.pull_from_step_id || "none"; const pullFromSelectID = `${step.id}-pull-from-step`; + const pullFromGuidance = step.pull_from_step_id + ? t("workflows:pullFromGuidanceWithFeeder", { + feeder: + otherSteps.find((candidate) => candidate.id === step.pull_from_step_id)?.name ?? + t("workflows:noFeederStep"), + }) + : t("workflows:pullFromGuidanceWithoutFeeder"); return (
@@ -68,7 +75,15 @@ export function StepWipControls({ - + +

{pullFromGuidance}

+

{t("workflows:wipLimitsNote")}

+
+ } + /> -

{t("workflows:wipLimitsNote")}

); } diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.test.ts b/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.test.ts index 2f655809f5..511215d498 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.test.ts +++ b/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.test.ts @@ -155,4 +155,15 @@ describe("toSheetItem queued prompt count", () => { ); expect(item.queuedCount).toBeUndefined(); }); + + it("carries WIP queue status separately from queued prompts", () => { + const wipQueue = { position: 1, total: 3, destinationTitle: "Review" }; + const item = toSheetItem(task(), { + ...emptyCtx(), + wipQueueByTaskId: new Map([["t1", wipQueue]]), + }); + + expect(item.wipQueue).toEqual(wipQueue); + expect(item.queuedCount).toBeUndefined(); + }); }); diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts b/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts index 88e5fa94cf..c680c7d18d 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts +++ b/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts @@ -26,11 +26,13 @@ import { statusSummaryActiveErrorPreview } from "@/lib/task-status-summary"; import { resolvePreferredSessionId } from "../task-select-helpers"; import { mapSnapshotToKanban, sortByUpdatedAtDesc } from "./session-task-switcher-sheet-helpers"; import { useTranslation } from "react-i18next"; +import type { WipQueueStatus } from "@/lib/kanban/wip-queue"; type SheetItemCtx = { repositoryPathsById: Map; workflowNameById: Map; stepTitleById: Map; + wipQueueByTaskId?: Map; acknowledgedAgentErrors?: Record; dismissedAgentErrors?: Record; }; @@ -129,6 +131,7 @@ export function toSheetItem( // Queued prompt count badge — same status summary source as the desktop // sidebar mapper (buildSidebarItem) so both surfaces agree. queuedCount: task.statusSummary?.queued_prompt_count, + wipQueue: ctx.wipQueueByTaskId?.get(task.id), }; } @@ -138,6 +141,7 @@ export function useSheetData(workspaceId: string | null) { allTasks, allSteps, stepsByWorkflowId, + wipQueueByTaskId, workflows, isLoading: tasksLoading, archivedError, @@ -161,6 +165,7 @@ export function useSheetData(workspaceId: string | null) { stepTitleById: new Map(allSteps.map((s) => [s.id, s.title])), acknowledgedAgentErrors, dismissedAgentErrors, + wipQueueByTaskId, }; return allTasks.map((task) => toSheetItem(task, ctx)); }, [ @@ -171,6 +176,7 @@ export function useSheetData(workspaceId: string | null) { workspaceId, acknowledgedAgentErrors, dismissedAgentErrors, + wipQueueByTaskId, ]); const dialogSteps = useMemo( diff --git a/apps/web/components/task/task-item-stats-row.tsx b/apps/web/components/task/task-item-stats-row.tsx index dae4930740..c7f0b5f232 100644 --- a/apps/web/components/task/task-item-stats-row.tsx +++ b/apps/web/components/task/task-item-stats-row.tsx @@ -1,12 +1,13 @@ "use client"; -import { IconMail } from "@tabler/icons-react"; +import { IconClockHour4, IconMail } from "@tabler/icons-react"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { useTranslation } from "react-i18next"; import { useAppStore } from "@/components/state-provider"; import { isDebugUI } from "@/lib/config"; import type { SessionPollMode } from "@/lib/state/slices/session-runtime/types"; import { cn, formatRelativeTime } from "@/lib/utils"; +import type { WipQueueStatus } from "@/lib/kanban/wip-queue"; /** Debug-overlay-only poll-mode legend. Labels resolve through `t()` so the * pseudo-locale stays complete; the row itself is gated behind `isDebugUI()`. */ @@ -25,18 +26,21 @@ const POLL_MODE_CONFIG: Record< /** * The sidebar row's metadata line: relative last-update time, PR number, - * queued-prompt mail badge, and (debug UI only) the session poll-mode letter. + * queued-prompt mail badge, WIP queue icon, and (debug UI only) the session + * poll-mode letter. */ export function TaskItemStatsRow({ updatedAt, prInfo, primarySessionId, queuedCount, + wipQueue, }: { updatedAt?: string; prInfo?: { number: number; state: string; aggregateState?: string }; primarySessionId?: string | null; queuedCount?: number; + wipQueue?: WipQueueStatus; }) { const { t } = useTranslation(); const pollMode = useAppStore((s) => @@ -45,10 +49,17 @@ export function TaskItemStatsRow({ : null, ); - if (!updatedAt && !prInfo && !pollMode && !queuedCount) return null; + if (!updatedAt && !prInfo && !pollMode && !queuedCount && !wipQueue) return null; const modeConfig = pollMode ? POLL_MODE_CONFIG[pollMode] : null; const modeLabel = modeConfig ? t(modeConfig.labelKey) : ""; + const wipQueueLabel = wipQueue + ? t("sidebar:wipQueuePosition", { + position: wipQueue.position, + total: wipQueue.total, + step: wipQueue.destinationTitle, + }) + : ""; return ( @@ -67,6 +78,22 @@ export function TaskItemStatsRow({ {queuedCount} ) : null} + {wipQueue ? ( + + + + + + {wipQueueLabel} + + ) : null} {modeConfig && ( diff --git a/apps/web/components/task/task-item.test.tsx b/apps/web/components/task/task-item.test.tsx index b14f43b540..719503fbd9 100644 --- a/apps/web/components/task/task-item.test.tsx +++ b/apps/web/components/task/task-item.test.tsx @@ -174,6 +174,21 @@ describe("TaskItem status icon", () => { }); }); +describe("TaskItem queue status", () => { + it("shows WIP queue status without replacing the queued prompt badge", () => { + renderTaskItem({ + queuedCount: 2, + wipQueue: { position: 2, total: 4, destinationTitle: "Review" }, + }); + + expect(screen.getByTestId("sidebar-task-queued-count")).not.toBeNull(); + const queueStatus = screen.getByTestId("sidebar-task-wip-queue"); + expect(queueStatus.querySelector("svg")).not.toBeNull(); + expect(queueStatus.textContent).toBe(""); + expect(queueStatus.getAttribute("aria-label")).toBe("Position 2 of 4 in Review queue"); + }); +}); + describe("TaskItem interrupted icon", () => { it("shows the red interrupted icon for a task marked interrupted after a restart", () => { renderTaskItem({ diff --git a/apps/web/components/task/task-item.tsx b/apps/web/components/task/task-item.tsx index ccf7d2ccc1..a66cc33f06 100644 --- a/apps/web/components/task/task-item.tsx +++ b/apps/web/components/task/task-item.tsx @@ -34,6 +34,7 @@ import { classifyTask } from "./task-classify"; import { ScrollOnOverflow } from "@kandev/ui/scroll-on-overflow"; import { useTranslation } from "react-i18next"; import { TaskAutopilotIcon } from "@/components/task/task-autopilot-icon"; +import type { WipQueueStatus } from "@/lib/kanban/wip-queue"; import { RegisteredChangeRequestTaskIcon } from "@/components/integrations/registered-change-request-task-icon"; type DiffStats = { @@ -97,6 +98,8 @@ type TaskItemProps = { prInfo?: { number: number; state: string; aggregateState?: string }; /** Number of prompts currently en-queued for this task (mail badge). */ queuedCount?: number; + /** Destination-resident WIP queue status, separate from queued prompts. */ + wipQueue?: WipQueueStatus; issueInfo?: { url: string; number: number }; isPinned?: boolean; agentErrorMessage?: string | null; @@ -348,6 +351,7 @@ function TaskItemContent({ updatedAt, prInfo, queuedCount, + wipQueue, issueInfo, agentErrorMessage, }: { @@ -364,6 +368,7 @@ function TaskItemContent({ updatedAt?: string; prInfo?: { number: number; state: string; aggregateState?: string }; queuedCount?: number; + wipQueue?: WipQueueStatus; issueInfo?: { url: string; number: number }; agentErrorMessage?: string | null; }) { @@ -408,6 +413,7 @@ function TaskItemContent({ prInfo={prInfo} primarySessionId={primarySessionId} queuedCount={queuedCount} + wipQueue={wipQueue} /> ); @@ -468,6 +474,7 @@ export const TaskItem = memo(function TaskItem({ repositories, prInfo, queuedCount, + wipQueue, issueInfo, isPinned, agentErrorMessage, @@ -516,6 +523,7 @@ export const TaskItem = memo(function TaskItem({ updatedAt={updatedAt} prInfo={prInfo} queuedCount={queuedCount} + wipQueue={wipQueue} issueInfo={issueInfo} agentErrorMessage={agentErrorMessage} /> diff --git a/apps/web/components/task/task-session-sidebar-item.test.ts b/apps/web/components/task/task-session-sidebar-item.test.ts index 7d331fefc0..39dcbeb66f 100644 --- a/apps/web/components/task/task-session-sidebar-item.test.ts +++ b/apps/web/components/task/task-session-sidebar-item.test.ts @@ -110,4 +110,15 @@ describe("buildSidebarItem", () => { expect(item.queuedCount).toBeUndefined(); }); + + it("carries WIP queue status separately from queued prompts", () => { + const wipQueue = { position: 2, total: 4, destinationTitle: "Review" }; + const item = buildSidebarItem(task(), { + ...emptyContext(), + wipQueueByTaskId: new Map([["t1", wipQueue]]), + }); + + expect(item.wipQueue).toEqual(wipQueue); + expect(item.queuedCount).toBeUndefined(); + }); }); diff --git a/apps/web/components/task/task-session-sidebar-item.ts b/apps/web/components/task/task-session-sidebar-item.ts index 5e9d902513..693e85030d 100644 --- a/apps/web/components/task/task-session-sidebar-item.ts +++ b/apps/web/components/task/task-session-sidebar-item.ts @@ -3,12 +3,14 @@ import type { TaskSessionState, TaskState } from "@/lib/types/http"; import type { TaskStatusSummary } from "@/lib/types/task-status-summary"; import { statusSummaryActiveErrorPreview } from "@/lib/task-status-summary"; import { workflowStepTitle } from "./task-session-sidebar-aggregate"; +import type { WipQueueStatus } from "@/lib/kanban/wip-queue"; type SidebarItemContext = { repositorySlugById: Map; titleById: Map; workflowNameById: Map; stepTitleById: Map; + wipQueueByTaskId?: Map; acknowledgedAgentErrors?: Record; dismissedAgentErrors?: Record; }; @@ -109,6 +111,7 @@ function sidebarStatus( prInfo: summaryPRInfo(summary), issueInfo: issueInfoForTask(task), queuedCount: summary?.queued_prompt_count, + wipQueue: context.wipQueueByTaskId?.get(task.id), }; } diff --git a/apps/web/components/task/task-session-sidebar.tsx b/apps/web/components/task/task-session-sidebar.tsx index c05da3d9a4..39e2788c6f 100644 --- a/apps/web/components/task/task-session-sidebar.tsx +++ b/apps/web/components/task/task-session-sidebar.tsx @@ -67,6 +67,7 @@ function useSidebarData(workspaceId: string | null) { allTasks, allSteps, stepsByWorkflowId, + wipQueueByTaskId, workflows, isLoading: isLoadingWorkflow, archivedError, @@ -86,6 +87,7 @@ function useSidebarData(workspaceId: string | null) { titleById, workflowNameById, stepTitleById, + wipQueueByTaskId, acknowledgedAgentErrors, dismissedAgentErrors, }; @@ -105,6 +107,7 @@ function useSidebarData(workspaceId: string | null) { workflows, workspaceId, archivedState, + wipQueueByTaskId, acknowledgedAgentErrors, dismissedAgentErrors, ]); diff --git a/apps/web/components/task/task-switcher-row.tsx b/apps/web/components/task/task-switcher-row.tsx index 04565ac947..b2f6465331 100644 --- a/apps/web/components/task/task-switcher-row.tsx +++ b/apps/web/components/task/task-switcher-row.tsx @@ -157,6 +157,7 @@ function TaskRowItem({ repositories={task.repositories} prInfo={task.prInfo} queuedCount={task.queuedCount} + wipQueue={task.wipQueue} issueInfo={task.issueInfo} agentErrorMessage={task.agentErrorMessage} isSubTask={isSubTask} diff --git a/apps/web/components/task/task-switcher-types.ts b/apps/web/components/task/task-switcher-types.ts index 0253cf2807..340768d7e0 100644 --- a/apps/web/components/task/task-switcher-types.ts +++ b/apps/web/components/task/task-switcher-types.ts @@ -1,6 +1,7 @@ import type { ForegroundActivity, TaskSessionState, TaskState } from "@/lib/types/http"; import type { GroupedSidebarList } from "@/lib/sidebar/apply-view"; import type { TaskMoveWorkflow } from "@/components/task/task-move-context-menu"; +import type { WipQueueStatus } from "@/lib/kanban/wip-queue"; export type StepDef = { id: string; @@ -46,6 +47,8 @@ export type TaskSwitcherItem = { prInfo?: { number: number; state: string; aggregateState?: string }; /** Number of prompts currently en-queued for this task (mail badge). */ queuedCount?: number; + /** Destination-resident WIP queue position, separate from queued prompts. */ + wipQueue?: WipQueueStatus; isPRReview?: boolean; isIssueWatch?: boolean; issueInfo?: { url: string; number: number }; diff --git a/apps/web/e2e/tests/kanban/mobile-kanban.spec.ts b/apps/web/e2e/tests/kanban/mobile-kanban.spec.ts index c57c1f9ef5..0fe4ab7458 100644 --- a/apps/web/e2e/tests/kanban/mobile-kanban.spec.ts +++ b/apps/web/e2e/tests/kanban/mobile-kanban.spec.ts @@ -726,8 +726,7 @@ test.describe("Mobile kanban view", () => { workflow_id: workflow.id, workflow_step_id: limitedStep.id, }); - // Seed a legacy over-limit state by applying the limit after task creation; - // creation itself must now reject capacity overflow. + // Seed a legacy over-limit state by applying the limit after task creation. await apiClient.updateWorkflowStep(limitedStep.id, { wip_limit: 1 }); await apiClient.saveUserSettings({ workspace_id: seedData.workspaceId, diff --git a/apps/web/e2e/tests/kanban/mobile-wip-overflow-queue.spec.ts b/apps/web/e2e/tests/kanban/mobile-wip-overflow-queue.spec.ts index 81290956f6..711a3b456c 100644 --- a/apps/web/e2e/tests/kanban/mobile-wip-overflow-queue.spec.ts +++ b/apps/web/e2e/tests/kanban/mobile-wip-overflow-queue.spec.ts @@ -1,5 +1,6 @@ import { expect, test } from "../../fixtures/test-base"; import { MobileKanbanPage } from "../../pages/mobile-kanban-page"; +import { SessionPage } from "../../pages/session-page"; import { useRegularMode } from "../../helpers/regular-mode"; useRegularMode(); @@ -45,3 +46,105 @@ test("shows queued overflow and admitted count in the focused mobile column", as })); expect(pageWidth.scroll).toBeLessThanOrEqual(pageWidth.client); }); + +test("moves through the mobile task switcher and shows touch queue status", async ({ + testPage, + apiClient, + seedData, +}) => { + const workflow = await apiClient.createWorkflow( + seedData.workspaceId, + "Mobile Move Queue Workflow", + ); + const backlogStep = await apiClient.createWorkflowStep(workflow.id, "Backlog", 0, { + is_start_step: true, + }); + const reviewStep = await apiClient.createWorkflowStep(workflow.id, "Review", 1); + const doneStep = await apiClient.createWorkflowStep(workflow.id, "Done", 2); + await apiClient.updateWorkflowStep(reviewStep.id, { wip_limit: 1 }); + await apiClient.saveUserSettings({ + workspace_id: seedData.workspaceId, + workflow_filter_id: workflow.id, + }); + + await apiClient.createTask(seedData.workspaceId, "Mobile Admitted Review", { + workflow_id: workflow.id, + workflow_step_id: reviewStep.id, + }); + const source = await apiClient.seedTask(seedData.workspaceId, "Mobile Moved Queue", { + workflow_id: workflow.id, + workflow_step_id: backlogStep.id, + }); + const anchor = await apiClient.seedTask(seedData.workspaceId, "Mobile Queue Anchor", { + workflow_id: workflow.id, + workflow_step_id: backlogStep.id, + }); + + await testPage.goto(`/t/${anchor.task_id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await testPage.getByTestId("mobile-session-menu").tap(); + const sheet = testPage.getByRole("dialog", { name: "Tasks" }); + const sourceRow = sheet.getByTestId("sidebar-task-item").filter({ + hasText: "Mobile Moved Queue", + }); + await expect(sourceRow).toBeVisible({ timeout: 15_000 }); + await sourceRow.getByRole("button", { name: "Task actions" }).tap(); + await testPage.getByTestId("task-context-move-to").tap(); + await testPage.getByTestId(`task-context-step-${reviewStep.id}`).tap(); + + await expect + .poll(async () => (await apiClient.getTask(source.task_id)).workflow_step_id) + .toBe(reviewStep.id); + await testPage.keyboard.press("Escape"); + await expect(testPage.getByRole("dialog", { name: "Tasks" })).toHaveCount(0); + await testPage.getByTestId("mobile-session-menu").tap(); + const queuedSourceRow = testPage + .getByRole("dialog", { name: "Tasks" }) + .getByTestId("sidebar-task-item") + .filter({ hasText: "Mobile Moved Queue" }); + const queueStatus = queuedSourceRow.getByTestId("sidebar-task-wip-queue"); + await expect(queueStatus).toBeVisible(); + await expect(queueStatus.locator("svg")).toBeVisible(); + await expect(queueStatus).toHaveAttribute("aria-label", "Position 1 of 1 in Review queue"); + await queueStatus.focus(); + await expect(testPage.getByRole("tooltip")).toHaveText("Position 1 of 1 in Review queue"); + await testPage.keyboard.press("Escape"); + + const mobile = new MobileKanbanPage(testPage); + await testPage.goto(`/?workflowId=${workflow.id}`); + await mobile.goto(); + await mobile.boardNavigator.click(); + await testPage.getByTestId("column-tab-1").tap(); + const reviewColumn = testPage.getByTestId(`kanban-column-${reviewStep.id}`); + await expect(reviewColumn.getByTestId("kanban-queued-section")).toBeVisible(); + await expect(mobile.taskCardByTitle("Mobile Moved Queue")).toContainText("Queued for Review"); + + await testPage.goto(`/t/${anchor.task_id}`); + await session.waitForLoad(); + await testPage.getByTestId("mobile-session-menu").tap(); + const admittedRow = testPage + .getByRole("dialog", { name: "Tasks" }) + .getByTestId("sidebar-task-item") + .filter({ hasText: "Mobile Admitted Review" }); + await admittedRow.getByRole("button", { name: "Task actions" }).tap(); + await testPage.getByTestId("task-context-move-to").tap(); + await testPage.getByTestId(`task-context-step-${doneStep.id}`).tap(); + + await expect + .poll(async () => (await apiClient.getTask(source.task_id)).workflow_step_id) + .toBe(reviewStep.id); + await testPage.keyboard.press("Escape"); + await expect(testPage.getByRole("dialog", { name: "Tasks" })).toHaveCount(0); + await testPage.getByTestId("mobile-session-menu").tap(); + await expect( + testPage + .getByRole("dialog", { name: "Tasks" }) + .getByTestId("sidebar-task-item") + .filter({ hasText: "Mobile Moved Queue" }) + .getByTestId("sidebar-task-wip-queue"), + ).toHaveCount(0); + await expect( + await testPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), + ).toBe(true); +}); diff --git a/apps/web/e2e/tests/kanban/wip-overflow-queue.spec.ts b/apps/web/e2e/tests/kanban/wip-overflow-queue.spec.ts index 9be76e13bb..43a15ba989 100644 --- a/apps/web/e2e/tests/kanban/wip-overflow-queue.spec.ts +++ b/apps/web/e2e/tests/kanban/wip-overflow-queue.spec.ts @@ -33,12 +33,14 @@ test("shows same-step WIP overflow and promotes the next queued task", async ({ const kanban = new KanbanPage(testPage); await kanban.goto(); - const reviewColumn = kanban.columnByStepId(reviewStep.id).first(); + const reviewColumn = kanban.columnByStepId(reviewStep.id); await expect(reviewColumn).toContainText("2/2"); await expect(reviewColumn.getByTestId("task-card-title")).toHaveCount(7); + await expect(reviewColumn.getByTestId("kanban-queued-section")).toBeVisible(); await expect(reviewColumn).toContainText("Queued for Review"); await prCapture.screenshot("desktop-visible-queue", { - caption: "Desktop Kanban showing seven visible tasks with two admitted and queued overflow.", + caption: + "Desktop Kanban showing seven visible tasks with two admitted cards and queued overflow.", }); await apiClient.moveTask(tasks[0].id, workflow.id, doneStep.id); @@ -46,3 +48,62 @@ test("shows same-step WIP overflow and promotes the next queued task", async ({ await expect(kanban.taskCardByTitle("Queue Review 3")).toBeVisible(); await expect(kanban.taskCardByTitle("Queue Review 3")).not.toContainText("Queued for Review"); }); + +test("moves into a full step, shows sidebar queue position, and promotes through the UI", async ({ + testPage, + apiClient, + seedData, +}) => { + const workflow = await apiClient.createWorkflow(seedData.workspaceId, "Move Queue Workflow"); + const backlogStep = await apiClient.createWorkflowStep(workflow.id, "Backlog", 0, { + is_start_step: true, + }); + const reviewStep = await apiClient.createWorkflowStep(workflow.id, "Review", 1); + const doneStep = await apiClient.createWorkflowStep(workflow.id, "Done", 2); + await apiClient.updateWorkflowStep(reviewStep.id, { wip_limit: 2 }); + await apiClient.saveUserSettings({ + workspace_id: seedData.workspaceId, + workflow_filter_id: workflow.id, + }); + + const admittedOne = await apiClient.createTask(seedData.workspaceId, "Admitted Review One", { + workflow_id: workflow.id, + workflow_step_id: reviewStep.id, + }); + await apiClient.createTask(seedData.workspaceId, "Admitted Review Two", { + workflow_id: workflow.id, + workflow_step_id: reviewStep.id, + }); + const source = await apiClient.createTask(seedData.workspaceId, "Moved Review Queue", { + workflow_id: workflow.id, + workflow_step_id: backlogStep.id, + }); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + const reviewColumn = kanban.columnByStepId(reviewStep.id); + await expect(reviewColumn).toContainText("2/2"); + + await kanban.moveTaskWithinWorkflow(source.id, reviewStep.id); + const queuedCard = kanban.taskCardInColumn("Moved Review Queue", reviewStep.id); + await expect(queuedCard).toBeVisible({ timeout: 10_000 }); + await expect(queuedCard).toContainText("Queued for Review"); + await expect(reviewColumn.getByTestId("kanban-queued-section")).toBeVisible(); + + const sidebarRow = testPage + .getByTestId("sidebar-task-item") + .filter({ hasText: "Moved Review Queue" }); + await expect(sidebarRow).toBeVisible({ timeout: 10_000 }); + const queueStatus = sidebarRow.getByTestId("sidebar-task-wip-queue"); + await expect(queueStatus).toBeVisible(); + await expect(queueStatus.locator("svg")).toBeVisible(); + await expect(queueStatus).toHaveAttribute("aria-label", "Position 1 of 1 in Review queue"); + await queueStatus.hover(); + await expect(testPage.getByRole("tooltip")).toHaveText("Position 1 of 1 in Review queue"); + await testPage.keyboard.press("Escape"); + + await kanban.moveTaskWithinWorkflow(admittedOne.id, doneStep.id); + await expect(queuedCard).not.toContainText("Queued for Review", { timeout: 15_000 }); + await expect(reviewColumn.getByTestId("kanban-queued-section")).toHaveCount(0); + await expect(sidebarRow.getByTestId("sidebar-task-wip-queue")).toHaveCount(0); +}); diff --git a/apps/web/e2e/tests/workflow/mobile-workflow-settings.spec.ts b/apps/web/e2e/tests/workflow/mobile-workflow-settings.spec.ts index efde164b00..33abcb823c 100644 --- a/apps/web/e2e/tests/workflow/mobile-workflow-settings.spec.ts +++ b/apps/web/e2e/tests/workflow/mobile-workflow-settings.spec.ts @@ -163,4 +163,41 @@ test.describe("Workflow settings on mobile", () => { ); expect(hasDocumentOverflow).toBe(false); }); + + test("shows optional feeder guidance from the WIP info tooltip", async ({ + testPage, + apiClient, + seedData, + }) => { + const workflow = await apiClient.createWorkflow(seedData.workspaceId, "Mobile WIP Guidance"); + await apiClient.createWorkflowStep(workflow.id, "Backlog", 0, { is_start_step: true }); + const reviewStep = await apiClient.createWorkflowStep(workflow.id, "Review", 1); + + const page = new WorkflowSettingsPage(testPage); + await page.goto(seedData.workspaceId); + const card = await page.findWorkflowCard("Mobile WIP Guidance"); + await page.selectStep(card, "Review", true); + + const guidanceHelp = card.getByTestId(`${reviewStep.id}-pull-from-guidance-help`); + await expect(guidanceHelp).toBeVisible(); + await guidanceHelp.tap(); + const guidanceTooltip = testPage.getByRole("tooltip"); + await expect(guidanceTooltip).toContainText( + "No feeder is selected. Direct moves and automatic transitions queue in this destination", + ); + await expect(guidanceTooltip).toContainText( + "WIP limits active work, not visibility. Overflow remains on the board until capacity opens", + ); + await testPage.keyboard.press("Escape"); + + await card.getByTestId(`${reviewStep.id}-pull-from-step-select`).tap(); + await testPage.getByRole("option", { name: "Backlog" }).tap(); + await guidanceHelp.tap(); + await expect(guidanceTooltip).toContainText( + "Optional automatic feeder intake. Destination-queued tasks are admitted first", + ); + await expect(guidanceTooltip).toContainText( + "Direct moves and automatic transitions queue in the destination", + ); + }); }); diff --git a/apps/web/e2e/tests/workflow/workflow-settings.spec.ts b/apps/web/e2e/tests/workflow/workflow-settings.spec.ts index e13fae5615..03b2a8a1bb 100644 --- a/apps/web/e2e/tests/workflow/workflow-settings.spec.ts +++ b/apps/web/e2e/tests/workflow/workflow-settings.spec.ts @@ -323,9 +323,25 @@ test.describe("Workflow settings", () => { await expect(card).toBeVisible(); await page.stepNodeByName(card, "Review").click(); + const guidanceHelp = card.getByTestId(`${reviewStep.id}-pull-from-guidance-help`); + await expect(guidanceHelp).toBeVisible(); + await guidanceHelp.hover(); + const guidanceTooltip = testPage.getByRole("tooltip"); + await expect(guidanceTooltip).toContainText( + "No feeder is selected. Direct moves and automatic transitions queue in this destination", + ); + await expect(guidanceTooltip).toContainText( + "WIP limits active work, not visibility. Overflow remains on the board until capacity opens", + ); + await testPage.keyboard.press("Escape"); + await card.getByTestId(`${reviewStep.id}-wip-limit-input`).fill("2"); await card.getByTestId(`${reviewStep.id}-pull-from-step-select`).click(); await testPage.getByRole("option", { name: "Backlog" }).click(); + await guidanceHelp.hover(); + await expect(guidanceTooltip).toContainText( + "Optional automatic feeder intake. Destination-queued tasks are admitted first", + ); expect( (await apiClient.listWorkflowSteps(workflow.id)).steps.find( diff --git a/apps/web/hooks/domains/kanban/use-workspace-sidebar-tasks.test.ts b/apps/web/hooks/domains/kanban/use-workspace-sidebar-tasks.test.ts index ec76efc715..db38c1defe 100644 --- a/apps/web/hooks/domains/kanban/use-workspace-sidebar-tasks.test.ts +++ b/apps/web/hooks/domains/kanban/use-workspace-sidebar-tasks.test.ts @@ -7,7 +7,18 @@ type Snapshot = { workflowId: string; workflowName: string; steps: Array<{ id: string; title: string; color: string; position: number }>; - tasks: Array<{ id: string; workflowStepId: string; title: string; position: number }>; + tasks: Array<{ + id: string; + workflowStepId: string; + title: string; + position: number; + queuedForStepId?: string; + wipAdmitted?: boolean; + priority?: string; + queuedAt?: string; + createdAt?: string; + isArchived?: boolean; + }>; }; type MockState = { @@ -48,16 +59,19 @@ function setMockState(patch: Partial) { }; } +const defaultStepId = "step-1"; +const defaultStepColor = "bg-blue-500"; + function makeSnapshot( workflowId: string, workflowName: string, taskIds: string[], - stepId = "step-1", + stepId = defaultStepId, ): Snapshot { return { workflowId, workflowName, - steps: [{ id: stepId, title: "Step 1", color: "bg-blue-500", position: 0 }], + steps: [{ id: stepId, title: "Step 1", color: defaultStepColor, position: 0 }], tasks: taskIds.map((id, i) => ({ id, workflowStepId: stepId, title: id, position: i })), }; } @@ -156,8 +170,8 @@ describe("useWorkspaceSidebarTasks", () => { kanbanMulti: { snapshots: {}, isLoading: false }, kanban: { workflowId: "wf-A", - tasks: [{ id: "t-a1", workflowStepId: "step-1", title: "A1", position: 0 }], - steps: [{ id: "step-1", title: "Step 1", color: "bg-blue-500", position: 0 }], + tasks: [{ id: "t-a1", workflowStepId: defaultStepId, title: "A1", position: 0 }], + steps: [{ id: defaultStepId, title: "Step 1", color: defaultStepColor, position: 0 }], }, }); @@ -167,6 +181,109 @@ describe("useWorkspaceSidebarTasks", () => { }); }); +describe("useWorkspaceSidebarTasks WIP queue", () => { + beforeEach(() => { + mockState = { + kanbanMulti: { snapshots: {}, isLoading: false }, + workflows: { items: [] }, + kanban: { workflowId: null, tasks: [], steps: [] }, + }; + }); + + it("derives destination queue positions for sidebar consumers", () => { + setMockState({ + workflows: { items: [{ id: "wf-A", workspaceId: "ws-1", name: "Alpha" }] }, + kanbanMulti: { + snapshots: { + "wf-A": { + workflowId: "wf-A", + workflowName: "Alpha", + steps: [{ id: defaultStepId, title: "Review", color: defaultStepColor, position: 0 }], + tasks: [ + { + id: "queued-later", + workflowStepId: defaultStepId, + queuedForStepId: defaultStepId, + wipAdmitted: false, + priority: "low", + queuedAt: "2026-08-12T00:02:00Z", + title: "Later", + position: 4, + }, + { + id: "queued-first", + workflowStepId: defaultStepId, + queuedForStepId: defaultStepId, + wipAdmitted: false, + priority: "high", + queuedAt: "2026-08-12T00:01:00Z", + title: "First", + position: 3, + }, + ], + }, + }, + isLoading: false, + }, + }); + + const { result } = renderHook(() => useWorkspaceSidebarTasks("ws-1")); + expect(result.current.wipQueueByTaskId.get("queued-first")).toEqual({ + position: 1, + total: 2, + destinationTitle: "Review", + }); + expect(result.current.wipQueueByTaskId.get("queued-later")).toEqual({ + position: 2, + total: 2, + destinationTitle: "Review", + }); + }); + + it("excludes archived tasks from active queue positions", () => { + setMockState({ + workflows: { items: [{ id: "wf-A", workspaceId: "ws-1", name: "Alpha" }] }, + kanbanMulti: { + snapshots: { + "wf-A": { + workflowId: "wf-A", + workflowName: "Alpha", + steps: [{ id: defaultStepId, title: "Review", color: defaultStepColor, position: 0 }], + tasks: [ + { + id: "archived-queued", + workflowStepId: defaultStepId, + queuedForStepId: defaultStepId, + wipAdmitted: false, + isArchived: true, + position: 0, + title: "Archived", + }, + { + id: "active-queued", + workflowStepId: defaultStepId, + queuedForStepId: defaultStepId, + wipAdmitted: false, + position: 1, + title: "Active", + }, + ], + }, + }, + isLoading: false, + }, + }); + + const { result } = renderHook(() => useWorkspaceSidebarTasks("ws-1")); + expect(result.current.wipQueueByTaskId.get("active-queued")).toEqual({ + position: 1, + total: 1, + destinationTitle: "Review", + }); + expect(result.current.wipQueueByTaskId.has("archived-queued")).toBe(false); + }); +}); + describe("mergeSidebarArchivedTasks", () => { it("merges only the current workspace's archived tasks and deduplicates IDs", () => { const active = [{ id: "same", _workflowId: "wf-1" }]; diff --git a/apps/web/hooks/domains/kanban/use-workspace-sidebar-tasks.ts b/apps/web/hooks/domains/kanban/use-workspace-sidebar-tasks.ts index b758ca534a..ff077e28d8 100644 --- a/apps/web/hooks/domains/kanban/use-workspace-sidebar-tasks.ts +++ b/apps/web/hooks/domains/kanban/use-workspace-sidebar-tasks.ts @@ -9,9 +9,11 @@ import { } from "@/components/task/task-session-sidebar-aggregate"; import type { TaskMoveWorkflow } from "@/components/task/task-move-context-menu"; import type { KanbanState } from "@/lib/state/slices/kanban/types"; +import { getDestinationQueue, type WipQueueStatus } from "@/lib/kanban/wip-queue"; export type WorkspaceSidebarTasksResult = AggregatedSidebarTasks & { workflows: TaskMoveWorkflow[]; + wipQueueByTaskId: Map; isLoading: boolean; archivedError: string | null; retryArchivedTasks: () => void; @@ -119,6 +121,32 @@ export function useWorkspaceSidebarTasks(workspaceId: string | null): WorkspaceS ); }, [aggregated.allTasks, archived.tasks, needsArchivedTasks, workspaceId]); + const wipQueueByTaskId = useMemo(() => { + const result = new Map(); + const activeTasks = allTasks.filter((task) => task.isArchived !== true); + const destinationStepIds = new Set( + activeTasks + .map((task) => task.queuedForStepId) + .filter((stepId): stepId is string => !!stepId), + ); + for (const stepId of destinationStepIds) { + for (const entry of getDestinationQueue(activeTasks, stepId)) { + const task = entry.task; + const stepTitle = + aggregated.stepsByWorkflowId[task._workflowId]?.find((step) => step.id === stepId) + ?.title ?? + aggregated.allSteps.find((step) => step.id === stepId)?.title ?? + stepId; + result.set(task.id, { + position: entry.position, + total: entry.total, + destinationTitle: stepTitle, + }); + } + } + return result; + }, [allTasks, aggregated.allSteps, aggregated.stepsByWorkflowId]); + const workspaceWorkflows = useMemo( () => filteredWorkflows.map((w) => ({ id: w.id, name: w.name, hidden: w.hidden })), [filteredWorkflows], @@ -132,6 +160,7 @@ export function useWorkspaceSidebarTasks(workspaceId: string | null): WorkspaceS return { ...aggregated, allTasks, + wipQueueByTaskId, workflows: workspaceWorkflows, isLoading, archivedError: archived.error, diff --git a/apps/web/lib/kanban/map-task.ts b/apps/web/lib/kanban/map-task.ts index 53132dbd11..695e941fa3 100644 --- a/apps/web/lib/kanban/map-task.ts +++ b/apps/web/lib/kanban/map-task.ts @@ -34,6 +34,7 @@ export type TaskLike = { autopilot?: boolean; position?: number; state?: TaskState; + priority?: string | number; repositories?: Array<{ id?: string; repository_id: string; @@ -177,6 +178,7 @@ export function toKanbanTask(source: TaskLike): KanbanTask { title: source.title ?? "", description: source.description ?? undefined, autopilot: source.autopilot, + priority: source.priority, position: source.position ?? 0, state: source.state, repositoryId: pickRepositoryId(source), diff --git a/apps/web/lib/kanban/wip-queue.test.ts b/apps/web/lib/kanban/wip-queue.test.ts new file mode 100644 index 0000000000..e6227406be --- /dev/null +++ b/apps/web/lib/kanban/wip-queue.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { + getDestinationQueue, + getWipQueueStatus, + partitionWipTasks, + compareWipQueueTasks, + type WipQueueTask, +} from "./wip-queue"; + +function task(overrides: Partial = {}): WipQueueTask { + return { + id: "task", + workflowStepId: "review", + queuedForStepId: "review", + wipAdmitted: false, + position: 0, + priority: "medium", + queuedAt: "2026-08-12T10:00:00Z", + createdAt: "2026-08-12T09:00:00Z", + ...overrides, + }; +} + +describe("wip queue helper", () => { + it("returns only destination-resident queued tasks in backend order", () => { + const tasks = [ + task({ id: "low", priority: "low" }), + task({ id: "critical", priority: "critical" }), + task({ id: "admitted", wipAdmitted: true, queuedForStepId: undefined }), + task({ id: "feeder", workflowStepId: "intake" }), + ]; + + expect(getDestinationQueue(tasks, "review").map((entry) => entry.task.id)).toEqual([ + "critical", + "low", + ]); + }); + + it("uses position, priority, queue time, creation time, then id", () => { + const tasks = [ + task({ id: "z", position: 2 }), + task({ id: "a", position: 1, priority: "low" }), + task({ id: "critical", position: 1, priority: "critical" }), + task({ id: "early", position: 1, priority: "medium", queuedAt: "2026-08-12T08:00:00Z" }), + ]; + + expect(getDestinationQueue(tasks, "review").map((entry) => entry.task.id)).toEqual([ + "critical", + "early", + "a", + "z", + ]); + }); + + it("returns one-based queue positions and keeps admitted counts separate", () => { + const tasks = [ + task({ id: "queued-a" }), + task({ id: "queued-b", position: 1 }), + task({ id: "admitted", wipAdmitted: true, queuedForStepId: undefined }), + ]; + const partition = partitionWipTasks(tasks, "review"); + + expect(partition.admitted.map((item) => item.id)).toEqual(["admitted"]); + expect(partition.queued.map((item) => item.id)).toEqual(["queued-a", "queued-b"]); + expect(getWipQueueStatus(tasks[1], tasks, "review", "Review")).toEqual({ + position: 2, + total: 2, + destinationTitle: "Review", + }); + expect(getWipQueueStatus(tasks[2], tasks, "review", "Review")).toBeUndefined(); + }); + + it("keeps partitioned queued tasks in the same sorted order as their positions", () => { + const tasks = [ + task({ id: "low", priority: "low", position: 2 }), + task({ id: "critical", priority: "critical", position: 1 }), + task({ id: "high", priority: "high", position: 1 }), + ]; + + expect(partitionWipTasks(tasks, "review").queued.map((item) => item.id)).toEqual([ + "critical", + "high", + "low", + ]); + }); + + it("orders missing timestamps deterministically without producing NaN", () => { + const left = task({ id: "left", queuedAt: null, createdAt: null }); + const right = task({ id: "right", queuedAt: "not-a-date", createdAt: "not-a-date" }); + + expect(compareWipQueueTasks(left, right)).toBeLessThan(0); + expect(Number.isNaN(compareWipQueueTasks(left, right))).toBe(false); + }); +}); diff --git a/apps/web/lib/kanban/wip-queue.ts b/apps/web/lib/kanban/wip-queue.ts new file mode 100644 index 0000000000..8b2dea9401 --- /dev/null +++ b/apps/web/lib/kanban/wip-queue.ts @@ -0,0 +1,108 @@ +export type WipQueueTask = { + id: string; + workflowStepId: string; + queuedForStepId?: string | null; + wipAdmitted?: boolean | null; + position?: number | null; + priority?: string | number | null; + queuedAt?: string | null; + createdAt?: string | null; +}; + +export type WipQueueEntry = { + task: T; + position: number; + total: number; +}; + +export type WipQueueStatus = { + position: number; + total: number; + destinationTitle: string; +}; + +function priorityRank(priority: WipQueueTask["priority"]): number { + if (typeof priority === "number" && Number.isFinite(priority)) return priority; + switch (priority) { + case "critical": + return 0; + case "high": + return 1; + case "medium": + return 2; + case "low": + return 3; + default: + return 4; + } +} + +function timestamp(value: string | null | undefined): number { + if (!value) return Number.POSITIVE_INFINITY; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : Number.POSITIVE_INFINITY; +} + +function compareNumbers(left: number, right: number): number { + if (left < right) return -1; + if (left > right) return 1; + return 0; +} + +export function compareWipQueueTasks(left: WipQueueTask, right: WipQueueTask): number { + const position = (left.position ?? 0) - (right.position ?? 0); + if (position !== 0) return position; + + const priority = priorityRank(left.priority) - priorityRank(right.priority); + if (priority !== 0) return priority; + + const queuedAt = compareNumbers(timestamp(left.queuedAt), timestamp(right.queuedAt)); + if (queuedAt !== 0) return queuedAt; + + const createdAt = compareNumbers(timestamp(left.createdAt), timestamp(right.createdAt)); + if (createdAt !== 0) return createdAt; + + return left.id.localeCompare(right.id); +} + +function isDestinationQueued(task: WipQueueTask, destinationStepId: string): boolean { + return ( + task.workflowStepId === destinationStepId && + task.queuedForStepId === destinationStepId && + task.wipAdmitted !== true + ); +} + +export function getDestinationQueue( + tasks: T[], + destinationStepId: string, +): WipQueueEntry[] { + const queued = tasks.filter((task) => isDestinationQueued(task, destinationStepId)); + queued.sort(compareWipQueueTasks); + return queued.map((task, index) => ({ task, position: index + 1, total: queued.length })); +} + +export function partitionWipTasks( + tasks: T[], + destinationStepId: string, +): { admitted: T[]; queued: T[] } { + const queuedEntries = getDestinationQueue(tasks, destinationStepId); + const queuedIds = new Set(queuedEntries.map(({ task }) => task.id)); + return { + admitted: tasks.filter((task) => !queuedIds.has(task.id)), + queued: queuedEntries.map(({ task }) => task), + }; +} + +export function getWipQueueStatus( + task: T, + tasks: T[], + destinationStepId: string, + destinationTitle: string, +): WipQueueStatus | undefined { + const entry = getDestinationQueue(tasks, destinationStepId).find( + ({ task: candidate }) => candidate.id === task.id, + ); + if (!entry) return undefined; + return { position: entry.position, total: entry.total, destinationTitle }; +} diff --git a/apps/web/lib/state/slices/kanban/types.ts b/apps/web/lib/state/slices/kanban/types.ts index f1ef620c87..94c46d8be9 100644 --- a/apps/web/lib/state/slices/kanban/types.ts +++ b/apps/web/lib/state/slices/kanban/types.ts @@ -70,6 +70,7 @@ export type KanbanState = { title: string; description?: string; autopilot?: boolean; + priority?: string | number; position: number; state?: TaskStatus; /** Primary repository id (lowest position). Kept for backwards compat. */ diff --git a/apps/web/src/locales/en/kanban.json b/apps/web/src/locales/en/kanban.json index 5c65632965..da868030ee 100644 --- a/apps/web/src/locales/en/kanban.json +++ b/apps/web/src/locales/en/kanban.json @@ -68,7 +68,10 @@ "pipeline": "Pipeline", "previewPanel": "Preview Panel", "previousStep": "Previous step", + "queuedCount_one": "{{count}} queued", + "queuedCount_other": "{{count}} queued", "queuedForStep": "Queued for {{step}}", + "queuedSection": "Queued", "repository": "Repository", "searchPlaceholder": "Search...", "searchSection": "Search", diff --git a/apps/web/src/locales/en/sidebar.json b/apps/web/src/locales/en/sidebar.json index 098df6a4d4..baacdffbab 100644 --- a/apps/web/src/locales/en/sidebar.json +++ b/apps/web/src/locales/en/sidebar.json @@ -70,6 +70,7 @@ "projects": "Projects", "queuedPromptCount_one": "{{count}} queued prompt", "queuedPromptCount_other": "{{count}} queued prompts", + "wipQueuePosition": "Position {{position}} of {{total}} in {{step}} queue", "quickChat": "Quick Chat", "quickTerminal": "Quick terminal", "quickChatTerminals": "Terminals", diff --git a/apps/web/src/locales/en/workflows.json b/apps/web/src/locales/en/workflows.json index a211a6d7be..e9daab441f 100644 --- a/apps/web/src/locales/en/workflows.json +++ b/apps/web/src/locales/en/workflows.json @@ -144,6 +144,8 @@ "promptSourceTaskDescription": "\"{{stepName}}\" has no step prompt, so re-entering it sends the task description.", "pullFrom": "Pull from", "pullFromHelp": "Optional feeder step to pull queued work from when this step has capacity.", + "pullFromGuidanceWithFeeder": "Optional automatic feeder intake. Destination-queued tasks are admitted first, then Kandev pulls eligible work from {{feeder}} when capacity opens. Direct moves and automatic transitions queue in the destination and do not require a feeder. New tasks targeting a full step use this feeder.", + "pullFromGuidanceWithoutFeeder": "No feeder is selected. Direct moves and automatic transitions queue in this destination when it is full. New tasks also stay visible in this queue until capacity opens.", "readOnlyManagedBySync": "Read-only; managed by workflow sync", "readOnlyManagedByImproveKandev": "Read-only; managed by Improve Kandev", "remove": "Remove", diff --git a/apps/web/src/locales/pseudo/kanban.json b/apps/web/src/locales/pseudo/kanban.json index 6693405c52..2cfbe851c9 100644 --- a/apps/web/src/locales/pseudo/kanban.json +++ b/apps/web/src/locales/pseudo/kanban.json @@ -68,7 +68,10 @@ "pipeline": "Ƥĩƥēĺĩńē", "previewPanel": "Ƥŕēvĩēŵ Ƥàńēĺ", "previousStep": "Ƥŕēvĩōũś śţēƥ", + "queuedCount_one": "{{count}} qũēũēď", + "queuedCount_other": "{{count}} qũēũēď", "queuedForStep": "Qũēũēď ƒōŕ {{step}}", + "queuedSection": "Qũēũēď", "repository": "Ŕēƥōśĩţōŕŷ", "searchPlaceholder": "Śēàŕćĥ...", "searchSection": "Śēàŕćĥ", diff --git a/apps/web/src/locales/pseudo/sidebar.json b/apps/web/src/locales/pseudo/sidebar.json index 9ba38cded2..bb83e31870 100644 --- a/apps/web/src/locales/pseudo/sidebar.json +++ b/apps/web/src/locales/pseudo/sidebar.json @@ -70,6 +70,7 @@ "projects": "Ƥŕōĵēćţś", "queuedPromptCount_one": "{{count}} qũēũēď ƥŕōḿƥţ", "queuedPromptCount_other": "{{count}} qũēũēď ƥŕōḿƥţś", + "wipQueuePosition": "Ƥōśĩţĩōń {{position}} ōƒ {{total}} ĩń {{step}} qũēũē", "quickChat": "Qũĩćķ Ćĥàţ", "quickTerminal": "Qũĩćķ ţēŕḿĩńàĺ", "quickChatTerminals": "Ţēŕḿĩńàĺś", diff --git a/apps/web/src/locales/pseudo/workflows.json b/apps/web/src/locales/pseudo/workflows.json index 597b6cb423..998cd11fa6 100644 --- a/apps/web/src/locales/pseudo/workflows.json +++ b/apps/web/src/locales/pseudo/workflows.json @@ -144,6 +144,8 @@ "promptSourceTaskDescription": "\"{{stepName}}\" ĥàś ńō śţēƥ ƥŕōḿƥţ, śō ŕē-ēńţēŕĩńĝ ĩţ śēńďś ţĥē ţàśķ ďēśćŕĩƥţĩōń.", "pullFrom": "Ƥũĺĺ ƒŕōḿ", "pullFromHelp": "Ōƥţĩōńàĺ ƒēēďēŕ śţēƥ ţō ƥũĺĺ qũēũēď ŵōŕķ ƒŕōḿ ŵĥēń ţĥĩś śţēƥ ĥàś ćàƥàćĩţŷ.", + "pullFromGuidanceWithFeeder": "Ōƥţĩōńàĺ àũţōḿàţĩć ƒēēďēŕ ĩńţàķē. Đēśţĩńàţĩōń-qũēũēď ţàśķś àŕē àďḿĩţţēď ƒĩŕśţ, ţĥēń Kàńďēv ƥũĺĺś ēĺĩɠĩƀĺē ŵōŕķ ƒŕōḿ {{feeder}} ŵĥēń ćàƥàćĩţŷ ōƥēńś. Đĩŕēćţ ḿōvēś àńď àũţōḿàţĩć ţŕàńśĩţĩōńś qũēũē ĩń ţĥē ďēśţĩńàţĩōń àńď ďō ńōţ ŕēqũĩŕē à ƒēēďēŕ. Ńēŵ ţàśķś ţàŕɠēţĩńɠ à ƒũĺĺ śţēƥ ũśē ţĥĩś ƒēēďēŕ.", + "pullFromGuidanceWithoutFeeder": "Ńō ƒēēďēŕ ĩś śēĺēćţēď. Đĩŕēćţ ḿōvēś àńď àũţōḿàţĩć ţŕàńśĩţĩōńś qũēũē ĩń ţĥĩś ďēśţĩńàţĩōń ŵĥēń ĩţ ĩś ƒũĺĺ. Ńēŵ ţàśķś àĺśō śţàŷ vĩśĩƀĺē ĩń ţĥĩś qũēũē ũńţĩĺ ćàƥàćĩţŷ ōƥēńś.", "readOnlyManagedBySync": "Ŕēàď-ōńĺŷ; ḿàńàĝēď ƀŷ ŵōŕķƒĺōŵ śŷńć", "readOnlyManagedByImproveKandev": "Ŕēàď-ōńĺŷ; ḿàńàĝēď ƀŷ Ĩḿƥŕōvē Ķàńďēv", "remove": "Ŕēḿōvē", diff --git a/apps/web/src/locales/pt-pt/kanban.json b/apps/web/src/locales/pt-pt/kanban.json index 0d429b4447..1efd4dc780 100644 --- a/apps/web/src/locales/pt-pt/kanban.json +++ b/apps/web/src/locales/pt-pt/kanban.json @@ -63,7 +63,10 @@ "pipeline": "Pipeline", "previewPanel": "Painel de pré-visualização", "previousStep": "Passo anterior", + "queuedCount_one": "{{count}} em fila", + "queuedCount_other": "{{count}} em fila", "queuedForStep": "Em fila para {{step}}", + "queuedSection": "Em fila", "repository": "Repositório", "searchPlaceholder": "Pesquisar...", "searchSection": "Pesquisar", diff --git a/apps/web/src/locales/pt-pt/sidebar.json b/apps/web/src/locales/pt-pt/sidebar.json index f5720c0e92..be0a6bcc47 100644 --- a/apps/web/src/locales/pt-pt/sidebar.json +++ b/apps/web/src/locales/pt-pt/sidebar.json @@ -68,6 +68,7 @@ "projects": "Projetos", "queuedPromptCount_one": "{{count}} prompt em fila", "queuedPromptCount_other": "{{count}} prompts em fila", + "wipQueuePosition": "Posição {{position}} de {{total}} na fila de {{step}}", "quickChat": "Chat rápido", "quickTerminal": "Terminal rápido", "quickChatTerminals": "Terminais", diff --git a/apps/web/src/locales/pt-pt/workflows.json b/apps/web/src/locales/pt-pt/workflows.json index 8935bdb58c..d1184a3a5f 100644 --- a/apps/web/src/locales/pt-pt/workflows.json +++ b/apps/web/src/locales/pt-pt/workflows.json @@ -141,6 +141,8 @@ "promptSourceTaskDescription": "\"{{stepName}}\" não tem prompt de passo, pelo que voltar a entrar envia a descrição da tarefa.", "pullFrom": "Puxar de", "pullFromHelp": "Passo alimentador opcional do qual puxar trabalho em fila quando este passo tiver capacidade.", + "pullFromGuidanceWithFeeder": "Entrada automática opcional através de um alimentador. As tarefas em fila no destino são admitidas primeiro; depois, o Kandev puxa trabalho elegível de {{feeder}} quando surge capacidade. Movimentos diretos e transições automáticas entram na fila do destino e não precisam de alimentador. Tarefas novas para um passo cheio usam este alimentador.", + "pullFromGuidanceWithoutFeeder": "Não está selecionado nenhum alimentador. Movimentos diretos e transições automáticas entram na fila deste destino quando está cheio. As tarefas novas também permanecem visíveis nesta fila até surgir capacidade.", "readOnlyManagedBySync": "Só de leitura; gerido pela sincronização de fluxos de trabalho", "readOnlyManagedByImproveKandev": "Só de leitura; gerido pelo Melhorar o Kandev", "remove": "Remover", diff --git a/apps/web/src/locales/zh-cn/kanban.json b/apps/web/src/locales/zh-cn/kanban.json index 7b65a85ef6..31993a5f42 100644 --- a/apps/web/src/locales/zh-cn/kanban.json +++ b/apps/web/src/locales/zh-cn/kanban.json @@ -63,7 +63,10 @@ "pipeline": "流水线", "previewPanel": "预览面板", "previousStep": "上一步", + "queuedCount_one": "{{count}} 个排队任务", + "queuedCount_other": "{{count}} 个排队任务", "queuedForStep": "已排队等待 {{step}}", + "queuedSection": "排队中", "repository": "仓库", "searchPlaceholder": "搜索…", "searchSection": "搜索", diff --git a/apps/web/src/locales/zh-cn/sidebar.json b/apps/web/src/locales/zh-cn/sidebar.json index d54802c3a0..68646e3e1d 100644 --- a/apps/web/src/locales/zh-cn/sidebar.json +++ b/apps/web/src/locales/zh-cn/sidebar.json @@ -70,6 +70,7 @@ "projects": "项目", "queuedPromptCount_one": "{{count}} 条排队提示", "queuedPromptCount_other": "{{count}} 条排队提示", + "wipQueuePosition": "{{step}} 队列中的第 {{position}} 项,共 {{total}} 项", "quickChat": "快速聊天", "quickTerminal": "快速终端", "quickChatTerminals": "终端", diff --git a/apps/web/src/locales/zh-cn/workflows.json b/apps/web/src/locales/zh-cn/workflows.json index 55fb460d28..801b064c23 100644 --- a/apps/web/src/locales/zh-cn/workflows.json +++ b/apps/web/src/locales/zh-cn/workflows.json @@ -140,6 +140,8 @@ "promptSourceTaskDescription": "“{{stepName}}”没有步骤提示词,因此重新进入时会发送任务描述。", "pullFrom": "拉取自", "pullFromHelp": "当此步骤有容量时,可选的馈送步骤用于拉取排队中的工作。", + "pullFromGuidanceWithFeeder": "可选的自动馈送入口。目标队列中的任务优先获得准入;有容量时,Kandev 再从 {{feeder}} 拉取符合条件的工作。直接移动和自动转换会在目标步骤排队,不需要馈送步骤。新任务在目标步骤已满时使用此馈送步骤。", + "pullFromGuidanceWithoutFeeder": "未选择馈送步骤。目标步骤已满时,直接移动和自动转换会在此目标队列中排队。新任务也会留在此队列中,直到有可用容量。", "readOnlyManagedBySync": "只读; 由工作流同步管理", "readOnlyManagedByImproveKandev": "只读; 由 Improve Kandev 管理", "remove": "移除", diff --git a/docs/decisions/2026-07-28-visible-wip-overflow-queues.md b/docs/decisions/2026-07-28-visible-wip-overflow-queues.md index 00f95a7621..056076b40c 100644 --- a/docs/decisions/2026-07-28-visible-wip-overflow-queues.md +++ b/docs/decisions/2026-07-28-visible-wip-overflow-queues.md @@ -47,6 +47,10 @@ pull behavior. Manual moves and workflow-engine transitions into a full step continue to return capacity conflicts. This decision changes creation overflow only. +That move rule is replaced by +[ADR-2026-08-12-queue-task-moves-at-wip-capacity](2026-08-12-queue-task-moves-at-wip-capacity.md). +The creation and feeder rules in this decision remain accepted. + ## Consequences - All accepted integration work is immediately visible and durable. diff --git a/docs/decisions/2026-08-12-queue-task-moves-at-wip-capacity.md b/docs/decisions/2026-08-12-queue-task-moves-at-wip-capacity.md new file mode 100644 index 0000000000..1205cfe31c --- /dev/null +++ b/docs/decisions/2026-08-12-queue-task-moves-at-wip-capacity.md @@ -0,0 +1,59 @@ +# ADR-2026-08-12-queue-task-moves-at-wip-capacity: Queue Task Moves at WIP Capacity + +**Status:** accepted +**Date:** 2026-08-12 +**Area:** backend, frontend, protocol, workflow + +## Context + +WIP limits separate visible work from admitted work during task creation. +Explicit task moves still return a conflict when the destination has no +capacity. Users cannot signal that a task is ready for a limited step while +another task occupies its WIP slot. + +The existing feeder mechanism does not provide this manual gate. Untagged +feeder tasks are eligible for automatic intake without an explicit move. + +## Decision + +All task moves use destination admission. A move to a full limited step commits +the task in that destination with `wip_admitted = false`, +`queued_for_step_id` set to the destination, and `queued_at` set. + +The move does not route through `pull_from_step_id`. Feeders remain optional +automatic intake sources. Destination-resident queued tasks have admission +priority over feeder-resident candidates. + +The source step exits when the queued move commits. Destination entry behavior +runs only after admission. The Kanban column shows active and queued areas. The +task sidebar shows the queue position for destination-resident queued tasks. + +This decision replaces the capacity-conflict rule for explicit, bulk, approval, +MCP, and workflow-engine moves in ADR-2026-07-28-visible-wip-overflow-queues. +The remaining creation, persistence, and feeder rules stay accepted. + +## Consequences + +- Users can approve work for a limited step before capacity opens. +- Bulk and automated task moves no longer fail only because the destination is + full. +- Queue metadata and existing promotion transactions remain the persistence + boundary. No new task column is necessary. +- The orchestrator must separate source exit from destination entry for a + queued move. +- Queue position is derived from the same deterministic order that controls + destination admission. +- Workflows that need automatic feeder intake keep `pull_from_step_id`. + Workflows that need a manual gate omit it and use destination queue moves. + +## Alternatives Considered + +- **Keep move conflicts and add a manual feeder mode.** Rejected because it + adds step configuration and preserves two locations for one destination + queue. +- **Change every feeder to manual-only intake.** Rejected because it silently + changes existing automatic workflows. +- **Keep queued tasks in the source step.** Rejected because the board would + not show that the move succeeded or group queued work with its destination. +- **Add a hidden global queue.** Rejected because users could not inspect the + queue in the workflow that owns it. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index 52d3ee9a17..83b9cba4b6 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -90,6 +90,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 2026-07-27-spa-failure-containment-and-deployment-recovery | [SPA Failure Containment and Deployment Recovery](2026-07-27-spa-failure-containment-and-deployment-recovery.md) | accepted | frontend | 2026-07-27 | | 2026-07-27-legacy-add-branch-live-rescan | [Preserve Live Rescan for Legacy Add Branch](2026-07-27-legacy-add-branch-live-rescan.md) | accepted | backend, protocol | 2026-07-27 | | 2026-07-28-visible-wip-overflow-queues | [Separate Visible Queueing From WIP Admission](2026-07-28-visible-wip-overflow-queues.md) | proposed | backend, frontend, protocol, workflow | 2026-07-28 | +| 2026-08-12-queue-task-moves-at-wip-capacity | [Queue Task Moves at WIP Capacity](2026-08-12-queue-task-moves-at-wip-capacity.md) | accepted | backend, frontend, protocol, workflow | 2026-08-12 | | 2026-07-28-coarse-running-busy-signal | [Restore Coarse Running Prompt Admission](2026-07-28-coarse-running-busy-signal.md) | accepted | backend, frontend, protocol | 2026-07-28 | | 2026-07-29-agent-stall-user-controlled-recovery | [Keep Agent Stall Recovery User Controlled](2026-07-29-agent-stall-user-controlled-recovery.md) | accepted | backend, frontend, protocol | 2026-07-29 | | 2026-07-29-quarantine-retention-override | [Make Quarantine Retention Overridable but Visible](2026-07-29-quarantine-retention-override.md) | accepted | backend, frontend | 2026-07-29 | diff --git a/docs/plans/queued-task-moves/plan.md b/docs/plans/queued-task-moves/plan.md new file mode 100644 index 0000000000..937fabf68d --- /dev/null +++ b/docs/plans/queued-task-moves/plan.md @@ -0,0 +1,280 @@ +--- +spec: docs/specs/tasks/wip-limit-pull-system.md +created: 2026-08-12 +status: completed +--- + +# Implementation Plan: Queued Task Moves + +## Overview + +Make a task move express readiness for the selected workflow step even when +that step has reached its WIP limit. The move succeeds, places the task in the +destination column, and marks it queued without consuming WIP. Capacity later +admits destination-resident queued tasks before pulling work from a configured +feeder. + +The UI makes this state explicit. Limited Kanban columns show separate active +and queued areas, and the task sidebar shows a distinct WIP queue icon. Hovering +or focusing the icon discloses the task's one-based queue position, total, and +destination step on desktop and touch layouts. + +No new persistence fields are required. The implementation reuses +`wip_admitted`, `queued_for_step_id`, and `queued_at`, but changes task moves +from capacity rejection to atomic destination admission. + +## Confirmed Current Behavior + +- `task/service.MoveTaskWithOptions` validates target WIP before the update and + returns a typed capacity conflict when the destination is full. +- The repository has atomic capacity updates and queued-task promotion, but no + atomic move operation that chooses admitted or queued placement. +- Bulk moves prevalidate capacity, so the whole request fails instead of + admitting available tasks and queueing the remainder. +- The workflow engine has a separate transition store path that also uses the + capacity-rejecting update. +- `task.moved` immediately runs both source `on_exit` and destination + `on_enter`. `task.queue_promoted` currently only handles auto-start, so it + cannot complete deferred destination entry for a queued move. +- Same-step overflow cards are visible, but the Kanban column does not separate + admitted and queued cards. +- The sidebar's existing queued count represents pending agent prompts. It is + unrelated to WIP admission and must remain visually and semantically + distinct. + +## Behavioral Contract + +- Moving to an unlimited or non-full limited step admits the task immediately. +- Moving to a full limited step succeeds and places the task in that step with + `wip_admitted = false`, `queued_for_step_id` equal to the step, and a durable + `queued_at` value. +- A move never redirects through `pull_from_step_id`. Feeders keep their + existing automatic-intake behavior. +- Destination-resident queued tasks are promoted before feeder-resident work. + Queue position uses the same deterministic ordering as promotion. +- A queued move runs source exit once at move time. Destination entry, + terminal-state effects, context reset, plan-mode behavior, and auto-start run + once after promotion. +- Moving an already queued task cancels its old queue/deferred-launch state and + applies admission at the new destination. +- Stepper, drag/drop, move menu, bulk, HTTP, WebSocket, MCP, approval, and + workflow-engine transition paths share the contract. +- An automatic workflow transition into a full step commits the task in that + destination queue. Destination entry waits for promotion. +- The move response and task events expose committed admission metadata. + +## Backend Design + +### Atomic move admission + +Add one repository operation analogous to creation admission. In a single +write transaction it locks/checks the target step, updates the task as admitted +when capacity exists, or updates it as destination-resident queued when full. +It returns the committed placement so the service does not infer the result +from a stale capacity read. + +For unlimited steps, preserve the ordinary update path and normalize the task +to admitted with no queue destination. For limited steps, count only admitted, +active, non-ephemeral occupants. A race for the final slot admits one task and +queues the other; neither request returns a WIP conflict. + +`MoveTaskWithOptions` resolves/validates the destination, clears obsolete queue +and deferred-launch state, applies the atomic admission update, synchronizes +only effects valid for the committed placement, publishes the task update and +move event, and reconciles the vacated source. Bulk moves process each task +through the same admission boundary rather than pre-rejecting the batch. + +### Event and lifecycle boundary + +Extend `task.moved` data with the committed admission state. For a queued move, +the orchestrator performs source exit and deliberately skips destination entry +and target-derived state. For an admitted move, existing exit/entry behavior +continues. + +Extend queue promotion handling into the single destination-entry boundary. +Promotion applies target state, runs `on_enter`, performs context reset and +terminal behavior, and handles auto-start exactly once. This must support tasks +with an existing session and tasks without one. Replayed or duplicate events +must not repeat entry effects. + +The workflow engine transition store uses the same admission result and event +contract. It must not leave the engine state ahead of a destination that has +not admitted the task. + +### Queue selection + +Keep the existing deterministic comparator within a queue. Change selection +to exhaust destination-resident queued candidates before considering feeder +candidates. This gives the displayed one-based position the same meaning as +the next promotion order. + +## Frontend Design + +### Shared queue model + +Add a pure queue helper that: + +- identifies destination-resident queued tasks; +- sorts them with the backend promotion order; +- returns one-based position, total, and destination title; +- partitions a limited column into admitted and queued cards. + +Use it from the Kanban and sidebar so both surfaces report the same order. The +existing queued-prompt count remains a separate field and badge. + +### Kanban column + +Within the existing column scroll owner, render admitted cards first and a +visually distinct queued area second. Show the queued-area label and count only +when the queue is non-empty. Keep the header WIP metric as `admitted/limit`. +Drag/drop into a full step succeeds and the card appears in the queued area. + +The shared `KanbanColumn` preserves desktop and focused mobile-column parity. +Do not add nested vertical scrolling or horizontal page overflow. + +### Sidebar + +Derive queue positions from workspace workflow snapshots in +`useWorkspaceSidebarTasks` and carry a distinct WIP queue object through the +desktop and mobile item mappers. `TaskItem` renders the same semantic chip in +both task switchers. + +The sidebar renders one compact queue icon on all pointer types. Its tooltip +states `Position N of M in STEP queue`, and the focusable trigger also supports +touch and keyboard users. The icon is status information, not a new action. + +All new copy is localized in English, pseudo, Portuguese, and Simplified +Chinese catalogs. Queue helper and component tests cover ordering, render/hide +rules, accessibility, the separate queued-prompt badge, and mobile-safe +presentation. + +### Workflow step settings + +Keep `Pull from` optional. Add an info tooltip beside it that separates three +behaviors: + +- Destination-resident queued tasks receive admission first. +- Kandev then pulls eligible tasks from the selected feeder when capacity is + available. +- Direct moves and automatic workflow transitions queue in the destination and + do not require a feeder. + +The help also states that new tasks targeting a full step uses the selected +feeder. The tooltip is available by hover or keyboard focus on desktop and by +tap or focus on mobile, so the contract does not depend on pointer hover. + +## Responsive and Mobile Contract + +- **Entry point:** the existing focused mobile Kanban column and task-switcher + sheet. +- **Nearest pattern:** `MobileColumnTabs`, `SwipeableColumns`, shared + `KanbanColumn`, and shared `TaskItem`. +- **Presentation:** inline column section and inline status chip; the workflow + settings explanation is a compact info tooltip, with no modal, drawer, or + mobile-only route. +- **Scroll ownership:** the existing Kanban column and task-switcher sheet keep + their single scroll owners. +- **Touch behavior:** queue position is visible without hover; the workflow + explanation opens from the existing info icon by tap or focus. +- **Parity:** move, queue visibility, position, and promotion are available on + desktop and mobile. + +## Test Strategy + +### Backend + +- Atomic move admission admits within capacity and queues overflow under + synchronized concurrency. +- Bulk movement fills available slots and queues the remainder. +- Moving queued work away clears old queue/deferred state and reapplies the new + target's admission rule. +- Destination-resident tasks promote before feeder tasks in deterministic + order. +- Queued moves run source exit once and destination entry only on promotion, + for existing-session and no-session cases. +- Terminal, auto-start, plan-mode, and context-reset effects are deferred. +- Service, approval, HTTP, WebSocket, MCP, and workflow-engine paths return + queued success instead of WIP conflict. + +### Frontend + +- The shared helper matches backend ordering and calculates stable one-based + positions. +- Kanban columns partition admitted and queued cards and preserve admitted WIP + counts. +- Sidebar queue status is separate from queued prompts, localized, accessible, + and correct in desktop and touch presentations. + +### Browser E2E + +- Desktop: use the UI to move a task into a full step, verify queued placement, + column partition, sidebar tooltip position, then free capacity and verify + promotion. +- Mobile Chrome: repeat the move in the focused-column flow, verify the queue + icon tooltip, promotion, touch usability, and no page-level horizontal + overflow. +- Seed prerequisite workflow/tasks through the API; perform and assert the + behavior under test through the UI. + +## Public Documentation + +Update `docs/public/tasks-and-workflows.md` and +`docs/public/workflow-tips.md`. Replace the manual-move conflict rule with +destination queue admission, explain destination queue priority over feeders, +and describe the Kanban/sidebar queue indicators. + +## Implementation Waves + +All tasks are sequential. The frontend consumes the finalized backend ordering +and lifecycle contract, and E2E/doc work validates the complete behavior. + +- [x] [Task 01: Add atomic move admission](task-01-atomic-move-admission.md) +- [x] [Task 02: Defer destination lifecycle until promotion](task-02-deferred-destination-lifecycle.md) +- [x] [Task 03: Add frontend queue guidance](task-03-kanban-queue-sections.md) +- [x] [Task 04: Show sidebar queue position](task-04-sidebar-queue-position.md) +- [x] [Task 05: Complete feature verification](task-05-e2e-and-documentation.md) + +## Implementation Progress + +Tasks 01 through 05 are implemented. The backend now commits admitted or +destination-queued placement atomically, defers destination lifecycle work +until promotion, and prioritizes destination-resident queues over feeder work. +The web client shows the same queue order in Kanban and desktop/mobile task +switchers, with localized `Pull from` guidance in an info tooltip. The initial +Kanban boot payload now includes WIP limits and task admission metadata, and the +sidebar uses an accessible queue icon with a position tooltip. Review hardening +also keeps replay tokens available after prerequisite failures, preserves state +changes in the promotion event path, and skips failed fallback candidates while +filling capacity. + +Focused backend and frontend checks pass. The broad backend regression gate, +managed browser checks, public-doc validation, and final diff review are +recorded in Task 05. + +No task is marked `parallel-safe`; this package does not authorize subagents. + +## Environment Prerequisite + +If `apps/node_modules` is absent, run `pnpm install --frozen-lockfile` from +`apps/` before frontend tests. Do not change the lockfile. + +## Risks + +- Applying destination state before admission can complete, reset, or start a + task while it is still queued. +- Running source exit again during promotion can duplicate messages or agent + actions. Event data must distinguish move placement from promotion entry. +- A non-atomic count-then-update can still over-admit under concurrent moves. +- Mixing destination and feeder candidates before selection makes the sidebar + position disagree with actual promotion priority. +- Reusing the sidebar's `queuedCount` field would confuse WIP queue state with + pending agent prompts. +- Bulk move ordering must be deterministic enough to make which tasks are + admitted versus queued testable. + +## Out of Scope + +- User-driven queue reordering. +- Dragging cards within the queued section to change priority. +- Recursive feeder routing. +- Changes to profile-wide agent-session concurrency. diff --git a/docs/plans/queued-task-moves/task-01-atomic-move-admission.md b/docs/plans/queued-task-moves/task-01-atomic-move-admission.md new file mode 100644 index 0000000000..f6d2767491 --- /dev/null +++ b/docs/plans/queued-task-moves/task-01-atomic-move-admission.md @@ -0,0 +1,99 @@ +--- +id: "01-atomic-move-admission" +title: "Add atomic move admission" +status: completed +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/tasks/wip-limit-pull-system.md" +--- + +# Task 01: Add Atomic Move Admission + +## Acceptance + +- A repository move to a limited step atomically commits either admitted or + destination-resident queued placement. A full target is a successful queued + result, not `ErrWIPLimitExceeded`. +- Concurrent moves for the last slot admit no more than the configured limit; + every other move remains durable in the target queue. +- Unlimited targets normalize to admitted with cleared queue metadata. +- `MoveTaskWithOptions` uses the committed admission result across ordinary, + cross-workflow, active-session, approval, and MCP calls. +- Bulk moves fill available capacity and queue the remaining tasks instead of + rejecting the batch during prevalidation. +- Moving a queued task clears its old queue/deferred-launch intent before the + new target admission is committed. +- The move response and task update contain the committed `wip_admitted`, + `queued_for_step_id`, and `queued_at` values. + +## TDD Sequence + +1. Add repository concurrency tests and service behavior tests. Run them RED + against the current capacity conflict. +2. Implement the atomic admission repository method and narrow interface. +3. Route single and bulk moves through it, then refactor duplicate validation. +4. Run focused repository and service tests GREEN with the race detector. + +## Verification + +```bash +cd apps/backend +rtk go test -tags fts5 ./internal/task/repository/... \ + ./internal/task/service ./internal/orchestrator -count=1 +rtk go test -tags fts5 ./internal/task/repository/sqlite \ + -run TestPostgresUpdateTaskWithWorkflowStepAdmission_ConcurrentLastSlot -count=1 +``` + +## Implementation Result + +- Added `UpdateTaskWithWorkflowStepAdmission`, which locks/counts the target + step and commits admitted or destination-resident queued placement in one + transaction. Unlimited targets clear queue metadata. +- Single, cross-workflow, approval, MCP, HTTP, WebSocket, and bulk move paths + use the committed admission result. Bulk moves queue overflow instead of + rejecting the batch. +- Moving a queued task clears its old deferred state before applying the new + target admission. +- Repository and service tests cover overflow, unlimited targets, the final + slot race, event metadata, and bulk moves. Manual placement, admitted state, + and queue-exit metadata now commit atomically. +- Lifecycle replay keeps move and promotion tokens pending when prerequisite + loading fails, and promotion errors no longer terminate queue filling. + +The focused implementation run was: + +```text +rtk go test -tags fts5 ./internal/task/repository/... ./internal/task/service ./internal/orchestrator -count=1 +Go test: 3150 passed in 5 packages + +rtk go test -tags fts5 ./internal/task/repository/sqlite \ + -run TestPostgresUpdateTaskWithWorkflowStepAdmission_ConcurrentLastSlot -count=1 +Go test: skipped because KANDEV_TEST_POSTGRES_DSN is not set +``` + +The repository admission tests and the focused service/orchestrator move +tests also passed. + +## Files Likely Touched + +- `apps/backend/internal/task/repository/interface.go` +- `apps/backend/internal/task/repository/sqlite/task.go` +- `apps/backend/internal/task/repository/task_wip_test.go` +- `apps/backend/internal/task/service/service_workflow.go` +- `apps/backend/internal/task/service/service_workflow_test.go` +- `apps/backend/internal/task/service/service_workflow_bulk_test.go` + +## Dependencies + +None. + +## Parallelism + +`sequential` + +## Output Contract + +Record RED/GREEN evidence, the repository admission result shape, concurrency +coverage, bulk ordering, files changed, and exact command results. Update this +task and `plan.md` status in the same implementation conversation. diff --git a/docs/plans/queued-task-moves/task-02-deferred-destination-lifecycle.md b/docs/plans/queued-task-moves/task-02-deferred-destination-lifecycle.md new file mode 100644 index 0000000000..2d0dbb6f77 --- /dev/null +++ b/docs/plans/queued-task-moves/task-02-deferred-destination-lifecycle.md @@ -0,0 +1,106 @@ +--- +id: "02-deferred-destination-lifecycle" +title: "Defer destination lifecycle until promotion" +status: completed +wave: 2 +depends_on: ["01-atomic-move-admission"] +plan: "plan.md" +spec: "../../specs/tasks/wip-limit-pull-system.md" +--- + +# Task 02: Defer Destination Lifecycle Until Promotion + +## Acceptance + +- `task.moved` carries enough admission information for consumers to + distinguish an admitted move from a destination-resident queued move. +- A queued move runs source `on_exit` exactly once and does not run target + `on_enter`, target state synchronization, terminal behavior, context reset, + plan-mode behavior, or auto-start. +- `task.queue_promoted` is the single destination-entry boundary. It applies + the target state and behavior exactly once for tasks with or without an + active session. +- Workflow-engine transitions use destination admission and do not advance + runtime state ahead of queue promotion. +- An automatic transition to a full limited step succeeds as a queued move. + The destination entry behavior waits for admission. +- Destination-resident queued tasks are selected before feeder-resident tasks; + deterministic ordering inside each pool remains position, priority, queue + time, creation time, and task ID. +- Duplicate/replayed move or promotion events do not repeat lifecycle effects. + +## TDD Sequence + +1. Add failing watcher/orchestrator tests for source exit at queue time and + destination entry at promotion time, with and without a session. +2. Extend the move event contract and split move handling at the committed + admission boundary. +3. Expand promotion handling to execute deferred destination entry once. +4. Add workflow-store tests for queued transitions and destination-before- + feeder selection. +5. Run focused packages GREEN and refactor shared entry logic. + +## Verification + +```bash +cd apps/backend +go test -tags fts5 ./internal/orchestrator/... \ + -run 'Test.*(TaskMoved|QueuePromoted|QueuedTransition|PullNext)' -count=1 +go test -tags fts5 ./internal/task/service/... \ + -run 'Test.*(TaskMovedEvent|Promotion|QueueOrder)' -count=1 +``` + +## Implementation Result + +- `task.moved` now carries committed admission metadata and a promotion marker + when a queued task becomes admitted. +- Queued moves run source exit once and defer destination state, terminal + effects, context reset, plan mode, and auto-start until promotion. +- Queue promotion handles both existing-session and no-session tasks through a + one-shot metadata claim. Destination-resident queued tasks are selected + before feeder candidates. +- Workflow-engine transitions use the same admission boundary and stop before + destination entry when the target is queued. +- Replay prerequisite failures leave lifecycle claim metadata available for a + later retry, and promotion state synchronization errors abort that attempt. + +The focused backend run passed: + +```text +rtk go test -tags fts5 ./internal/task/service ./internal/orchestrator -run 'TestService_MoveTaskQueuesFullWIPLimitedTarget|TestClaimTaskEventMetadataIsOneShot|TestExecuteStepTransition_FullTargetRunsExitAndDefersEntry|TestService_MoveTaskPullsNextFeederTaskOnVacate|TestService_MoveTaskPullSkipsBlockedFeederCandidate' -count=1 +5 passed in 2 packages +``` + +The post-review focused backend run also passed the full affected package set: + +```text +rtk go test -tags fts5 ./internal/task/repository/... ./internal/task/service ./internal/orchestrator -count=1 +Go test: 3150 passed in 5 packages +``` + +## Files Likely Touched + +- `apps/backend/internal/task/service/service_events.go` +- `apps/backend/internal/task/service/service_workflow.go` +- `apps/backend/internal/orchestrator/watcher/watcher.go` +- `apps/backend/internal/orchestrator/event_handlers_workflow.go` +- `apps/backend/internal/orchestrator/event_handlers_workflow_queue.go` +- `apps/backend/internal/orchestrator/workflow_store.go` +- `apps/backend/internal/orchestrator/event_handlers_workflow_moved_test.go` +- `apps/backend/internal/orchestrator/event_handlers_workflow_test.go` +- `apps/backend/internal/orchestrator/workflow_store_test.go` +- `apps/backend/internal/task/service/service_workflow_test.go` + +## Dependencies + +Task 01 supplies the committed admitted-or-queued move result. + +## Parallelism + +`sequential` + +## Output Contract + +Record RED/GREEN lifecycle evidence, event payload changes, idempotency +mechanism, queue-priority evidence, files changed, and exact command results. +Update this task and `plan.md` status in the same implementation conversation. diff --git a/docs/plans/queued-task-moves/task-03-kanban-queue-sections.md b/docs/plans/queued-task-moves/task-03-kanban-queue-sections.md new file mode 100644 index 0000000000..62147263b7 --- /dev/null +++ b/docs/plans/queued-task-moves/task-03-kanban-queue-sections.md @@ -0,0 +1,107 @@ +--- +id: "03-kanban-queue-sections" +title: "Add frontend queue guidance" +status: completed +wave: 3 +depends_on: ["02-deferred-destination-lifecycle"] +plan: "plan.md" +spec: "../../specs/tasks/wip-limit-pull-system.md" +--- + +# Task 03: Add Frontend Queue Guidance + +## Acceptance + +- A pure frontend helper identifies and sorts destination-resident queued + tasks with the backend promotion order and returns one-based positions. +- A limited Kanban column renders admitted cards first and a labeled queued + area second. The queued label/count is absent when no task is queued. +- The column header continues to show admitted WIP as `admitted/limit`; queued + cards do not consume the numerator. +- Drag/drop and other move UI treat the backend's queued result as success and + show the card in the target queue without a capacity error. +- The shared column implementation preserves the focused mobile-column flow, + one vertical scroll owner, readable queue state, and no horizontal overflow. +- The workflow step settings page identifies `Pull from` as optional automatic + feeder intake. An info tooltip explains destination-queue priority, feeder + intake, and queued direct or automatic transitions. +- The help also states that new tasks targeting a full step use the selected + feeder. The full contract is available through hover or focus on desktop and + tap or focus on mobile. +- All new copy is localized across English, pseudo, Portuguese, and Simplified + Chinese catalogs. +- Unit/component tests cover partitioning, ordering, counts, empty queues, and + desktop/mobile rendering. They also cover the `Pull from` help with and + without a selected feeder. + +## TDD Sequence + +1. Add helper and column tests for admitted/queued partition and deterministic + order. Run RED. +2. Implement the shared helper and column queue section. +3. Add `Pull from` guidance to the existing responsive step editor's info + tooltip. +4. Update move-result handling only where current UI assumes a WIP conflict. +5. Add locale keys and run frontend gates GREEN. + +## Verification + +```bash +cd apps +pnpm --filter @kandev/web exec vitest run \ + lib/kanban/wip-queue.test.ts \ + hooks/domains/kanban/use-workspace-sidebar-tasks.test.ts \ + components/task/task-session-sidebar-item.test.ts \ + components/task/mobile/session-task-switcher-sheet-hooks.test.ts \ + components/task/task-item.test.tsx +pnpm --filter @kandev/web run typecheck +pnpm --filter @kandev/web run lint +pnpm --filter @kandev/web run i18n:check +pnpm --filter @kandev/web run i18n:ratchet +``` + +## Implementation Result + +- Added the shared `wip-queue` helper with backend-compatible ordering, + destination queue positions, and admitted/queued partitioning. +- Kanban columns render admitted cards first, then a localized queued section; + the header count remains admitted WIP over the configured limit. +- Added localized `Pull from` guidance for feeder and no-feeder configurations + to the existing info tooltip across English, pseudo, Portuguese, and + Simplified Chinese catalogs. +- Focused Vitest coverage passed with 5 files and 71 tests using the exact + command above. Typecheck, lint, + `i18n:check`, `i18n:ratchet`, and the E2E production build passed. + +## Files Likely Touched + +- `apps/web/lib/kanban/wip-queue.ts` +- `apps/web/lib/kanban/wip-queue.test.ts` +- `apps/web/lib/kanban/wip-limit.ts` +- `apps/web/components/kanban-column.tsx` +- `apps/web/components/kanban-column.test.tsx` +- `apps/web/components/kanban/swimlane-kanban-content.tsx` +- `apps/web/components/settings/workflow-pipeline-editor-wip-controls.tsx` +- `apps/web/components/settings/workflow-pipeline-editor-wip-controls.test.tsx` +- `apps/web/src/locales/en/kanban.json` +- `apps/web/src/locales/pseudo/kanban.json` +- `apps/web/src/locales/pt-pt/kanban.json` +- `apps/web/src/locales/zh-cn/kanban.json` +- `apps/web/src/locales/en/workflows.json` +- `apps/web/src/locales/pseudo/workflows.json` +- `apps/web/src/locales/pt-pt/workflows.json` +- `apps/web/src/locales/zh-cn/workflows.json` + +## Dependencies + +Task 02 fixes the authoritative backend promotion order and event semantics. + +## Parallelism + +`sequential` + +## Output Contract + +Record RED/GREEN evidence, queue helper contract, desktop/mobile rendering +notes, i18n and type/lint results, files changed, and exact command results. +Update this task and `plan.md` status in the same implementation conversation. diff --git a/docs/plans/queued-task-moves/task-04-sidebar-queue-position.md b/docs/plans/queued-task-moves/task-04-sidebar-queue-position.md new file mode 100644 index 0000000000..3acd371f08 --- /dev/null +++ b/docs/plans/queued-task-moves/task-04-sidebar-queue-position.md @@ -0,0 +1,96 @@ +--- +id: "04-sidebar-queue-position" +title: "Show sidebar queue position" +status: completed +wave: 4 +depends_on: ["03-kanban-queue-sections"] +plan: "plan.md" +spec: "../../specs/tasks/wip-limit-pull-system.md" +--- + +# Task 04: Show Sidebar Queue Position + +## Acceptance + +- `useWorkspaceSidebarTasks` derives each destination-resident queued task's + position, total queue size, and destination title from workspace workflow + snapshots using the shared queue helper. +- `TaskSwitcherItem` carries WIP queue status in a field distinct from the + existing `queuedCount`, which remains the pending-agent-prompt count. +- Desktop and mobile sidebar mappers pass the same queue object to shared + `TaskItem` rendering for tasks and subtasks. +- All pointer presentations show one compact localized queue icon whose tooltip + states `Position N of M in STEP queue`. Its focusable trigger supports hover, + keyboard focus, and touch focus, so the position does not depend on hover. +- The icon is accessible status information, remains shrink-safe, and does not + create horizontal overflow or a second scroll owner. +- Live task updates and queue promotions update or remove the chip without a + reload. +- Unit/component tests prove mapping, ordering, render/hide behavior, icon and + tooltip accessibility, subtasks, and separation from the queued-prompt badge. + +## TDD Sequence + +1. Add hook, mapper, and `TaskItem` tests for WIP queue status. Run RED. +2. Add the distinct item type and derive queue data once in the workspace hook. +3. Render the responsive localized chip in shared task rows. +4. Run focused tests and frontend gates GREEN. + +## Verification + +```bash +cd apps +pnpm --filter @kandev/web exec vitest run \ + lib/kanban/wip-queue.test.ts \ + hooks/domains/kanban/use-workspace-sidebar-tasks.test.ts \ + components/task/task-session-sidebar-item.test.ts \ + components/task/mobile/session-task-switcher-sheet-hooks.test.ts \ + components/task/task-item.test.tsx +pnpm --filter @kandev/web run typecheck +pnpm --filter @kandev/web run lint +pnpm --filter @kandev/web run i18n:check +pnpm --filter @kandev/web run i18n:ratchet +``` + +## Implementation Result + +- `useWorkspaceSidebarTasks` derives WIP queue positions from the shared + helper and carries them separately from the existing queued-agent-prompt + count. +- Desktop and mobile task switchers share the same queue status mapping and + focusable queue icon. The icon tooltip states `Position N of M in STEP queue`. +- Focused mapper and component tests passed with the exact 5-file, 71-test + Vitest run above. The queue icon disappears after promotion and preserves + mobile width constraints in managed E2E coverage. +- The SSR Kanban boot payload includes WIP limits and task admission metadata, + so the initial column count does not wait for a later snapshot refresh. + +## Files Likely Touched + +- `apps/web/hooks/domains/kanban/use-workspace-sidebar-tasks.ts` +- `apps/web/hooks/domains/kanban/use-workspace-sidebar-tasks.test.ts` +- `apps/web/components/task/task-switcher-types.ts` +- `apps/web/components/task/task-session-sidebar-item.ts` +- `apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts` +- `apps/web/components/task/task-item.tsx` +- `apps/web/components/task/task-item-stats-row.tsx` +- related focused test files +- `apps/web/src/locales/en/sidebar.json` +- `apps/web/src/locales/pseudo/sidebar.json` +- `apps/web/src/locales/pt-pt/sidebar.json` +- `apps/web/src/locales/zh-cn/sidebar.json` + +## Dependencies + +Task 03 supplies the shared queue-order helper and Kanban terminology. + +## Parallelism + +`sequential` + +## Output Contract + +Record RED/GREEN evidence, the distinct queue type, fine/coarse-pointer +behavior, live-update behavior, i18n and type/lint results, files changed, and +exact command results. Update this task and `plan.md` status in the same +implementation conversation. diff --git a/docs/plans/queued-task-moves/task-05-e2e-and-documentation.md b/docs/plans/queued-task-moves/task-05-e2e-and-documentation.md new file mode 100644 index 0000000000..65e6b4e1e8 --- /dev/null +++ b/docs/plans/queued-task-moves/task-05-e2e-and-documentation.md @@ -0,0 +1,144 @@ +--- +id: "05-e2e-and-documentation" +title: "Complete feature verification" +status: completed +wave: 5 +depends_on: + - "01-atomic-move-admission" + - "02-deferred-destination-lifecycle" + - "03-kanban-queue-sections" + - "04-sidebar-queue-position" +plan: "plan.md" +spec: "../../specs/tasks/wip-limit-pull-system.md" +--- + +# Task 05: Complete Feature Verification + +## Acceptance + +- Desktop E2E uses the UI to move a task into a full step, verifies successful + destination queue placement, admitted/queued Kanban areas, WIP count, and + sidebar queue-position tooltip, then frees capacity and verifies promotion. +- Mobile Chrome E2E repeats the move through the focused-column/task-switcher + flow, verifies the queue icon tooltip, promotion, touch usability, and no + page-level horizontal overflow. +- Desktop and mobile workflow-settings coverage opens the `Pull from` info + tooltip. The guidance identifies automatic feeder intake as optional and + distinguishes it from direct or automatic transitions. +- Tests seed prerequisite workflow/task state through the API but perform and + assert the behavior under test through visible UI interactions. +- Existing creation-overflow and feeder auto-pull E2E coverage stays green. +- When PR asset capture is available, screenshots are reviewed at desktop and + mobile widths for section hierarchy, truncation, chip legibility, and + queue-position consistency. Capture is best effort and environment failures + are recorded without blocking functional verification. +- Public task/workflow documentation states that full-step moves queue in the + destination, destination queues promote before feeders, and the UI exposes + queued cards and position. +- Focused backend/frontend suites and documentation validation pass; broader + regressions are reported without hiding environment-only failures. + +## Verification + +```bash +cd apps/web +pnpm e2e:run tests/kanban/wip-overflow-queue.spec.ts +pnpm e2e:run --project mobile-chrome \ + tests/kanban/mobile-wip-overflow-queue.spec.ts +pnpm e2e:run tests/workflow/workflow-settings.spec.ts +pnpm e2e:run --project mobile-chrome \ + tests/workflow/mobile-workflow-settings.spec.ts + +cd ../../ +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs + +make -C apps/backend test +cd apps +pnpm --filter @kandev/web run typecheck +pnpm --filter @kandev/web run lint +pnpm --filter @kandev/web run i18n:check +pnpm --filter @kandev/web run i18n:ratchet +``` + +## Implementation Result + +- Desktop queue E2E passed: `wip-overflow-queue.spec.ts`, 2 tests. +- Mobile queue E2E passed: `mobile-wip-overflow-queue.spec.ts`, 2 tests. +- Desktop and mobile workflow-settings guidance checks each passed 1 test. + They cover both the no-feeder and selected-feeder messages. +- The managed E2E checks assert admitted counts, queued sections, sidebar icon + tooltip position, promotion, touch interaction, and no horizontal + overflow. Existing queue overflow behavior remains green. +- The backend boot-state serializer regression test verifies that WIP limits, + feeder configuration, admission, and queue metadata are present in the + first-paint Kanban payload. +- After the review hardening, the desktop and mobile queue suites were rerun + against the updated selectors and each passed two tests. +- The pseudo-locale E2E build passed. The focused queue helper and sidebar + Vitest suites passed 14 tests, and web typecheck, lint, i18n check, and i18n + ratchet all passed. +- Public documentation validation passed: 58 Node tests and 41 published + pages. The two public workflow pages now describe destination admission, + destination-before-feeder promotion, and Kanban/sidebar indicators. +- The broad backend gate passed on its second run. The first run had one + existing process-manager stderr-sanitization test miss under full-suite + load; the test passed five isolated runs and the complete process package + passed 620 tests before the full suite was rerun successfully. +- A separate `CAPTURE_PR_ASSETS=1` screenshot pass was attempted for direct + visual inspection, but the runner filesystem was at 100% and Docker could + not allocate overlay space. Functional E2E assertions and captured failure + diagnostics were available; no screenshot artifact was retained in the + worktree. + +Final verification highlights: + +```text +rtk make -C apps/backend test +exit 0 + +rtk pnpm --filter @kandev/web run build:e2e +exit 0 + +rtk pnpm --filter @kandev/web exec vitest run \ + lib/kanban/wip-queue.test.ts \ + hooks/domains/kanban/use-workspace-sidebar-tasks.test.ts +2 files, 14 tests passed + +rtk pnpm e2e:run tests/kanban/wip-overflow-queue.spec.ts +2 passed + +rtk pnpm e2e:run --project mobile-chrome tests/kanban/mobile-wip-overflow-queue.spec.ts +2 passed + +rtk node --test scripts/validate-public-docs.test.mjs +58 passed +``` + +## Files Likely Touched + +- `apps/web/e2e/tests/kanban/wip-overflow-queue.spec.ts` +- `apps/web/e2e/tests/kanban/mobile-wip-overflow-queue.spec.ts` +- `apps/web/e2e/tests/workflow/workflow-settings.spec.ts` +- `apps/web/e2e/tests/workflow/mobile-workflow-settings.spec.ts` +- `apps/web/e2e/pages/kanban-page.ts` +- `apps/web/e2e/pages/mobile-kanban-page.ts` +- `docs/public/tasks-and-workflows.md` +- `docs/public/workflow-tips.md` +- `docs/plans/queued-task-moves/plan.md` +- completed task files in this plan + +## Dependencies + +Tasks 01 through 04 provide the end-to-end behavior and presentation. + +## Parallelism + +`sequential` + +## Output Contract + +Record E2E results, screenshots inspected, public-doc validation, focused and +broad gate results, any environment-only failures, and final changed files. +Mark this task and `plan.md` complete only when the behavior and documentation +are both verified. diff --git a/docs/public/tasks-and-workflows.md b/docs/public/tasks-and-workflows.md index dc04712720..d132d9b7a8 100644 --- a/docs/public/tasks-and-workflows.md +++ b/docs/public/tasks-and-workflows.md @@ -341,17 +341,22 @@ New steps allow manual moves by default. **Show in command panel** also defaults | **Allow manual move** | Allows dragging a task into this step. Treat it as workflow UX, not as a security or approval boundary. | | **Show in command panel** | Includes tasks in this step in the default, empty-search **Cmd+K** task list. Typed task search currently searches every step and can also return archived tasks, regardless of this setting. | | **Auto-archive** | Archives inactive tasks after the configured number of hours. Enabling it starts at 24 hours; the minimum is 1. | -| **WIP limit** | Maximum admitted active, non-archived, non-ephemeral tasks in the step. `0` means unlimited. Overflow remains visible as queued cards; manual moves into a full step are still rejected. | -| **Pull from** | Optional one-hop feeder step. When capacity opens or eligible work arrives in the feeder, Kandev promotes queued work from the destination first, then the feeder. A full feeder rejects new overflow creation. | +| **WIP limit** | Maximum admitted active, non-archived, non-ephemeral tasks in the step. `0` means unlimited. Overflow remains visible as queued cards; manual moves into a full step succeed and queue there. | +| **Pull from** | Optional one-hop feeder step. When capacity opens or eligible work arrives in the feeder, Kandev promotes queued work from the destination first, then the feeder. Direct moves and automatic transitions queue in the destination without using the feeder. A full feeder rejects new overflow creation. | The WIP check also applies when a task is created. It runs for an explicit `workflow_step_id` and for the workflow's resolved start step, and the admission check is atomic. When a limited step is full, the task is still created and visible: it is queued in that step when no feeder is configured, or placed in the configured feeder and tagged for the destination. Queued -tasks do not start sessions or consume destination WIP until promoted. If the -configured feeder is also full, creation returns a conflict. Ephemeral tasks -are not counted. +tasks do not start sessions or consume destination WIP until promoted. If you +manually move a task, or an automatic transition sends it to a full limited +step, it queues in that destination instead of using the feeder. The Kanban +column shows the admitted count and limit, then a **Queued** section. The task +sidebar shows a queue icon whose tooltip gives the task's position in that +destination queue. If the configured +feeder is also full, creation returns a conflict. Ephemeral tasks are not +counted. Integration watchers use the same admission rule. For example, a GitHub review watch targeting a `Review` step with a limit of two admits at most two newly @@ -361,7 +366,7 @@ reservation and does not start an agent for them. Auto-archive is checked on a five-minute background interval and uses the task's last update time. Any task update postpones eligibility, so the archive is not guaranteed at the exact configured minute. Archiving, deleting, or moving an admitted task opens capacity and promotes the oldest queued card. Auto-archive affects the task itself, not its children. -Pull configuration rejects self-references, cycles, and cross-workflow feeders. Pulling runs when a task vacates the limited step and when eligible work is created in its feeder, filling each available slot. Candidates are ordered by board position, then priority (`critical`, `high`, `medium`, `low`, `none`), queue time, creation time, and ID. A candidate whose move fails, for example because its session is running or starting, is skipped for that pull pass. +Pull configuration rejects self-references, cycles, and cross-workflow feeders. Pulling runs when a task vacates the limited step and when eligible work is created in its feeder, filling each available slot. Destination-queued tasks are promoted before feeder candidates. Candidates are ordered by board position, then priority (`critical`, `high`, `medium`, `low`, `none`), queue time, creation time, and ID. A candidate whose move fails, for example because its session is running or starting, is skipped for that pull pass. ### Configure events and transitions diff --git a/docs/public/workflow-tips.md b/docs/public/workflow-tips.md index f23f19ce63..46de0927e6 100644 --- a/docs/public/workflow-tips.md +++ b/docs/public/workflow-tips.md @@ -135,8 +135,14 @@ Workflow-level settings include the name and default agent profile. A step can o | Auto-archive | Archives eligible tasks after the configured number of hours. `0` disables it; the background sweep runs every five minutes and uses task `updated_at`, so timing is approximate. | | Wait for agent completion signal | With an `on_turn_complete` transition, waits for the agent to call `step_complete_kandev`. A halt without the signal leaves the task on the current step; retry or reconnect the agent, or move the task through the normal workflow UI. Without this setting, a normal turn end counts as completion. Default is off. | | Run completion actions when a turn is cancelled | Also runs the step's `on_turn_complete` actions after an explicit user cancellation settles. A pending clarification, silent interruption, parent/task stop, provider failure, crash, or runtime teardown does not qualify. If the destination has `on_enter: auto_start_agent`, another agent turn can begin immediately. Default is off for custom steps; the built-in Kanban workflow enables it on Backlog and In Progress for newly created workflows; existing workflows are not backfilled. | -| WIP limit | Maximum admitted active, non-archived, non-ephemeral tasks in the step. `0` means unlimited; visible overflow is queued. A full target rejects manual moves. | -| Pull from | Optional one-hop feeder step. When capacity opens, Kandev promotes queued destination work first, then feeder work. A full feeder rejects new overflow creation. | +| WIP limit | Maximum admitted active, non-archived, non-ephemeral tasks in the step. `0` means unlimited; visible overflow is queued. A manual move into a full target succeeds and queues in that target. | +| Pull from | Optional one-hop feeder step. When capacity opens, Kandev promotes queued destination work first, then feeder work. Direct moves and automatic transitions queue in the destination without using the feeder. A full feeder rejects new overflow creation. | + +The Kanban column shows the admitted count and limit, followed by a **Queued** +section when overflow exists. The task sidebar shows a queue icon for each +queued task; hover or focus gives its position in the destination queue. +Queued tasks do not start destination entry actions or consume WIP until +promotion. Pull candidates are selected by board position, then priority, queue time, creation time, and ID. A candidate that cannot be moved is skipped. Pulling runs for every limited step; a feeder is only needed for overflow created outside the destination step. @@ -156,7 +162,7 @@ The standard Kanban editor exposes these events: The portable format also recognizes `set_session_mode`, `clear_decisions`, `queue_run`, and `queue_run_for_each_participant` in `on_enter`; these are advanced/runtime-dependent actions and most are not offered by the Kanban editor. Office event triggers have a broader model, but do not round-trip through Kanban import/export. See the exact boundary in [Workflow Import / Export](workflow-import-export.md). -Keep one transition action per event. A “next” action on the last step or “previous” on the first has nowhere to go and leaves the task in place. WIP rejection, a missing target step, a failed agent launch, or missing credentials can also prevent the intended progression; inspect the task/session error and backend logs before changing the workflow. +Keep one transition action per event. A “next” action on the last step or “previous” on the first has nowhere to go and leaves the task in place. A missing target step, a failed agent launch, missing credentials, or a full feeder can prevent the intended progression; inspect the task/session error and backend logs before changing the workflow. A full destination step queues the task instead of rejecting the move. Cancellation policy is deliberately narrow: it is evaluated only for the visible user **Cancel** action while a turn is working. It reuses the normal turn-complete pipeline after the runtime settles, but does not reinterpret every way a session can stop as a completion. diff --git a/docs/specs/tasks/wip-limit-pull-system.md b/docs/specs/tasks/wip-limit-pull-system.md index cc2941e2cd..06f40b3d6d 100644 --- a/docs/specs/tasks/wip-limit-pull-system.md +++ b/docs/specs/tasks/wip-limit-pull-system.md @@ -1,7 +1,7 @@ --- status: accepted created: 2026-07-27 -updated: 2026-07-29 +updated: 2026-08-12 owner: kandev --- @@ -41,9 +41,10 @@ GitHub-specific retry logic. the WIP limit is reached. Same-step promotion clears the queued state without changing columns. Feeder promotion moves the task into the destination step and clears the queued state. -- Promotion order is deterministic: position ascending, priority rank - (`critical`, `high`, `medium`, `low`, none/unknown), queue time ascending, - then task ID ascending. +- Destination-resident queued tasks are admitted before feeder-resident tasks. + Each pool uses position ascending, priority rank (`critical`, `high`, + `medium`, `low`, none/unknown), queue time ascending, creation time + ascending, then task ID ascending. - A promoted task follows the destination step's normal `on_enter` behavior. If the original create request explicitly requested an agent start, that deferred launch intent also becomes eligible when the task is promoted. @@ -53,15 +54,38 @@ GitHub-specific retry logic. must not both launch. This applies whether the destination has capacity at creation (direct admission) or opens capacity during the same create call (feeder placement immediately promoted). See "Auto-start idempotency" below. -- The Kanban board shows queued state on each affected task and continues to - show WIP consumption as `admitted/limit`. The total number of cards may be - larger than the admitted count. +- The Kanban board shows admitted cards and queued cards in separate areas of + each limited step. It continues to show WIP consumption as + `admitted/limit`. The total number of cards may be larger than the admitted + count. +- The initial server-rendered Kanban boot state includes each step's WIP limit + and each task's admission and queue metadata, so the admitted count is + correct on first paint and does not depend on a later snapshot refresh. +- The task sidebar shows a queue icon for a destination-resident queued task. + Hover or focus on the icon shows the task position, queue size, and + destination step. The same focusable icon and tooltip work on touch devices. - The rule applies to task creation through the UI, HTTP, WebSocket, MCP, and integration watchers, including requests that resolve the workflow start step implicitly. -- Manual moves, drag/drop moves, bulk moves, and workflow-engine transitions - into a full limited step retain the existing capacity conflict. This feature - changes creation overflow, not explicit move semantics. +- A task move into a limited step uses the same admission boundary as task + creation. If capacity is available, the task is admitted. If the step is + full, the task moves into that destination and becomes queued there. +- Queue admission applies to stepper moves, drag/drop, move menus, bulk moves, + HTTP, WebSocket, MCP, approved transitions, and workflow-engine transitions. +- An automatic workflow transition into a full limited step moves the task into + that step's queue. The task waits for admission before Kandev runs the + destination step's entry behavior. +- A move never routes through the destination's configured feeder. A feeder + remains an optional automatic intake source for workflows that want it. +- `pull_from_step_id` is optional. When it is empty, the step does not pull + tasks automatically from another step. Direct moves and automatic workflow + transitions still queue in the destination when it is full. +- The workflow step settings page explains that `Pull from` controls automatic + feeder intake through an info tooltip. Its help text also explains + destination-queue priority and is available by tap or focus on touch + devices. +- A queued move processes the source step's `on_exit` behavior immediately. + The destination step's `on_enter` behavior runs only after admission. - Ephemeral tasks remain outside workflow WIP and queue behavior. ### One-hop feeder assumption @@ -154,6 +178,8 @@ adapters gain: - `workflow_step_id` always reports the task's actual visible column. - `queued_for_step_id` is omitted or empty for an admitted task. - Create responses return success for both admitted and queued tasks. +- Move responses return success for both admitted and queued tasks. The + returned task contains the committed queue metadata. - HTTP, WebSocket, and MCP return the existing conflict classification only when one-hop placement cannot succeed, such as a configured feeder that is also full. @@ -182,6 +208,14 @@ create for full target without feeder create for full target with full feeder -> conflict + no task + +move to target with capacity + -> leave source + admitted in target + +manual or automatic move to full target + -> leave source + resident in target + queued for target + -> destination capacity opens + -> admitted in place + clear queue ``` Capacity reconciliation runs after every event that can open or add slots: @@ -197,10 +231,14 @@ Reconciliation is idempotent and transactionally claims one slot and one queue candidate at a time. Concurrent reconcilers cannot over-admit a step or launch the same deferred intent twice. -If a user manually moves a queued task away from its current column, Kandev -cancels its queue destination and deferred auto-start intent before applying the -move. The explicit move then follows normal WIP admission rules at the new -destination. +If a user moves a queued task away from its current column, Kandev cancels its +queue destination and deferred auto-start intent before it applies admission at +the new destination. + +A queued move leaves the source step when the move commits. Kandev processes +the source `on_exit` behavior once. Kandev defers destination `on_enter`, +auto-start, plan-mode, context-reset, and terminal-step effects until queue +promotion. ## Permissions @@ -224,6 +262,8 @@ destination. returns the typed conflict without exceeding either admitted limit. - If promotion loses a race for the destination slot, the task stays queued and is eligible for the next reconciliation. +- If a move loses a race for the last destination slot, the move still succeeds + and commits the task as queued in the destination. - If deferred launch fails after promotion, the task remains admitted and the launch intent remains retryable and visible through existing task/session error reporting. It is not silently returned to the queue. @@ -273,6 +313,35 @@ destination. - **GIVEN** a full destination and a configured feeder that is also WIP-full, **WHEN** a new task targets the destination, **THEN** creation returns a conflict and no task is persisted; a second feeder is not traversed. +- **GIVEN** a full WIP-1 `Test` step, **WHEN** a user moves a task from + `Improve` to `Test`, **THEN** the move succeeds and the task appears in the + queued area of `Test` without consuming WIP. +- **GIVEN** a full WIP-1 `Test` step, **WHEN** the workflow automatically + transitions a task from `Improve` to `Test`, **THEN** the task appears in the + queued area of `Test` and waits for admission before `Test` entry behavior. +- **GIVEN** a step with no `Pull from` selection, **WHEN** a direct or automatic + transition targets that full step, **THEN** the task still queues in the + destination and no feeder is required. +- **GIVEN** a user edits a workflow step, **WHEN** the user opens the + `Pull from` info tooltip, **THEN** the help identifies it as optional + automatic intake and explains destination-queue priority. +- **GIVEN** two tasks queued in `Test`, **WHEN** capacity opens, **THEN** Kandev + admits the first destination-resident queued task before it pulls work from a + configured feeder. +- **GIVEN** a queued task with an existing session, **WHEN** the queue move + commits, **THEN** Kandev runs source `on_exit` once and does not run target + `on_enter`. +- **GIVEN** that queued task, **WHEN** Kandev admits it, **THEN** Kandev runs + target `on_enter` once and applies the destination's normal state behavior. +- **GIVEN** a limited Kanban step with admitted and queued tasks, **WHEN** a user + views the board, **THEN** the column shows separate active and queued areas + with the queued cards in promotion order. +- **GIVEN** a destination-resident queued task, **WHEN** a user hovers over or + focuses its sidebar queue icon, **THEN** the tooltip shows its one-based + position, total queue size, and destination step. +- **GIVEN** the same task on a touch viewport, **WHEN** the task appears in the + mobile task switcher, **THEN** focusing or tapping the queue icon exposes the + same tooltip without a hover-only action or horizontal page overflow. - **GIVEN** an auto-start `Review` step with a WIP limit and a `pull_from_step_id` feeder that has capacity, **WHEN** a GitHub review watch creates a task that is placed in the feeder and immediately promoted into `Review` during the same @@ -286,7 +355,7 @@ destination. **THEN** its queue destination and deferred auto-start intent are cancelled and the move follows the new step's normal WIP rule. - **GIVEN** a full limited step, **WHEN** a user drags an unrelated task into - it, **THEN** the move is rejected and rolled back as it is today. + it, **THEN** the move succeeds and the task becomes queued in that step. - **GIVEN** a step with `wip_limit: 0`, **WHEN** tasks are created or moved into it, **THEN** they are admitted immediately and no WIP queue is created. - **GIVEN** a narrow mobile viewport, **WHEN** the user opens a feeder or @@ -299,16 +368,19 @@ destination. - Replacing `agent_profiles.max_concurrent_sessions`. - Recursive feeder-chain routing. - Automatically creating feeder steps. -- Redirecting manual or workflow-engine moves into queues. - A GitHub-specific `max_inflight_tasks` replacement; watcher throttles remain independent safeguards. - Reordering queued tasks through a new dedicated queue-management UI. +- Moving queued cards between positions inside the queue. ## Decision See [`../../decisions/2026-07-28-visible-wip-overflow-queues.md`](../../decisions/2026-07-28-visible-wip-overflow-queues.md). +Destination queue admission for task moves: +[`../../decisions/2026-08-12-queue-task-moves-at-wip-capacity.md`](../../decisions/2026-08-12-queue-task-moves-at-wip-capacity.md). + ## Implementation plan See @@ -316,3 +388,6 @@ See Auto-start idempotency repair (single agent per review task): [`../../plans/review-watcher-double-autostart/plan.md`](../../plans/review-watcher-double-autostart/plan.md). + +Queued task moves and queue presentation: +[`../../plans/queued-task-moves/plan.md`](../../plans/queued-task-moves/plan.md).