diff --git a/apps/backend/config/prompts/config-context.md b/apps/backend/config/prompts/config-context.md index 0d99a2b573..a6e5b57b59 100644 --- a/apps/backend/config/prompts/config-context.md +++ b/apps/backend/config/prompts/config-context.md @@ -11,10 +11,10 @@ WORKFLOW TOOLS: - update_workflow_kandev: Update a workflow. Required: workflow_id. Optional: name, description. - delete_workflow_kandev: Delete a workflow and all its steps (destructive). Required: workflow_id. - import_workflow_kandev: Import one or more workflows into a workspace from a portable document (the kandev_workflow YAML/JSON export envelope). Workflows whose name already exists are skipped. Required: workspace_id, document. Returns the created and skipped workflow names. -- export_workflow_kandev: Export one workflow as a portable version 1 kandev_workflow JSON document. Required: workflow_id. Pass the returned JSON unchanged as document to import_workflow_kandev. +- export_workflow_kandev: Export one workflow as a portable version 2 kandev_workflow JSON document. Required: workflow_id. Pass the returned JSON unchanged as document to import_workflow_kandev. Version 1 documents remain accepted for compatibility. - list_workflow_steps_kandev: List workflow steps (columns) in a workflow. Required: workflow_id. -- create_workflow_step_kandev: Create a new workflow step. Required: workflow_id, name. Optional: position, color, prompt, is_start_step, allow_manual_move, show_in_command_panel, auto_advance_requires_signal, cancel_triggers_turn_complete, events. -- update_workflow_step_kandev: Update a workflow step. Required: step_id. Optional: name, color, prompt, is_start_step, allow_manual_move, show_in_command_panel, auto_archive_after_hours, auto_advance_requires_signal, cancel_triggers_turn_complete, events. +- create_workflow_step_kandev: Create a new workflow step. Required: workflow_id, name. Optional: position, color, prompt, is_start_step, allow_manual_move, show_in_command_panel, complete_task_on_enter, auto_advance_requires_signal, cancel_triggers_turn_complete, events. +- update_workflow_step_kandev: Update a workflow step. Required: step_id. Optional: name, color, prompt, is_start_step, allow_manual_move, show_in_command_panel, auto_archive_after_hours, complete_task_on_enter, auto_advance_requires_signal, cancel_triggers_turn_complete, events. - cancel_triggers_turn_complete: When true, an explicit user cancellation runs the step's normal on_turn_complete actions after the cancelled turn settles. It does not apply to internal or agent cancellations. - delete_workflow_step_kandev: Delete a workflow step (destructive). Required: step_id. - reorder_workflow_steps_kandev: Reorder workflow steps. Required: workflow_id, step_ids (ordered array of step IDs). diff --git a/apps/backend/config/workflows/architecture.yml b/apps/backend/config/workflows/architecture.yml index e629d3f32c..1c06ba259d 100644 --- a/apps/backend/config/workflows/architecture.yml +++ b/apps/backend/config/workflows/architecture.yml @@ -75,4 +75,5 @@ steps: name: Approved position: 3 color: bg-green-500 + complete_task_on_enter: true allow_manual_move: true diff --git a/apps/backend/config/workflows/feature-dev.yml b/apps/backend/config/workflows/feature-dev.yml index f4db8fd9d5..d5875341f2 100644 --- a/apps/backend/config/workflows/feature-dev.yml +++ b/apps/backend/config/workflows/feature-dev.yml @@ -349,4 +349,5 @@ steps: name: Done position: 7 color: bg-green-500 + complete_task_on_enter: true allow_manual_move: true diff --git a/apps/backend/config/workflows/kanban.yml b/apps/backend/config/workflows/kanban.yml index 24820ed8b5..5b7f9f36cf 100644 --- a/apps/backend/config/workflows/kanban.yml +++ b/apps/backend/config/workflows/kanban.yml @@ -54,6 +54,7 @@ steps: name: Done position: 3 color: bg-green-500 + complete_task_on_enter: true allow_manual_move: true events: on_turn_start: diff --git a/apps/backend/config/workflows/loader.go b/apps/backend/config/workflows/loader.go index f781c6ee19..31f3678ab4 100644 --- a/apps/backend/config/workflows/loader.go +++ b/apps/backend/config/workflows/loader.go @@ -39,6 +39,7 @@ type stepDefYAML struct { ProfileSessionEndPolicy taskmodels.WorkflowProfileSessionEndPolicy `yaml:"profile_session_end_policy,omitempty"` AutoAdvanceRequiresSignal bool `yaml:"auto_advance_requires_signal,omitempty"` CancelTriggersTurnComplete bool `yaml:"cancel_triggers_turn_complete,omitempty"` + CompleteTaskOnEnter bool `yaml:"complete_task_on_enter,omitempty"` Events stepEventsYAML `yaml:"events,omitempty"` } @@ -183,6 +184,7 @@ func convertStep(s stepDefYAML) (models.StepDefinition, error) { ProfileSessionEndPolicy: taskmodels.NormalizeWorkflowProfileSessionEndPolicy(string(s.ProfileSessionEndPolicy)), AutoAdvanceRequiresSignal: s.AutoAdvanceRequiresSignal, CancelTriggersTurnComplete: s.CancelTriggersTurnComplete, + CompleteTaskOnEnter: s.CompleteTaskOnEnter, StageType: stage, }, nil } diff --git a/apps/backend/config/workflows/loader_test.go b/apps/backend/config/workflows/loader_test.go index c75610bc79..bee62a6b66 100644 --- a/apps/backend/config/workflows/loader_test.go +++ b/apps/backend/config/workflows/loader_test.go @@ -61,6 +61,32 @@ func TestLoadTemplates_CancelTriggersTurnCompleteDefaults(t *testing.T) { } } +func TestLoadTemplates_CompletionPolicyUsesExplicitFinalStepSetting(t *testing.T) { + templates, err := LoadTemplates() + if err != nil { + t.Fatalf("LoadTemplates() returned error: %v", err) + } + for _, template := range templates { + for _, step := range template.Steps { + want := template.ID != "improve-kandev" && template.ID != "report-kandev-issue" && + step.Position == lastTemplatePosition(template.Steps) + if step.CompleteTaskOnEnter != want { + t.Errorf("template %q step %q completion policy = %t, want %t", template.ID, step.Name, step.CompleteTaskOnEnter, want) + } + } + } +} + +func lastTemplatePosition(steps []models.StepDefinition) int { + last := 0 + for i, step := range steps { + if i == 0 || step.Position > last { + last = step.Position + } + } + return last +} + func TestLoadTemplates_AllValid(t *testing.T) { templates, err := LoadTemplates() if err != nil { diff --git a/apps/backend/config/workflows/office-default.yml b/apps/backend/config/workflows/office-default.yml index fe3ba43876..997a9ae109 100644 --- a/apps/backend/config/workflows/office-default.yml +++ b/apps/backend/config/workflows/office-default.yml @@ -144,6 +144,7 @@ steps: position: 4 color: bg-green-500 stage_type: custom + complete_task_on_enter: true allow_manual_move: true show_in_command_panel: true events: diff --git a/apps/backend/config/workflows/plan-and-build.yml b/apps/backend/config/workflows/plan-and-build.yml index 02489fd1a1..4b8c9082d5 100644 --- a/apps/backend/config/workflows/plan-and-build.yml +++ b/apps/backend/config/workflows/plan-and-build.yml @@ -117,4 +117,5 @@ steps: name: Done position: 3 color: bg-green-500 + complete_task_on_enter: true allow_manual_move: true diff --git a/apps/backend/config/workflows/pr-review.yml b/apps/backend/config/workflows/pr-review.yml index 1297936bb6..61d7e882e1 100644 --- a/apps/backend/config/workflows/pr-review.yml +++ b/apps/backend/config/workflows/pr-review.yml @@ -169,4 +169,5 @@ steps: name: Done position: 2 color: bg-green-500 + complete_task_on_enter: true allow_manual_move: true diff --git a/apps/backend/config/workflows/routine.yml b/apps/backend/config/workflows/routine.yml index 6a2a08bf8f..23ed9141f0 100644 --- a/apps/backend/config/workflows/routine.yml +++ b/apps/backend/config/workflows/routine.yml @@ -27,5 +27,6 @@ steps: name: Done position: 1 color: bg-green-500 + complete_task_on_enter: true show_in_command_panel: false allow_manual_move: false diff --git a/apps/backend/internal/mcp/handlers/config_workflow_handlers.go b/apps/backend/internal/mcp/handlers/config_workflow_handlers.go index 92bc19cceb..25ba857b9e 100644 --- a/apps/backend/internal/mcp/handlers/config_workflow_handlers.go +++ b/apps/backend/internal/mcp/handlers/config_workflow_handlers.go @@ -1,8 +1,10 @@ package handlers import ( + "bytes" "context" "encoding/json" + "fmt" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/task/service" @@ -162,6 +164,7 @@ func (h *Handlers) handleCreateWorkflowStep(ctx context.Context, msg *ws.Message ShowInCommandPanel *bool `json:"show_in_command_panel"` AutoAdvanceRequiresSignal *bool `json:"auto_advance_requires_signal"` CancelTriggersTurnComplete *bool `json:"cancel_triggers_turn_complete"` + CompleteTaskOnEnter json.RawMessage `json:"complete_task_on_enter"` WIPLimit *int `json:"wip_limit"` PullFromStepID *string `json:"pull_from_step_id"` Events *wfmodels.StepEvents `json:"events"` @@ -169,6 +172,10 @@ func (h *Handlers) handleCreateWorkflowStep(ctx context.Context, msg *ws.Message if err := json.Unmarshal(msg.Payload, &req); err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) } + completeTaskOnEnter, err := optionalCompleteTaskOnEnter(req.CompleteTaskOnEnter) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } if req.WorkflowID == "" { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workflow_id is required", nil) } @@ -189,6 +196,7 @@ func (h *Handlers) handleCreateWorkflowStep(ctx context.Context, msg *ws.Message ShowInCommandPanel: req.ShowInCommandPanel, AutoAdvanceRequiresSignal: req.AutoAdvanceRequiresSignal, CancelTriggersTurnComplete: req.CancelTriggersTurnComplete, + CompleteTaskOnEnter: completeTaskOnEnter, WIPLimit: req.WIPLimit, PullFromStepID: req.PullFromStepID, Events: req.Events, @@ -207,6 +215,20 @@ func (h *Handlers) handleCreateWorkflowStep(ctx context.Context, msg *ws.Message return ws.NewResponse(msg.ID, msg.Action, resp) } +func optionalCompleteTaskOnEnter(raw json.RawMessage) (*bool, error) { + if len(raw) == 0 { + return nil, nil + } + if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return nil, fmt.Errorf("complete_task_on_enter must be a boolean") + } + var value bool + if err := json.Unmarshal(raw, &value); err != nil { + return nil, err + } + return &value, nil +} + func (h *Handlers) handleUpdateWorkflowStep(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req struct { StepID string `json:"step_id"` @@ -222,6 +244,7 @@ func (h *Handlers) handleUpdateWorkflowStep(ctx context.Context, msg *ws.Message AutoArchiveAfterHours *int `json:"auto_archive_after_hours"` AutoAdvanceRequiresSignal *bool `json:"auto_advance_requires_signal"` CancelTriggersTurnComplete *bool `json:"cancel_triggers_turn_complete"` + CompleteTaskOnEnter json.RawMessage `json:"complete_task_on_enter"` WIPLimit *int `json:"wip_limit"` PullFromStepID *string `json:"pull_from_step_id"` Events *wfmodels.StepEvents `json:"events"` @@ -229,6 +252,10 @@ func (h *Handlers) handleUpdateWorkflowStep(ctx context.Context, msg *ws.Message if err := json.Unmarshal(msg.Payload, &req); err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) } + completeTaskOnEnter, err := optionalCompleteTaskOnEnter(req.CompleteTaskOnEnter) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } if req.StepID == "" { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "step_id is required", nil) } @@ -247,6 +274,7 @@ func (h *Handlers) handleUpdateWorkflowStep(ctx context.Context, msg *ws.Message AutoArchiveAfterHours: req.AutoArchiveAfterHours, AutoAdvanceRequiresSignal: req.AutoAdvanceRequiresSignal, CancelTriggersTurnComplete: req.CancelTriggersTurnComplete, + CompleteTaskOnEnter: completeTaskOnEnter, WIPLimit: req.WIPLimit, PullFromStepID: req.PullFromStepID, Events: req.Events, diff --git a/apps/backend/internal/mcp/handlers/config_workflow_handlers_test.go b/apps/backend/internal/mcp/handlers/config_workflow_handlers_test.go index 498472e3d1..efc7354301 100644 --- a/apps/backend/internal/mcp/handlers/config_workflow_handlers_test.go +++ b/apps/backend/internal/mcp/handlers/config_workflow_handlers_test.go @@ -216,6 +216,23 @@ func TestHandleCreateWorkflowStep_PersistsCancelTriggersTurnComplete(t *testing. assert.True(t, steps[0].CancelTriggersTurnComplete) } +func TestHandleCreateWorkflowStep_RejectsNullCompletionPolicy(t *testing.T) { + h, _, repo := setupImportHandlers(t) + ctx := context.Background() + h.workflowCtrl = workflowctrl.NewController(h.workflowSvc) + + resp, err := h.handleCreateWorkflowStep(ctx, makeWSMessage(t, ws.ActionMCPCreateWorkflowStep, map[string]interface{}{ + "workflow_id": "wf-test", + "name": "Null completion", + "complete_task_on_enter": nil, + })) + require.NoError(t, err) + assertWSError(t, resp, ws.ErrorCodeBadRequest) + steps, err := repo.ListStepsByWorkflow(ctx, "wf-test") + require.NoError(t, err) + require.Empty(t, steps) +} + func TestHandleUpdateWorkflowStep_PublishesDemotedStartStep(t *testing.T) { h, _, repo := setupImportHandlers(t) ctx := context.Background() @@ -316,6 +333,29 @@ func TestHandleUpdateWorkflowStep_PersistsCancelTriggersTurnCompleteFalse(t *tes assert.False(t, step.CancelTriggersTurnComplete) } +func TestHandleUpdateWorkflowStep_RejectsNullCompletionPolicyWithoutMutation(t *testing.T) { + h, _, repo := setupImportHandlers(t) + ctx := context.Background() + h.workflowCtrl = workflowctrl.NewController(h.workflowSvc) + require.NoError(t, repo.CreateStep(ctx, &wfmodels.WorkflowStep{ + ID: "completed-step", + WorkflowID: "wf-test", + Name: "Done", + Position: 0, + CompleteTaskOnEnter: true, + })) + + resp, err := h.handleUpdateWorkflowStep(ctx, makeWSMessage(t, ws.ActionMCPUpdateWorkflowStep, map[string]interface{}{ + "step_id": "completed-step", + "complete_task_on_enter": nil, + })) + require.NoError(t, err) + assertWSError(t, resp, ws.ErrorCodeBadRequest) + step, err := repo.GetStep(ctx, "completed-step") + require.NoError(t, err) + require.True(t, step.CompleteTaskOnEnter) +} + func TestHandleListWorkflowSteps_IncludesAutoAdvanceRequiresSignal(t *testing.T) { h, _, repo := setupImportHandlers(t) ctx := context.Background() diff --git a/apps/backend/internal/mcp/handlers/handlers.go b/apps/backend/internal/mcp/handlers/handlers.go index 3fa335ee36..9587077c1d 100644 --- a/apps/backend/internal/mcp/handlers/handlers.go +++ b/apps/backend/internal/mcp/handlers/handlers.go @@ -3198,6 +3198,15 @@ func (h *Handlers) dispatchTaskMessage(ctx context.Context, taskID string, sessi switch session.State { case models.TaskSessionStateFailed, models.TaskSessionStateCancelled: return taskMessageDispatchResult{}, terminalSessionDispatchError(session) + case models.TaskSessionStateCompleted: + if !pinnedTarget { + return taskMessageDispatchResult{}, terminalSessionDispatchError(session) + } + resumed, err := h.resumeCompletedTaskMessageSession(ctx, taskID, session.ID) + if err != nil { + return taskMessageDispatchResult{}, err + } + return h.dispatchPreparedTaskMessage(ctx, taskID, resumed, prompt, metadata) case models.TaskSessionStateRunning, models.TaskSessionStateStarting: if interruptIfBusy { @@ -3249,6 +3258,33 @@ func (h *Handlers) dispatchTaskMessage(ctx context.Context, taskID string, sessi } } +// resumeCompletedTaskMessageSession admits a pinned completed target through +// the same guarded resume path as the user-facing recovery action. The session +// is reloaded before the message is recorded so the prompt cannot be attached +// to a different session or to the pre-resume terminal snapshot. +func (h *Handlers) resumeCompletedTaskMessageSession(ctx context.Context, taskID, sessionID string) (*models.TaskSession, error) { + _, err := h.sessionLauncher.LaunchSession(ctx, &orchestrator.LaunchSessionRequest{ + TaskID: taskID, + SessionID: sessionID, + Intent: orchestrator.IntentResume, + AllowCompletedSessionResume: true, + }) + if err != nil { + return nil, fmt.Errorf("failed to resume completed session: %w", err) + } + resumed, err := h.taskSvc.GetTaskSession(ctx, sessionID) + if err != nil { + return nil, fmt.Errorf("failed to reload completed session after resume: %w", err) + } + if resumed == nil || resumed.TaskID != taskID { + return nil, errors.New("completed session was not available after resume") + } + if resumed.State == models.TaskSessionStateCompleted { + return nil, errors.New("completed session did not become ready for a follow-up message") + } + return resumed, nil +} + func (h *Handlers) dispatchPreparedTaskMessage(ctx context.Context, taskID string, session *models.TaskSession, prompt string, metadata map[string]interface{}) (taskMessageDispatchResult, error) { switch session.State { case models.TaskSessionStateFailed, models.TaskSessionStateCancelled: diff --git a/apps/backend/internal/mcp/handlers/message_target_session.go b/apps/backend/internal/mcp/handlers/message_target_session.go index 5aeedcc3a9..571c9cceb5 100644 --- a/apps/backend/internal/mcp/handlers/message_target_session.go +++ b/apps/backend/internal/mcp/handlers/message_target_session.go @@ -98,6 +98,13 @@ func (h *Handlers) resolveMessageTargetSession( // business rewriting as a side effect. return primary, true, nil } + completed, completedErr := h.newestCompletedSession(ctx, taskID) + if completedErr != nil { + return nil, false, wsError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "failed to list completed sessions for task: "+completedErr.Error()) + } + if completed != nil { + return completed, true, nil + } return nil, false, wsError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "target task exists but has no active session — use spawn_session_kandev to start one") } @@ -118,6 +125,23 @@ func (h *Handlers) newestLiveSession(ctx context.Context, taskID string) (*model return nil, nil } +// newestCompletedSession returns the most recently started completed session +// on a task. It is used only when the task has no primary and no live session; +// a completed primary remains a dedicated terminal target so FAILED/CANCELLED +// dispatch behavior is not changed. +func (h *Handlers) newestCompletedSession(ctx context.Context, taskID string) (*models.TaskSession, error) { + sessions, err := h.taskSvc.ListTaskSessions(ctx, taskID) + if err != nil { + return nil, err + } + for _, session := range sessions { + if session != nil && session.State == models.TaskSessionStateCompleted { + return session, nil + } + } + return nil, nil +} + // resolveExplicitTargetSession loads and validates a caller-named target // session for message_task. Only genuine no-row lookups are NotFound; // transient DB errors surface as internal so callers keep retrying instead of diff --git a/apps/backend/internal/mcp/handlers/message_target_session_test.go b/apps/backend/internal/mcp/handlers/message_target_session_test.go index 1361c59dfc..32e1f6c87e 100644 --- a/apps/backend/internal/mcp/handlers/message_target_session_test.go +++ b/apps/backend/internal/mcp/handlers/message_target_session_test.go @@ -306,6 +306,34 @@ func TestHandleMessageTask_CompletedSiblingDoesNotShadowTheLiveFallback(t *testi assert.Equal(t, 0, orch.queue.GetStatus(context.Background(), retired.ID).Count) } +// A task with no primary and no live session can still address its newest +// completed conversation. The resolver pins that historical choice so the +// dispatcher can perform explicit completed-session recovery on the same row. +func TestResolveMessageTargetSession_NoPrimaryUsesNewestCompletedSession(t *testing.T) { + svc, repo := newTestTaskService(t) + ctx := context.Background() + require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-completed-fallback", Name: "Test"})) + require.NoError(t, repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-completed-fallback", WorkspaceID: "ws-completed-fallback", Name: "Board"})) + targetResult, err := svc.CreateTask(ctx, &service.CreateTaskRequest{ + WorkspaceID: "ws-completed-fallback", WorkflowID: "wf-completed-fallback", Title: "Target", + }) + require.NoError(t, err) + older := addSession(t, repo, targetResult.Task.ID, "sess-completed-older", models.TaskSessionStateCompleted, 1) + newer := addSession(t, repo, targetResult.Task.ID, "sess-completed-newer", models.TaskSessionStateCompleted, 2) + require.NotEqual(t, older.ID, newer.ID) + + h, _ := newMessageTaskHandler(t, svc, repo) + msg := makeWSMessage(t, ws.ActionMCPMessageTask, map[string]interface{}{ + "task_id": targetResult.Task.ID, + "prompt": "continue the conversation", + }) + session, pinned, errResp := h.resolveMessageTargetSession(ctx, msg, targetResult.Task.ID, "") + require.Nil(t, errResp) + require.NotNil(t, session) + assert.Equal(t, newer.ID, session.ID) + assert.True(t, pinned) +} + // indexOfSession reports where a session lands in the resolver's walk order. func indexOfSession(t *testing.T, sessions []*models.TaskSession, sessionID string) int { t.Helper() diff --git a/apps/backend/internal/mcp/handlers/message_task_test.go b/apps/backend/internal/mcp/handlers/message_task_test.go index 69e7c0bcf5..fc02c625bd 100644 --- a/apps/backend/internal/mcp/handlers/message_task_test.go +++ b/apps/backend/internal/mcp/handlers/message_task_test.go @@ -17,6 +17,7 @@ import ( promptservice "github.com/kandev/kandev/internal/prompts/service" "github.com/kandev/kandev/internal/steptelemetry" "github.com/kandev/kandev/internal/task/models" + sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" "github.com/kandev/kandev/internal/task/service" v1 "github.com/kandev/kandev/pkg/api/v1" ws "github.com/kandev/kandev/pkg/websocket" @@ -41,6 +42,7 @@ type fakeOrchestrator struct { interruptCalls []interruptCall launchCalls []*orchestrator.LaunchSessionRequest launchErr error + launchFunc func(context.Context, *orchestrator.LaunchSessionRequest) (*orchestrator.LaunchSessionResponse, error) launchResponseProfileID string renameCalls []renameCall renameErr error @@ -105,13 +107,18 @@ type renameCall struct { sessionID, name string } -func (f *fakeOrchestrator) LaunchSession(_ context.Context, req *orchestrator.LaunchSessionRequest) (*orchestrator.LaunchSessionResponse, error) { +func (f *fakeOrchestrator) LaunchSession(ctx context.Context, req *orchestrator.LaunchSessionRequest) (*orchestrator.LaunchSessionResponse, error) { f.mu.Lock() - defer f.mu.Unlock() f.launchCalls = append(f.launchCalls, req) + launchFunc := f.launchFunc if f.launchErr != nil { + f.mu.Unlock() return nil, f.launchErr } + f.mu.Unlock() + if launchFunc != nil { + return launchFunc(ctx, req) + } response := &orchestrator.LaunchSessionResponse{ Success: true, TaskID: req.TaskID, @@ -126,6 +133,34 @@ func (f *fakeOrchestrator) LaunchSession(_ context.Context, req *orchestrator.La return response, nil } +func completedSessionLaunchFunc( + t *testing.T, + repo *sqliterepo.Repository, + taskID, sessionID string, +) func(context.Context, *orchestrator.LaunchSessionRequest) (*orchestrator.LaunchSessionResponse, error) { + t.Helper() + return func(ctx context.Context, req *orchestrator.LaunchSessionRequest) (*orchestrator.LaunchSessionResponse, error) { + require.Equal(t, orchestrator.IntentResume, req.Intent) + require.True(t, req.AllowCompletedSessionResume) + require.Equal(t, taskID, req.TaskID) + require.Equal(t, sessionID, req.SessionID) + require.NoError(t, repo.UpdateTaskSessionState(ctx, sessionID, models.TaskSessionStateWaitingForInput, "")) + require.NoError(t, repo.SetSessionMetadataKey(ctx, sessionID, models.SessionMetaKeyCompletionFollowUp, true)) + require.NoError(t, repo.UpsertExecutorRunning(ctx, &models.ExecutorRunning{ + ID: "exec-row-" + sessionID, + SessionID: sessionID, + TaskID: taskID, + Status: "running", + Resumable: true, + AgentExecutionID: "exec-" + sessionID, + })) + return &orchestrator.LaunchSessionResponse{ + Success: true, TaskID: taskID, SessionID: sessionID, + State: string(models.TaskSessionStateWaitingForInput), + }, nil + } +} + func (f *fakeOrchestrator) PromptTask(_ context.Context, taskID, sessionID, prompt, _ string, _ bool, _ []v1.MessageAttachment, dispatchOnly bool) (*orchestrator.PromptResult, error) { f.mu.Lock() defer f.mu.Unlock() @@ -576,6 +611,57 @@ func TestHandleMessageTask_QueuesPromptWhenOnTurnStartQueuesTask(t *testing.T) { assert.Contains(t, status.Entries[0].Content, "wait for admission") } +func TestHandleMessageTask_CompletedSessionResumesBeforePersistingPrompt(t *testing.T) { + svc, repo := newTestTaskService(t) + sender, target, session := seedTaskWithSession(t, svc, repo, models.TaskSessionStateCompleted) + ctx := context.Background() + require.NoError(t, repo.UpdateTaskState(ctx, target.ID, v1.TaskStateCompleted)) + + h, orch := newMessageTaskHandler(t, svc, repo) + orch.launchFunc = func(ctx context.Context, req *orchestrator.LaunchSessionRequest) (*orchestrator.LaunchSessionResponse, error) { + require.Equal(t, orchestrator.IntentResume, req.Intent) + require.True(t, req.AllowCompletedSessionResume) + require.Equal(t, target.ID, req.TaskID) + require.Equal(t, session.ID, req.SessionID) + require.NoError(t, repo.UpdateTaskSessionState(ctx, session.ID, models.TaskSessionStateWaitingForInput, "")) + require.NoError(t, repo.UpsertExecutorRunning(ctx, &models.ExecutorRunning{ + ID: "exec-row-" + session.ID, + SessionID: session.ID, + TaskID: target.ID, + Status: "running", + Resumable: true, + AgentExecutionID: "exec-" + session.ID, + })) + return &orchestrator.LaunchSessionResponse{ + Success: true, TaskID: target.ID, SessionID: session.ID, + State: string(models.TaskSessionStateWaitingForInput), + }, nil + } + + msg := makeWSMessage(t, ws.ActionMCPMessageTask, + senderPayload(target.ID, "continue the conversation", sender.ID)) + resp, err := h.handleMessageTask(ctx, msg) + require.NoError(t, err) + require.Equal(t, ws.MessageTypeResponse, resp.Type) + + var out map[string]interface{} + require.NoError(t, json.Unmarshal(resp.Payload, &out)) + assert.Equal(t, session.ID, out["session_id"]) + assert.Equal(t, taskMessageStatusSent, out[stopTaskStatusKey]) + require.Len(t, orch.launchCalls, 1) + require.Len(t, orch.promptCalls, 1) + assert.Equal(t, session.ID, orch.promptCalls[0].sessionID) + assert.Equal(t, 0, orch.resumeCalls, "completed follow-up must use the explicit launch permission") + + messages, err := svc.ListMessages(ctx, session.ID) + require.NoError(t, err) + require.Len(t, messages, 1) + assert.Contains(t, messages[0].Content, "continue the conversation") + reloadedTask, err := svc.GetTask(ctx, target.ID) + require.NoError(t, err) + assert.Equal(t, v1.TaskStateCompleted, reloadedTask.State, "conversation follow-up must not reopen task work") +} + func TestHandleMessageTask_SessionIDWrongTask_Rejected(t *testing.T) { svc, repo := newTestTaskService(t) sender, target, sess := seedTaskWithSession(t, svc, repo, models.TaskSessionStateRunning) @@ -1181,8 +1267,10 @@ func TestHandleMessageTask_CompletedSessionWithoutSwitch_PromptsSameSession(t *t ctx := context.Background() svc, repo := newTestTaskService(t) sender, target, sess := seedTaskWithSession(t, svc, repo, models.TaskSessionStateCompleted) + require.NoError(t, repo.UpdateTaskState(ctx, target.ID, v1.TaskStateCompleted)) h, orch := newMessageTaskHandler(t, svc, repo) + orch.launchFunc = completedSessionLaunchFunc(t, repo, target.ID, sess.ID) msg := makeWSMessage(t, ws.ActionMCPMessageTask, senderPayload(target.ID, "follow up completed", sender.ID)) resp, err := h.handleMessageTask(ctx, msg) @@ -1205,27 +1293,16 @@ func TestHandleMessageTask_CompletedSessionWithoutSwitch_PromptsSameSession(t *t assert.Equal(t, sender.ID, messages[0].Metadata["sender_task_id"]) } -func TestHandleMessageTask_CompletedSession_UsesSessionSelectedByTurnStart(t *testing.T) { +func TestHandleMessageTask_CompletedSession_DoesNotReplayTurnStart(t *testing.T) { ctx := context.Background() svc, repo := newTestTaskService(t) sender, target, sess := seedTaskWithSession(t, svc, repo, models.TaskSessionStateCompleted) - - replacement := &models.TaskSession{ - ID: "sess-2", - TaskID: target.ID, - AgentProfileID: "agent-profile-2", - State: models.TaskSessionStateWaitingForInput, - IsPrimary: false, - } - require.NoError(t, repo.CreateTaskSession(ctx, replacement)) + require.NoError(t, repo.UpdateTaskState(ctx, target.ID, v1.TaskStateCompleted)) h, orch := newMessageTaskHandler(t, svc, repo) - orch.onTurnStart = func(ctx context.Context, _, _ string) error { - oldSession, err := svc.GetTaskSession(ctx, sess.ID) - require.NoError(t, err) - oldSession.IsPrimary = false - require.NoError(t, repo.UpdateTaskSession(ctx, oldSession)) - require.NoError(t, repo.SetSessionPrimary(ctx, replacement.ID)) + orch.launchFunc = completedSessionLaunchFunc(t, repo, target.ID, sess.ID) + orch.onTurnStart = func(context.Context, string, string) error { + t.Fatal("completed follow-up must resume before on_turn_start") return nil } @@ -1237,21 +1314,18 @@ func TestHandleMessageTask_CompletedSession_UsesSessionSelectedByTurnStart(t *te var payload map[string]interface{} require.NoError(t, json.Unmarshal(resp.Payload, &payload)) - assert.Equal(t, "started", payload["status"]) - assert.Equal(t, replacement.ID, payload["session_id"]) + assert.Equal(t, "sent", payload["status"]) + assert.Equal(t, sess.ID, payload["session_id"]) - require.Len(t, orch.startCreatedCalls, 1) - assert.Equal(t, replacement.ID, orch.startCreatedCalls[0].sessionID) - assert.Empty(t, orch.promptCalls) + assert.Empty(t, orch.startCreatedCalls) + require.Len(t, orch.promptCalls, 1) + assert.Equal(t, sess.ID, orch.promptCalls[0].sessionID) oldMessages, err := svc.ListMessages(ctx, sess.ID) require.NoError(t, err) - assert.Empty(t, oldMessages) - newMessages, err := svc.ListMessages(ctx, replacement.ID) - require.NoError(t, err) - require.Len(t, newMessages, 1) - assert.Contains(t, newMessages[0].Content, "handoff from completed") - assert.Equal(t, sender.ID, newMessages[0].Metadata["sender_task_id"]) + require.Len(t, oldMessages, 1) + assert.Contains(t, oldMessages[0].Content, "handoff from completed") + assert.Equal(t, sender.ID, oldMessages[0].Metadata["sender_task_id"]) } func TestHandleMessageTask_WaitingForInputCompletedWithoutPrimarySwitchRejects(t *testing.T) { diff --git a/apps/backend/internal/mcp/server/config_handlers.go b/apps/backend/internal/mcp/server/config_handlers.go index 3ef7d4346b..8e04cba912 100644 --- a/apps/backend/internal/mcp/server/config_handlers.go +++ b/apps/backend/internal/mcp/server/config_handlers.go @@ -63,7 +63,7 @@ func (s *Server) registerConfigWorkflowTools() { ) s.mcpServer.AddTool( mcp.NewTool("import_workflow_kandev", - mcp.WithDescription("Import one or more workflows into a workspace from a portable document. The document is the same YAML/JSON envelope produced by the workflow export (type: kandev_workflow, version: 1) and may contain multiple workflows. Workflows whose name already exists in the workspace are skipped. Returns the names that were created and skipped."), + mcp.WithDescription("Import one or more workflows into a workspace from a portable document. The document is the same YAML/JSON envelope produced by the workflow export (type: kandev_workflow, version: 2; version 1 is accepted) and may contain multiple workflows. Workflows whose name already exists in the workspace are skipped. Returns the names that were created and skipped."), mcp.WithString("workspace_id", mcp.Required(), mcp.Description("The workspace ID to import the workflows into")), mcp.WithString(documentArg, mcp.Required(), mcp.Description("The portable workflow document as a YAML or JSON string (a kandev_workflow export envelope). Includes the workflows and their steps.")), ), @@ -71,7 +71,7 @@ func (s *Server) registerConfigWorkflowTools() { ) s.mcpServer.AddTool( mcp.NewTool("export_workflow_kandev", - mcp.WithDescription("Export one workflow as a portable version 1 kandev_workflow JSON document. The result contains one workflow and its steps without instance IDs or timestamps. Pass the JSON text unchanged as document to import_workflow_kandev (the import document limit is 1 MiB)."), + mcp.WithDescription("Export one workflow as a portable version 2 kandev_workflow JSON document. The result contains one workflow and its steps without instance IDs or timestamps. Pass the JSON text unchanged as document to import_workflow_kandev (the import document limit is 1 MiB)."), mcp.WithReadOnlyHintAnnotation(true), mcp.WithDestructiveHintAnnotation(false), mcp.WithIdempotentHintAnnotation(true), @@ -107,6 +107,7 @@ func (s *Server) registerConfigWorkflowStepTools() { mcp.WithBoolean("show_in_command_panel", mcp.Description("Show this step in the command panel")), mcp.WithBoolean("auto_advance_requires_signal", mcp.Description("Require step_complete_kandev before on_turn_complete auto-advance transitions run")), mcp.WithBoolean("cancel_triggers_turn_complete", mcp.Description("Run on_turn_complete actions when an explicit user cancellation occurs")), + mcp.WithBoolean("complete_task_on_enter", mcp.Description("Complete the task when it enters this final workflow step")), mcp.WithNumber("wip_limit", mcp.Description("Work-in-progress limit for this step. 0 means unlimited.")), mcp.WithString("pull_from_step_id", mcp.Description("Optional feeder workflow step ID to pull from when capacity opens.")), mcp.WithObject("events", mcp.Description("Event-driven actions. Keys: on_enter, on_exit, on_turn_start, on_turn_complete. Each is an array of {type, config} objects.")), @@ -129,6 +130,7 @@ func (s *Server) registerConfigWorkflowStepTools() { mcp.WithNumber("auto_archive_after_hours", mcp.Description("Auto-archive tasks after N hours in this step (0 to disable)")), mcp.WithBoolean("auto_advance_requires_signal", mcp.Description("Require step_complete_kandev before on_turn_complete auto-advance transitions run")), mcp.WithBoolean("cancel_triggers_turn_complete", mcp.Description("Run on_turn_complete actions when an explicit user cancellation occurs")), + mcp.WithBoolean("complete_task_on_enter", mcp.Description("Complete the task when it enters this final workflow step")), mcp.WithNumber("wip_limit", mcp.Description("Work-in-progress limit for this step. 0 means unlimited.")), mcp.WithString("pull_from_step_id", mcp.Description("Optional feeder workflow step ID to pull from when capacity opens.")), mcp.WithObject("events", mcp.Description("Event-driven actions. Keys: on_enter, on_exit, on_turn_start, on_turn_complete.")), @@ -496,7 +498,7 @@ func (s *Server) createWorkflowStepHandler() server.ToolHandlerFunc { payload["prompt"] = prompt } args := req.GetArguments() - for _, key := range []string{"position", "is_start_step", "allow_manual_move", "show_in_command_panel", "agent_profile_id", "profile_session_start_policy", "profile_session_end_policy", "auto_advance_requires_signal", "cancel_triggers_turn_complete", "wip_limit", "pull_from_step_id", "events"} { + for _, key := range []string{"position", "is_start_step", "allow_manual_move", "show_in_command_panel", "agent_profile_id", "profile_session_start_policy", "profile_session_end_policy", "auto_advance_requires_signal", "cancel_triggers_turn_complete", "complete_task_on_enter", "wip_limit", "pull_from_step_id", "events"} { if args[key] != nil { payload[key] = args[key] } @@ -522,7 +524,7 @@ func (s *Server) updateWorkflowStepHandler() server.ToolHandlerFunc { payload["prompt"] = prompt } args := req.GetArguments() - for _, key := range []string{"is_start_step", "allow_manual_move", "show_in_command_panel", "agent_profile_id", "profile_session_start_policy", "profile_session_end_policy", "auto_archive_after_hours", "auto_advance_requires_signal", "cancel_triggers_turn_complete", "wip_limit", "pull_from_step_id", "events"} { + for _, key := range []string{"is_start_step", "allow_manual_move", "show_in_command_panel", "agent_profile_id", "profile_session_start_policy", "profile_session_end_policy", "auto_archive_after_hours", "auto_advance_requires_signal", "cancel_triggers_turn_complete", "complete_task_on_enter", "wip_limit", "pull_from_step_id", "events"} { if args[key] != nil { payload[key] = args[key] } diff --git a/apps/backend/internal/orchestrator/ci_automation_dispatch.go b/apps/backend/internal/orchestrator/ci_automation_dispatch.go index b4fca6c7b3..4949360f86 100644 --- a/apps/backend/internal/orchestrator/ci_automation_dispatch.go +++ b/apps/backend/internal/orchestrator/ci_automation_dispatch.go @@ -234,6 +234,9 @@ func ciAutomationSessionCanReceivePrompt(session *models.TaskSession) bool { if session == nil { return false } + if models.IsCompletionFollowUpSession(session.Metadata) { + return false + } switch session.State { case models.TaskSessionStateCreated, models.TaskSessionStateStarting, diff --git a/apps/backend/internal/orchestrator/completed_session_resume_test.go b/apps/backend/internal/orchestrator/completed_session_resume_test.go new file mode 100644 index 0000000000..5cbcb62268 --- /dev/null +++ b/apps/backend/internal/orchestrator/completed_session_resume_test.go @@ -0,0 +1,360 @@ +package orchestrator + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/kandev/kandev/internal/orchestrator/executor" + "github.com/kandev/kandev/internal/orchestrator/watcher" + "github.com/kandev/kandev/internal/task/models" + v1 "github.com/kandev/kandev/pkg/api/v1" + "github.com/stretchr/testify/require" +) + +// TestCompletedSessionResumePreservesConversation covers the explicit recovery +// path for a completed session. Recovery must relaunch the existing provider +// conversation in place instead of creating a sibling or silently losing the +// provider resume identity. +func TestCompletedSessionResumePreservesConversation(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + taskRepo := newMockTaskRepo() + + var captured *executor.LaunchAgentRequest + started := false + agentMgr := &sessionUpdatingAgentManager{ + mockAgentManager: &mockAgentManager{ + launchAgentFunc: func(_ context.Context, req *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) { + captured = req + return &executor.LaunchAgentResponse{ + AgentExecutionID: "exec-completed-follow-up", + Status: v1.AgentStatusStarting, + }, nil + }, + }, + repo: repo, + sessionID: "session-completed-follow-up", + taskID: "task-completed-follow-up", + onStartCalled: &started, + } + svc := createTestServiceWithAgent(repo, newMockStepGetter(), taskRepo, agentMgr) + svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{}) + + seedTaskAndSession( + t, + repo, + "task-completed-follow-up", + "session-completed-follow-up", + models.TaskSessionStateCompleted, + ) + now := time.Now().UTC() + require.NoError(t, repo.CreateExecutor(ctx, &models.Executor{ + ID: "executor-completed-follow-up", + Name: "Worktree", + Type: models.ExecutorTypeWorktree, + Status: models.ExecutorStatusActive, + Resumable: true, + })) + require.NoError(t, repo.CreateRepository(ctx, &models.Repository{ + ID: "repo-completed-follow-up", + WorkspaceID: "ws1", + Name: "backend", + SourceType: "local", + LocalPath: t.TempDir(), + DefaultBranch: "main", + CreatedAt: now, + UpdatedAt: now, + })) + require.NoError(t, repo.CreateTaskRepository(ctx, &models.TaskRepository{ + ID: "task-repo-completed-follow-up", + TaskID: "task-completed-follow-up", + RepositoryID: "repo-completed-follow-up", + BaseBranch: "main", + Position: 0, + CreatedAt: now, + UpdatedAt: now, + })) + + session, err := repo.GetTaskSession(ctx, "session-completed-follow-up") + require.NoError(t, err) + session.AgentProfileID = "profile-completed-follow-up" + session.ExecutorID = "executor-completed-follow-up" + session.RepositoryID = "repo-completed-follow-up" + session.BaseBranch = "main" + require.NoError(t, repo.UpdateTaskSession(ctx, session)) + require.NoError(t, repo.UpsertExecutorRunning(ctx, &models.ExecutorRunning{ + ID: "running-completed-follow-up", + SessionID: "session-completed-follow-up", + TaskID: "task-completed-follow-up", + AgentExecutionID: "exec-before-completed-follow-up", + ResumeToken: "acp-session-completed-follow-up", + Resumable: true, + CreatedAt: now, + UpdatedAt: now, + })) + + response, err := svc.RecoverSession( + ctx, + "task-completed-follow-up", + "session-completed-follow-up", + "resume", + ) + require.NoError(t, err) + require.NotNil(t, response) + require.Equal(t, "task-completed-follow-up", response.TaskID) + require.Equal(t, "session-completed-follow-up", response.SessionID) + require.NotNil(t, captured) + require.Equal(t, "session-completed-follow-up", captured.SessionID) + require.Equal(t, "acp-session-completed-follow-up", captured.ACPSessionID) + require.True(t, started) + + reloaded, err := repo.GetTaskSession(ctx, "session-completed-follow-up") + require.NoError(t, err) + require.Equal(t, models.TaskSessionStateWaitingForInput, reloaded.State) + require.Equal(t, session.ID, reloaded.ID) + require.NotNil(t, reloaded.Metadata) + require.Equal(t, true, reloaded.Metadata["completion_follow_up"]) + + running, err := repo.GetExecutorRunningBySessionID(ctx, "session-completed-follow-up") + require.NoError(t, err) + require.Equal(t, "acp-session-completed-follow-up", running.ResumeToken) + sessions, err := repo.ListTaskSessions(ctx, "task-completed-follow-up") + require.NoError(t, err) + require.Len(t, sessions, 1) +} + +func TestCompletedSessionFollowUpOwnership(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedTaskAndSession(t, repo, "task-completed-ownership", "session-follow-up", models.TaskSessionStateCompleted) + + followUp, err := repo.GetTaskSession(ctx, "session-follow-up") + require.NoError(t, err) + followUp.AgentProfileID = "profile-ownership" + followUp.UpdatedAt = time.Now().UTC().Add(2 * time.Minute) + require.NoError(t, repo.UpdateTaskSession(ctx, followUp)) + require.NoError(t, repo.SetSessionMetadataKey( + ctx, + "session-follow-up", + models.SessionMetaKeyCompletionFollowUp, + true, + )) + + require.NoError(t, repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "session-reusable", + TaskID: "task-completed-ownership", + AgentProfileID: "profile-ownership", + State: models.TaskSessionStateWaitingForInput, + StartedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC().Add(time.Minute), + })) + + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + reusable, err := svc.findReusableSessionForProfile( + ctx, + "task-completed-ownership", + "profile-ownership", + "", + ) + require.NoError(t, err) + require.NotNil(t, reusable) + require.Equal(t, "session-reusable", reusable.ID) +} + +func TestCompletedSessionFollowUpTurnReturnsToWaiting(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedTaskAndSession(t, repo, "task-completed-ready", "session-completed-ready", models.TaskSessionStateRunning) + require.NoError(t, repo.UpdateTaskState(ctx, "task-completed-ready", v1.TaskStateCompleted)) + + session, err := repo.GetTaskSession(ctx, "session-completed-ready") + require.NoError(t, err) + require.NoError(t, repo.SetSessionMetadataKey( + ctx, + "session-completed-ready", + models.SessionMetaKeyCompletionFollowUp, + true, + )) + require.Equal(t, models.TaskSessionStateRunning, session.State) + storedSession, err := repo.GetTaskSession(ctx, "session-completed-ready") + require.NoError(t, err) + require.True(t, models.IsCompletionFollowUpSession(storedSession.Metadata)) + + svc := createTestServiceWithAgent( + repo, + newMockStepGetter(), + newMockTaskRepo(), + &mockAgentManager{isAgentRunning: true}, + ) + + svc.handleAgentReady(ctx, watcher.AgentEventData{ + TaskID: "task-completed-ready", + SessionID: "session-completed-ready", + }) + + reloaded, err := repo.GetTaskSession(ctx, "session-completed-ready") + require.NoError(t, err) + require.Equal(t, models.TaskSessionStateWaitingForInput, reloaded.State) + require.True(t, models.IsCompletionFollowUpSession(reloaded.Metadata)) + task, err := repo.GetTask(ctx, "task-completed-ready") + require.NoError(t, err) + require.Equal(t, v1.TaskStateCompleted, task.State) +} + +func TestCompletedSessionResumeRaces(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + taskRepo := newMockTaskRepo() + startEntered := make(chan struct{}) + startRelease := make(chan struct{}) + startFinished := make(chan struct{}) + var startOnce sync.Once + var launchCalls atomic.Int32 + started := false + agentMgr := &blockingCompletedResumeAgentManager{ + sessionUpdatingAgentManager: &sessionUpdatingAgentManager{ + mockAgentManager: &mockAgentManager{ + isAgentRunning: true, + launchAgentFunc: func(_ context.Context, req *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) { + launchCalls.Add(1) + return &executor.LaunchAgentResponse{ + AgentExecutionID: "exec-completed-race", + Status: v1.AgentStatusStarting, + }, nil + }, + }, + repo: repo, + sessionID: "session-completed-race", + taskID: "task-completed-race", + onStartCalled: &started, + }, + startEntered: startEntered, + startRelease: startRelease, + startFinished: startFinished, + startOnce: &startOnce, + finishOnce: &sync.Once{}, + } + svc := createTestServiceWithAgent(repo, newMockStepGetter(), taskRepo, agentMgr) + svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{}) + + seedTaskAndSession( + t, + repo, + "task-completed-race", + "session-completed-race", + models.TaskSessionStateCompleted, + ) + now := time.Now().UTC() + require.NoError(t, repo.CreateExecutor(ctx, &models.Executor{ + ID: "executor-completed-race", + Name: "Worktree", + Type: models.ExecutorTypeWorktree, + Status: models.ExecutorStatusActive, + Resumable: true, + })) + require.NoError(t, repo.CreateRepository(ctx, &models.Repository{ + ID: "repo-completed-race", + WorkspaceID: "ws1", + Name: "backend", + SourceType: "local", + LocalPath: t.TempDir(), + DefaultBranch: "main", + CreatedAt: now, + UpdatedAt: now, + })) + require.NoError(t, repo.CreateTaskRepository(ctx, &models.TaskRepository{ + ID: "task-repo-completed-race", + TaskID: "task-completed-race", + RepositoryID: "repo-completed-race", + BaseBranch: "main", + Position: 0, + CreatedAt: now, + UpdatedAt: now, + })) + session, err := repo.GetTaskSession(ctx, "session-completed-race") + require.NoError(t, err) + session.AgentProfileID = "profile-completed-race" + session.ExecutorID = "executor-completed-race" + session.RepositoryID = "repo-completed-race" + session.BaseBranch = "main" + require.NoError(t, repo.UpdateTaskSession(ctx, session)) + require.NoError(t, repo.UpsertExecutorRunning(ctx, &models.ExecutorRunning{ + ID: "running-completed-race", + SessionID: "session-completed-race", + TaskID: "task-completed-race", + AgentExecutionID: "exec-before-completed-race", + ResumeToken: "acp-session-completed-race", + Resumable: true, + CreatedAt: now, + UpdatedAt: now, + })) + + type recoveryResult struct { + response *LaunchSessionResponse + err error + } + firstResult := make(chan recoveryResult, 1) + go func() { + response, recoverErr := svc.RecoverSession(ctx, "task-completed-race", "session-completed-race", "resume") + firstResult <- recoveryResult{response: response, err: recoverErr} + }() + select { + case <-startEntered: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for the first completed-session resume") + } + + secondResult := make(chan recoveryResult, 1) + go func() { + response, recoverErr := svc.RecoverSession(ctx, "task-completed-race", "session-completed-race", "resume") + secondResult <- recoveryResult{response: response, err: recoverErr} + }() + close(startRelease) + first := <-firstResult + require.NoError(t, first.err) + require.NotNil(t, first.response) + select { + case result := <-secondResult: + if result.err == nil { + require.NotNil(t, result.response) + require.Equal(t, "session-completed-race", result.response.SessionID) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for the duplicate completed-session resume") + } + + select { + case <-startFinished: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for the resumed agent process") + } + require.Equal(t, int32(1), launchCalls.Load()) + require.True(t, started) + + reloaded, err := repo.GetTaskSession(ctx, "session-completed-race") + require.NoError(t, err) + require.Equal(t, models.TaskSessionStateWaitingForInput, reloaded.State) + sessions, err := repo.ListTaskSessions(ctx, "task-completed-race") + require.NoError(t, err) + require.Len(t, sessions, 1) +} + +type blockingCompletedResumeAgentManager struct { + *sessionUpdatingAgentManager + startEntered chan struct{} + startRelease <-chan struct{} + startFinished chan struct{} + startOnce *sync.Once + finishOnce *sync.Once +} + +func (m *blockingCompletedResumeAgentManager) StartAgentProcess(ctx context.Context, agentExecutionID string) error { + m.startOnce.Do(func() { close(m.startEntered) }) + <-m.startRelease + err := m.sessionUpdatingAgentManager.StartAgentProcess(ctx, agentExecutionID) + m.finishOnce.Do(func() { close(m.startFinished) }) + return err +} diff --git a/apps/backend/internal/orchestrator/completed_task_follow_up_test.go b/apps/backend/internal/orchestrator/completed_task_follow_up_test.go new file mode 100644 index 0000000000..ee6f891180 --- /dev/null +++ b/apps/backend/internal/orchestrator/completed_task_follow_up_test.go @@ -0,0 +1,73 @@ +package orchestrator + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/orchestrator/watcher" + "github.com/kandev/kandev/internal/task/models" + v1 "github.com/kandev/kandev/pkg/api/v1" + "github.com/stretchr/testify/require" +) + +func TestCompletedTaskFollowUpAdmissionIsConversationalOnly(t *testing.T) { + for _, child := range []bool{false, true} { + t.Run(map[bool]string{false: "root", true: "child"}[child], func(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + steps, ids := buildWorkflowFromJSON(t, developmentWorkflowJSON) + steps.steps[ids["Done"]].CompleteTaskOnEnter = true + seedSession(t, repo, "task", "session", ids["New Step"]) + setSessionExecID(t, repo, "session", "exec") + setSessionState(t, ctx, repo, "session", models.TaskSessionStateRunning) + if child { + task, err := repo.GetTask(ctx, "task") + require.NoError(t, err) + task.ParentID = "parent" + require.NoError(t, repo.UpdateTask(ctx, task)) + } + + agentMgr := &mockAgentManager{repoForExecutionLookup: repo, isAgentRunning: true} + svc := createEngineService(t, repo, steps, agentMgr) + session, err := repo.GetTaskSession(ctx, "session") + require.NoError(t, err) + require.True(t, svc.processOnTurnCompleteViaEngine(ctx, "task", session)) + + task, err := repo.GetTask(ctx, "task") + require.NoError(t, err) + require.Equal(t, v1.TaskStateCompleted, task.State) + require.Equal(t, ids["Done"], task.WorkflowStepID) + require.Equal(t, models.TaskSessionStateWaitingForInput, mustGetSession(t, repo, "session").State) + + result, err := svc.ProcessOnTurnStart(ctx, "task", "session") + require.NoError(t, err) + require.False(t, result.Queued) + + followUp, err := repo.GetTaskSession(ctx, "session") + require.NoError(t, err) + require.True(t, models.IsCompletionFollowUpSession(followUp.Metadata)) + svc.setSessionRunning(ctx, "task", "session") + task, err = repo.GetTask(ctx, "task") + require.NoError(t, err) + require.Equal(t, v1.TaskStateCompleted, task.State) + + svc.handleAgentReady(ctx, watcher.AgentEventData{ + TaskID: "task", + SessionID: "session", + AgentExecutionID: "exec", + }) + require.Equal(t, models.TaskSessionStateWaitingForInput, mustGetSession(t, repo, "session").State) + task, err = repo.GetTask(ctx, "task") + require.NoError(t, err) + require.Equal(t, v1.TaskStateCompleted, task.State) + require.Equal(t, ids["Done"], task.WorkflowStepID) + }) + } +} + +func mustGetSession(t *testing.T, repo sessionExecutorStore, sessionID string) *models.TaskSession { + t.Helper() + session, err := repo.GetTaskSession(context.Background(), sessionID) + require.NoError(t, err) + return session +} diff --git a/apps/backend/internal/orchestrator/event_handlers_agent.go b/apps/backend/internal/orchestrator/event_handlers_agent.go index 5153998b21..76fcd6c4df 100644 --- a/apps/backend/internal/orchestrator/event_handlers_agent.go +++ b/apps/backend/internal/orchestrator/event_handlers_agent.go @@ -916,20 +916,28 @@ func (s *Service) handleAgentReady(ctx context.Context, data watcher.AgentEventD return } - // Check for workflow transition based on session's current step. - // Uses the engine when available; falls back to legacy evaluation. - // The ViaEngine method handles setSessionWaitingForInput internally when no transition occurs. - transitioned := s.processOnTurnCompleteViaEngine(ctx, data.TaskID, session) + completionFollowUp := models.IsCompletionFollowUpSession(session.Metadata) + if completionFollowUp { + // A completed task's explicit follow-up turn is conversational only. It + // must become promptable again without re-evaluating the final step's + // workflow actions or changing the task's completed state. + s.setSessionWaitingForInput(ctx, data.TaskID, data.SessionID, session) + } else { + // Check for workflow transition based on session's current step. + // Uses the engine when available; falls back to legacy evaluation. + // The ViaEngine method handles setSessionWaitingForInput internally when no transition occurs. + transitioned := s.processOnTurnCompleteViaEngine(ctx, data.TaskID, session) - // When a workflow transition occurred (e.g. Work → Review), the new step's - // on_enter actions handle the next prompt (auto_start_agent launches a goroutine). - // Skip the queued-message check to avoid racing with that auto-start goroutine — - // both would try to call PromptTask and the loser's queued message would be lost. - if transitioned { - s.logger.Debug("workflow transition occurred, skipping queued message check", - zap.String("task_id", data.TaskID), - zap.String("session_id", data.SessionID)) - return + // When a workflow transition occurred (e.g. Work → Review), the new step's + // on_enter actions handle the next prompt (auto_start_agent launches a goroutine). + // Skip the queued-message check to avoid racing with that auto-start goroutine — + // both would try to call PromptTask and the loser's queued message would be lost. + if transitioned { + s.logger.Debug("workflow transition occurred, skipping queued message check", + zap.String("task_id", data.TaskID), + zap.String("session_id", data.SessionID)) + return + } } // Passthrough sessions: deliver queued messages via PTY stdin instead of ACP. @@ -1811,17 +1819,22 @@ func (s *Service) finishAgentCompleted( session *models.TaskSession, guard *lockedCancelInFlightGuard, ) { + completionFollowUp := models.IsCompletionFollowUpSession(session.Metadata) // A successful, still-live completion clears retry state and scheduler // ownership only after the guarded terminal/rotation checks above. s.resetTransientRetry(data.SessionID) - s.scheduler.HandleTaskCompleted(data.TaskID, true) - s.scheduler.RemoveTask(data.TaskID) + if !completionFollowUp { + s.scheduler.HandleTaskCompleted(data.TaskID, true) + s.scheduler.RemoveTask(data.TaskID) + } // The agent finished a turn, so any stored failure no longer describes the // session. `session` was read above, so the guard costs nothing. s.clearRecoveredAgentError(context.WithoutCancel(ctx), data.TaskID, session) - s.reconcileCIAutoFixTurnBeforeCompletion(ctx, data.TaskID, data.SessionID, "") + if !completionFollowUp { + s.reconcileCIAutoFixTurnBeforeCompletion(ctx, data.TaskID, data.SessionID, "") + } s.completeTurnForSession(context.WithoutCancel(ctx), data.SessionID) if s.sessionHasPendingClarification(ctx, data.SessionID) { @@ -1836,9 +1849,10 @@ func (s *Service) finishAgentCompleted( return } - transitioned := !s.drainQueuedBeforeWorkflowTransition(ctx, data.TaskID, data.SessionID, session) && + transitioned := !completionFollowUp && + !s.drainQueuedBeforeWorkflowTransition(ctx, data.TaskID, data.SessionID, session) && s.processOnTurnCompleteViaEngine(ctx, data.TaskID, session) - s.finishAgentCompletedTurn(ctx, data, session, transitioned, guard) + s.finishAgentCompletedTurn(ctx, data, session, transitioned, completionFollowUp, guard) } // finishAgentCompletedTurn runs the settle steps after @@ -1852,6 +1866,7 @@ func (s *Service) finishAgentCompletedTurn( data watcher.AgentEventData, session *models.TaskSession, transitioned bool, + completionFollowUp bool, guard *lockedCancelInFlightGuard, ) { // Agent-exit path: processOnTurnCompleteViaEngine handles normal @@ -1888,7 +1903,9 @@ func (s *Service) finishAgentCompletedTurn( // Finalize the automation run: mark status=succeeded so the automation's // concurrency slot is released. The worktree stays. - s.finalizeAutomationRun(ctx, data.TaskID, true, "") + if !completionFollowUp { + s.finalizeAutomationRun(ctx, data.TaskID, true, "") + } // Settle point: turn has been completed and the session state has been // reconciled. reclaimIdleSession is the synchronous equivalent of the @@ -2336,13 +2353,19 @@ func (s *Service) handleRecoverableFailureLocked(ctx context.Context, data watch // run after the guard is released. Deletion uses the same guard, so an active // error event cannot publish after the deleted-session inactive event. func (s *Service) handleRecoverableFailureLockedState(ctx context.Context, data watcher.AgentEventData) func() { + completionFollowUp := false + if session, err := s.repo.GetTaskSession(ctx, data.SessionID); err == nil && session != nil { + completionFollowUp = models.IsCompletionFollowUpSession(session.Metadata) + } s.logger.Warn("handling recoverable agent failure", zap.String("task_id", data.TaskID), zap.String("session_id", data.SessionID), zap.String("error", data.ErrorMessage)) // Complete the current turn. - s.reconcileCIAutoFixTurnBeforeCompletion(ctx, data.TaskID, data.SessionID, "") + if !completionFollowUp { + s.reconcileCIAutoFixTurnBeforeCompletion(ctx, data.TaskID, data.SessionID, "") + } s.completeTurnForSession(ctx, data.SessionID) s.persistLastAgentError(ctx, data) @@ -2383,7 +2406,7 @@ func (s *Service) handleRecoverableFailureLockedState(ctx context.Context, data // the session's metadata bag until it's silently cleared on resume. // Office sessions go FAILED, not WAITING_FOR_INPUT, and must not // advance the step here. - if nextState == models.TaskSessionStateWaitingForInput && data.SessionID != "" { + if !completionFollowUp && nextState == models.TaskSessionStateWaitingForInput && data.SessionID != "" { session, err := s.repo.GetTaskSession(ctx, data.SessionID) if err != nil { s.logger.Warn("failed to reload session for step-completion reconciliation; "+ @@ -2404,6 +2427,9 @@ func (s *Service) handleRecoverableFailureLockedState(ctx context.Context, data // guard, so dispatching while it is held would deadlock. Panic recovery is // the recovered wrapper's job, not this one's — routes R2-R5 reach this // closure with no recover above them on the stack. + if completionFollowUp { + return func() {} + } return func() { s.dispatchKanbanAgentErrorTriggerRecovered(context.WithoutCancel(ctx), data) } diff --git a/apps/backend/internal/orchestrator/event_handlers_children_completed.go b/apps/backend/internal/orchestrator/event_handlers_children_completed.go index 85da7a3da8..172477a580 100644 --- a/apps/backend/internal/orchestrator/event_handlers_children_completed.go +++ b/apps/backend/internal/orchestrator/event_handlers_children_completed.go @@ -147,7 +147,6 @@ func (s *Service) readyChildCompletionRows(ctx context.Context, parentID string) zap.Error(err)) return nil, false } - s.annotateTerminalChildSteps(ctx, rows) if len(rows) == 0 || !allChildrenTerminal(rows) { return nil, false } @@ -260,31 +259,13 @@ func (s *Service) markChildCompletionApplied(ctx context.Context, parentID, oper func allChildrenTerminal(rows []models.ChildCompletionRow) bool { for _, row := range rows { - if !models.IsTerminalTaskState(row.State) && !row.TerminalWorkflowStep { + if !models.IsTerminalTaskState(row.State) { return false } } return true } -func (s *Service) annotateTerminalChildSteps(ctx context.Context, rows []models.ChildCompletionRow) { - if s.workflowStepGetter == nil { - return - } - cache := make(map[string]bool) - for i := range rows { - if models.IsTerminalTaskState(rows[i].State) || rows[i].WorkflowStepID == "" { - continue - } - terminal, ok := cache[rows[i].WorkflowStepID] - if !ok { - terminal = s.workflowStepIsTerminal(ctx, rows[i].WorkflowStepID) - cache[rows[i].WorkflowStepID] = terminal - } - rows[i].TerminalWorkflowStep = terminal - } -} - func (s *Service) workflowStepIsTerminal(ctx context.Context, workflowStepID string) bool { if s.workflowStepGetter == nil || workflowStepID == "" { return false @@ -316,9 +297,6 @@ func childCompletionPayload(rows []models.ChildCompletionRow) engine.OnChildrenC } func childCompletionStatus(row models.ChildCompletionRow) string { - if row.TerminalWorkflowStep && !models.IsTerminalTaskState(row.State) { - return string(v1.TaskStateCompleted) - } return string(row.State) } @@ -333,12 +311,6 @@ func childCompletionOperationID(parentID string, rows []models.ChildCompletionRo b.WriteString(":") b.WriteString(row.WorkflowStepID) b.WriteString(":") - if row.TerminalWorkflowStep { - b.WriteString("terminal") - } else { - b.WriteString("active") - } - b.WriteString(":") b.WriteString(row.UpdatedAt.UTC().Format(time.RFC3339Nano)) } sum := sha256.Sum256([]byte(b.String())) diff --git a/apps/backend/internal/orchestrator/event_handlers_children_completed_test.go b/apps/backend/internal/orchestrator/event_handlers_children_completed_test.go index 17a8393392..28633a6132 100644 --- a/apps/backend/internal/orchestrator/event_handlers_children_completed_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_children_completed_test.go @@ -109,7 +109,7 @@ func TestProcessOnChildrenCompleted_TransitionsParentWhenAllActiveChildrenTermin } } -func TestProcessOnChildrenCompleted_TreatsTerminalStepAsCompleted(t *testing.T) { +func TestProcessOnChildrenCompleted_UsesCommittedTaskState(t *testing.T) { ctx := context.Background() repo := setupTestRepo(t) seedSession(t, repo, "parent", "parent-session", "step_wait") @@ -152,7 +152,7 @@ func TestProcessOnChildrenCompleted_TreatsTerminalStepAsCompleted(t *testing.T) now := time.Now().UTC() requireCreateTask(t, repo, &models.Task{ ID: "child-terminal-step", WorkspaceID: "ws1", WorkflowID: "wf-child", WorkflowStepID: "child_done", - Title: "Child in Done", State: v1.TaskStateReview, ParentID: "parent", + Title: "Child in Done", State: v1.TaskStateCompleted, ParentID: "parent", CreatedAt: now, UpdatedAt: now, }) @@ -200,10 +200,11 @@ func TestHandleTaskMovedToTerminalStepProcessesParentChildrenCompleted(t *testin Position: 0, } stepGetter.steps["child_done"] = &wfmodels.WorkflowStep{ - ID: "child_done", - WorkflowID: "wf-child", - Name: "Done", - Position: 1, + ID: "child_done", + WorkflowID: "wf-child", + Name: "Done", + Position: 1, + CompleteTaskOnEnter: true, } agentMgr := &mockAgentManager{repoForExecutionLookup: repo} diff --git a/apps/backend/internal/orchestrator/event_handlers_pending_move_test.go b/apps/backend/internal/orchestrator/event_handlers_pending_move_test.go index 4be610fdbf..bbae3ae8a0 100644 --- a/apps/backend/internal/orchestrator/event_handlers_pending_move_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_pending_move_test.go @@ -85,6 +85,7 @@ func TestPendingMove_ReviewToInProgress_OneTransitionOnly(t *testing.T) { func TestPendingMove_OutOfTerminalStepReopensCompletedTask(t *testing.T) { sc := buildPendingMoveScenario(t) sc.stepGetter.steps[stepReviewedID].Name = "Done" + sc.stepGetter.steps[stepReviewedID].CompleteTaskOnEnter = true task, err := sc.repo.GetTask(sc.ctx, "task-1") if err != nil { diff --git a/apps/backend/internal/orchestrator/event_handlers_reclaim_wiring_test.go b/apps/backend/internal/orchestrator/event_handlers_reclaim_wiring_test.go index 24361b6a32..130c45f625 100644 --- a/apps/backend/internal/orchestrator/event_handlers_reclaim_wiring_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_reclaim_wiring_test.go @@ -63,14 +63,14 @@ func (m *reclaimTrackingAgentManager) callsSnapshot() []string { return append([]string(nil), m.calls...) } -// TestSubtaskTerminalCollapse_ReclaimsProviderRuntime is the canonical +// TestSubtaskTerminalSettle_ReclaimsProviderRuntime is the canonical // end-to-end wiring test: a child task whose last agent message did not -// request input collapses to COMPLETED inside +// request input remains WAITING_FOR_INPUT inside // setSessionWaitingForInputIfRequested. With no live agent process and no // active turn, reclaimIdleSession must fire on this synchronous settle // point, the executor row must flip to status=stopped with LocalPID=0, // and the resume_token/worktree_path must remain intact. -func TestSubtaskTerminalCollapse_ReclaimsProviderRuntime(t *testing.T) { +func TestSubtaskTerminalSettle_ReclaimsProviderRuntime(t *testing.T) { ctx := context.Background() repo := setupTestRepo(t) now := time.Now().UTC() @@ -116,13 +116,13 @@ func TestSubtaskTerminalCollapse_ReclaimsProviderRuntime(t *testing.T) { svc.handleAgentCompleted(ctx, watcherAgentCompletedData("child-task", "s-child", "exec-child")) waitForStopCall(t, inner) - // 1. Session collapsed to COMPLETED (existing guard behavior). + // 1. Session remains promptable after a successful child completion. updated, err := repo.GetTaskSession(ctx, "s-child") if err != nil { t.Fatalf("load session: %v", err) } - if updated.State != models.TaskSessionStateCompleted { - t.Fatalf("subtask terminal must collapse to COMPLETED, got %q", updated.State) + if updated.State != models.TaskSessionStateWaitingForInput { + t.Fatalf("successful subtask must remain WAITING_FOR_INPUT, got %q", updated.State) } // 2. reclaim fired exactly once for this session. diff --git a/apps/backend/internal/orchestrator/event_handlers_streaming.go b/apps/backend/internal/orchestrator/event_handlers_streaming.go index ba917ee1d4..d45b5e22c3 100644 --- a/apps/backend/internal/orchestrator/event_handlers_streaming.go +++ b/apps/backend/internal/orchestrator/event_handlers_streaming.go @@ -1860,12 +1860,22 @@ func (s *Service) deleteCompletedExecutionIfExpired(key string, expiresAt time.T func allowsSessionStartingRecovery( nextState, expectedState, currentState models.TaskSessionState, promoteTask bool, +) bool { + return allowsSessionStartingRecoveryWithPermission( + nextState, expectedState, currentState, promoteTask, false, + ) +} + +func allowsSessionStartingRecoveryWithPermission( + nextState, expectedState, currentState models.TaskSessionState, + promoteTask, allowCompletedResume bool, ) bool { return !promoteTask && nextState == models.TaskSessionStateStarting && currentState == expectedState && (expectedState == models.TaskSessionStateFailed || - expectedState == models.TaskSessionStateCancelled) + expectedState == models.TaskSessionStateCancelled || + (allowCompletedResume && expectedState == models.TaskSessionStateCompleted)) } func (s *Service) setSessionStarting( @@ -1874,6 +1884,18 @@ func (s *Service) setSessionStarting( session *models.TaskSession, expectedState models.TaskSessionState, promoteTask bool, +) error { + return s.setSessionStartingWithOptions( + ctx, taskID, session, expectedState, promoteTask, false, + ) +} + +func (s *Service) setSessionStartingWithOptions( + ctx context.Context, + taskID string, + session *models.TaskSession, + expectedState models.TaskSessionState, + promoteTask, allowCompletedResume bool, ) error { if session == nil { return nil @@ -1890,8 +1912,8 @@ func (s *Service) setSessionStarting( if err != nil { return err } - allowedTerminalRecovery := allowsSessionStartingRecovery( - session.State, expectedState, current.State, promoteTask, + allowedTerminalRecovery := allowsSessionStartingRecoveryWithPermission( + session.State, expectedState, current.State, promoteTask, allowCompletedResume, ) if isTerminalSessionState(current.State) && !allowedTerminalRecovery { return &executor.SessionStateSupersededError{SessionID: session.ID, State: current.State} @@ -2090,21 +2112,9 @@ func (s *Service) setSessionWaitingForInputWithHook( } // Child terminal receipts use the task's parent relationship and durable -// clarification projection to decide whether a session can collapse to -// COMPLETED. Root-task sibling sessions keep the original WAITING affordance. -// setSessionWaitingForInputIfRequested is the terminal-receipt variant -// of setSessionWaitingForInput. It only applies to subtasks (task.ParentID -// non-empty) because the symptom it guards — "child WAITING_FOR_INPUT -// after the agent's last turn had no active clarification" — only -// arises for child tasks whose task or workflow step is terminal, not to -// surface a UI prompt. Sibling sessions on a root task keep the original -// affordance so a finishing session on a multi-session task still flips to -// WAITING_FOR_INPUT. -// -// When a terminal task has no input request, collapse the session to -// COMPLETED so the child row does not leak in a stuck active state. A clean -// turn on a non-terminal child is not enough evidence for that collapse, so -// it remains WAITING_FOR_INPUT. +// clarification projection to preserve existing failure/cancellation cleanup. +// Successful completion is independent from conversation access, so a +// completed child remains WAITING_FOR_INPUT and can receive a follow-up. func (s *Service) setSessionWaitingForInputIfRequested( ctx context.Context, taskID, sessionID string, @@ -2145,9 +2155,9 @@ func (s *Service) setSessionWaitingForInputIfRequestedWithHook( s.setSessionWaitingForInputWithHook(ctx, taskID, sessionID, onChanged, preloadedSession...) return } - taskTerminal := models.IsTerminalTaskState(task.State) || s.workflowStepIsTerminal(ctx, task.WorkflowStepID) - if len(activeClarifications) == 0 && taskTerminal { - s.logger.Debug("subtask terminal: skipping WAITING write; collapsing session to COMPLETED", + failedOrCancelled := task.State == v1.TaskStateFailed || task.State == v1.TaskStateCancelled + if len(activeClarifications) == 0 && failedOrCancelled { + s.logger.Debug("subtask failed or cancelled: skipping WAITING write; collapsing session to COMPLETED", zap.String("task_id", taskID), zap.String("session_id", sessionID)) s.updateTaskSessionState(ctx, taskID, sessionID, models.TaskSessionStateCompleted, "", false) @@ -2493,10 +2503,16 @@ func (s *Service) reconcileTaskStateForRuntimeLocked( if state == v1.TaskStateInProgress && task != nil && task.IsFromOffice { return nil } + if state == v1.TaskStateInProgress && task != nil && task.State == v1.TaskStateCompleted { + return nil + } session, err := s.repo.GetTaskSession(ctx, sessionID) if err != nil { return err } + if state == v1.TaskStateInProgress && models.IsCompletionFollowUpSession(session.Metadata) { + return nil + } if !runtimeSessionOwnsTaskState(session, state) { return nil } diff --git a/apps/backend/internal/orchestrator/event_handlers_subtask_waiting_test.go b/apps/backend/internal/orchestrator/event_handlers_subtask_waiting_test.go index 1f2a2c31f1..37c2d6baf7 100644 --- a/apps/backend/internal/orchestrator/event_handlers_subtask_waiting_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_subtask_waiting_test.go @@ -12,20 +12,14 @@ import ( v1 "github.com/kandev/kandev/pkg/api/v1" ) -// TestHandleAgentCompleted_SubtaskWithoutRequestsInputCollapsesToCompleted -// pins the symptom reported in the v0.88 total-control fix plan: -// "child in a terminal state without requests_input must NOT write -// WAITING_FOR_INPUT". handleAgentCompleted's existing line 1238 writes -// WAITING unconditionally for non-transitioned terminal receipts. -// setSessionWaitingForInputIfRequested (this commit's guard) refuses -// the WAITING write for child tasks and instead collapses the session -// to COMPLETED, so a child agent's clean exit does not leave the -// session in a stuck RUNNING/RUNNING-equivalent state. +// TestHandleAgentCompleted_SubtaskWithoutRequestsInputRemainsPromptable +// protects completed task conversation access. A successful child can finish +// its task while its session remains an ordinary promptable conversation. // // The seed task is a subtask (ParentID="parent") in a terminal task state // and the agent's last message has requests_input=false; the orchestrator -// must therefore finish the session to COMPLETED, not WAITING. -func TestHandleAgentCompleted_SubtaskWithoutRequestsInputCollapsesToCompleted(t *testing.T) { +// must therefore finish the session in WAITING_FOR_INPUT. +func TestHandleAgentCompleted_SubtaskWithoutRequestsInputRemainsPromptable(t *testing.T) { ctx := context.Background() repo := setupTestRepo(t) now := time.Now().UTC() @@ -40,8 +34,8 @@ func TestHandleAgentCompleted_SubtaskWithoutRequestsInputCollapsesToCompleted(t } seedExecutorRunning(t, repo, "s-child", "child-task", "exec-child") - // No messages: requests_input must be false, so the guard refuses - // the WAITING write and collapses the session to COMPLETED. + // No messages: requests_input is false, but this is still a promptable + // successful conversation. taskRepo := newMockTaskRepo() agentMgr := &mockAgentManager{repoForExecutionLookup: repo} @@ -54,8 +48,8 @@ func TestHandleAgentCompleted_SubtaskWithoutRequestsInputCollapsesToCompleted(t if err != nil { t.Fatalf("load session after subtask terminal: %v", err) } - if updated.State != models.TaskSessionStateCompleted { - t.Fatalf("subtask terminal without requests_input must collapse to COMPLETED, got %q", updated.State) + if updated.State != models.TaskSessionStateWaitingForInput { + t.Fatalf("successful subtask without requests_input must remain WAITING_FOR_INPUT, got %q", updated.State) } } @@ -95,7 +89,7 @@ func TestHandleAgentCompleted_NonTerminalSubtaskWithoutRequestsInputWritesWaitin } } -func TestHandleAgentCompleted_SubtaskIgnoresResolvedClarification(t *testing.T) { +func TestHandleAgentCompleted_SubtaskWithResolvedClarificationRemainsPromptable(t *testing.T) { ctx := context.Background() repo := setupTestRepo(t) now := time.Now().UTC() @@ -132,8 +126,8 @@ func TestHandleAgentCompleted_SubtaskIgnoresResolvedClarification(t *testing.T) if err != nil { t.Fatalf("load session after resolved clarification: %v", err) } - if updated.State != models.TaskSessionStateCompleted { - t.Fatalf("resolved clarification must not keep a terminal child waiting, got %q", updated.State) + if updated.State != models.TaskSessionStateWaitingForInput { + t.Fatalf("resolved clarification must leave a completed child promptable, got %q", updated.State) } } diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow.go b/apps/backend/internal/orchestrator/event_handlers_workflow.go index 5a251bd264..7e5475e9dd 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow.go @@ -382,12 +382,25 @@ func (s *Service) ProcessOnTurnStart(ctx context.Context, taskID, sessionID stri State: session.State, } } + task, err := s.repo.GetTask(ctx, taskID) + if err != nil { + return ProcessOnTurnStartResult{}, fmt.Errorf("load task for on_turn_start: %w", err) + } + if task != nil && task.State == v1.TaskStateCompleted && !models.IsCompletionFollowUpSession(session.Metadata) { + if err := s.repo.SetSessionMetadataKey(ctx, session.ID, models.SessionMetaKeyCompletionFollowUp, true); err != nil { + return ProcessOnTurnStartResult{}, fmt.Errorf("mark completed task follow-up: %w", err) + } + if session.Metadata == nil { + session.Metadata = make(map[string]interface{}) + } + session.Metadata[models.SessionMetaKeyCompletionFollowUp] = true + } // ADR 0015 — a fresh user message before the pending signal's // transition has fired cancels the signal (re-open semantics). The // user is continuing the conversation; this step is no longer "done". s.clearPendingStepSignal(ctx, session) s.processOnTurnStartViaEngine(ctx, taskID, session) - task, err := s.repo.GetTask(ctx, taskID) + 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. @@ -2411,6 +2424,13 @@ func (s *Service) tagSessionAsWorkflowSwitched(ctx context.Context, sessionID st s.logger.Warn("failed to persist workflow-switch tag", zap.String("session_id", sessionID), zap.Error(err)) } + // A workflow step explicitly taking ownership of this session is the only + // path that clears conversational-only follow-up ownership. Ordinary sends, + // page activation, and automatic profile lookup must leave the marker set. + if err := s.repo.SetSessionMetadataKey(ctx, sessionID, models.SessionMetaKeyCompletionFollowUp, nil); err != nil { + s.logger.Warn("failed to clear completed-conversation follow-up marker", + zap.String("session_id", sessionID), zap.Error(err)) + } } // switchSessionForStep activates a session for the new agent profile. @@ -2537,6 +2557,9 @@ func (s *Service) findReusableSessionForProfile(ctx context.Context, taskID, pro if sess.AgentProfileID != profileID { continue } + if models.IsCompletionFollowUpSession(sess.Metadata) { + continue + } if isTerminalSessionState(sess.State) { continue } @@ -3160,9 +3183,7 @@ func (s *Service) prepareWorkflowStepSession( ctx = withWorkflowMetaCache(ctx) effectiveProfile := s.resolveStepAgentProfile(ctx, step) if effectiveProfile == "" || effectiveProfile == session.AgentProfileID { - if effectiveProfile != "" { - s.tagSessionAsWorkflowSwitched(ctx, session.ID) - } + s.tagSessionAsWorkflowSwitched(ctx, session.ID) if !session.IsPrimary { if err := s.SetPrimarySession(ctx, session.ID); err != nil { s.logger.Warn("failed to preserve session as primary for workflow step", @@ -5947,6 +5968,9 @@ func (s *Service) processOnTurnCompleteViaEngineWithCause( session *models.TaskSession, cause turnCompletionCause, ) bool { + if session == nil || models.IsCompletionFollowUpSession(session.Metadata) { + return false + } task, err := s.repo.GetTask(ctx, taskID) if err != nil { s.logger.Warn("failed to load task for on_turn_complete", @@ -6530,6 +6554,9 @@ func (s *Service) launchProcessOnEnter( // actions. Falls back to the legacy method when the engine is not initialized. // Returns true if a step transition occurred. func (s *Service) processOnTurnStartViaEngine(ctx context.Context, taskID string, session *models.TaskSession) bool { + if session == nil || models.IsCompletionFollowUpSession(session.Metadata) { + return false + } task, err := s.repo.GetTask(ctx, taskID) if err != nil { s.logger.Warn("failed to load task for on_turn_start", diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow_profile_test.go b/apps/backend/internal/orchestrator/event_handlers_workflow_profile_test.go index 527062bd59..a6f31b360c 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow_profile_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow_profile_test.go @@ -528,6 +528,36 @@ func TestPrepareWorkflowStepSession_PreservesMatchingProfileSession(t *testing.T } } +func TestPrepareWorkflowStepSession_ClearsCompletionFollowUpWithoutProfile(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + if err := repo.SetSessionMetadataKey(ctx, "s1", models.SessionMetaKeyCompletionFollowUp, true); err != nil { + t.Fatalf("mark completion follow-up: %v", err) + } + + session, err := repo.GetTaskSession(ctx, "s1") + if err != nil { + t.Fatalf("get session: %v", err) + } + step := &wfmodels.WorkflowStep{ID: "step1", WorkflowID: "wf1"} + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + + if _, switched, err := svc.prepareWorkflowStepSession(ctx, "t1", session, step, nil); err != nil { + t.Fatalf("prepareWorkflowStepSession returned error: %v", err) + } else if switched { + t.Fatal("step without a profile must not switch sessions") + } + + updated, err := repo.GetTaskSession(ctx, session.ID) + if err != nil { + t.Fatalf("reload session: %v", err) + } + if models.IsCompletionFollowUpSession(updated.Metadata) { + t.Fatal("explicit workflow step entry retained completion follow-up ownership") + } +} + func TestSwitchWorkflowDispatcherRoutesOnEnterToDestinationProfileSession(t *testing.T) { ctx := context.Background() repo := setupTestRepo(t) diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index c0709f818c..9a270443f5 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -98,6 +98,18 @@ type executorStore interface { GetTaskPlan(ctx context.Context, taskID string) (*models.TaskPlan, error) } +// sessionMetadataKeyStateSetter is an optional repository capability. Legacy +// test stores can keep their existing metadata API, while the SQL repository +// can guard recovery markers against a concurrent stop or archive. +type sessionMetadataKeyStateSetter interface { + SetSessionMetadataKeyIfState( + ctx context.Context, + sessionID, key string, + value interface{}, + expectedState models.TaskSessionState, + ) (bool, error) +} + // officeTaskSessionCreator lets repositories make Office-session origin // selection part of the insert transaction. Test and legacy stores can omit // it; the executor keeps a per-task fallback lock for those implementations. @@ -750,6 +762,19 @@ type SessionStartingFunc func( promoteTask bool, ) error +// SessionStartingWithOptionsFunc is the extended STARTING callback used by +// explicit completed-conversation recovery. It keeps the legacy callback +// shape available to lightweight executors and tests while carrying the +// narrow permission needed for the guarded completed-state transition. +type SessionStartingWithOptionsFunc func( + ctx context.Context, + taskID string, + session *models.TaskSession, + expectedState models.TaskSessionState, + promoteTask bool, + allowCompletedResume bool, +) error + // ExecutionCleanupClaimFunc atomically claims forced cleanup for one exact // session execution. It returns true when the executor owns cleanup and false // when another teardown path already owns that execution. @@ -864,6 +889,9 @@ type Executor struct { // the orchestrator so launch/resume/model-switch transitions serialize with // runtime task-state reconciliation. onSessionStarting SessionStartingFunc + // Extended STARTING callback for explicit completed-session recovery. When + // present, it takes precedence over the legacy callback above. + onSessionStartingWithOptions SessionStartingWithOptionsFunc // Callback for exact-execution forced cleanup arbitration. Set by the // orchestrator so coordinator graceful stop and launch cleanup cannot both @@ -1159,6 +1187,12 @@ func (e *Executor) SetOnSessionStarting(fn SessionStartingFunc) { e.onSessionStarting = fn } +// SetOnSessionStartingWithOptions sets the extended STARTING callback used by +// explicit completed-session recovery. +func (e *Executor) SetOnSessionStartingWithOptions(fn SessionStartingWithOptionsFunc) { + e.onSessionStartingWithOptions = fn +} + // SetOnExecutionCleanupClaim sets the exact-execution forced cleanup arbiter. func (e *Executor) SetOnExecutionCleanupClaim(fn ExecutionCleanupClaimFunc) { e.onExecutionCleanupClaim = fn diff --git a/apps/backend/internal/orchestrator/executor/executor_execute.go b/apps/backend/internal/orchestrator/executor/executor_execute.go index 9e844600b2..3635195deb 100644 --- a/apps/backend/internal/orchestrator/executor/executor_execute.go +++ b/apps/backend/internal/orchestrator/executor/executor_execute.go @@ -589,12 +589,22 @@ func isStopTerminalSessionState(state models.TaskSessionState) bool { func allowsSessionStartingRecovery( nextState, expectedState, currentState models.TaskSessionState, promoteTask bool, +) bool { + return allowsSessionStartingRecoveryWithPermission( + nextState, expectedState, currentState, promoteTask, false, + ) +} + +func allowsSessionStartingRecoveryWithPermission( + nextState, expectedState, currentState models.TaskSessionState, + promoteTask, allowCompletedResume bool, ) bool { return !promoteTask && nextState == models.TaskSessionStateStarting && currentState == expectedState && (expectedState == models.TaskSessionStateFailed || - expectedState == models.TaskSessionStateCancelled) + expectedState == models.TaskSessionStateCancelled || + (allowCompletedResume && expectedState == models.TaskSessionStateCompleted)) } // updateSessionStarting persists a full session-row STARTING transition, using @@ -607,6 +617,23 @@ func (e *Executor) updateSessionStarting( expectedState models.TaskSessionState, promoteTask bool, ) error { + return e.updateSessionStartingWithOptions( + ctx, taskID, session, expectedState, promoteTask, false, + ) +} + +func (e *Executor) updateSessionStartingWithOptions( + ctx context.Context, + taskID string, + session *models.TaskSession, + expectedState models.TaskSessionState, + promoteTask, allowCompletedResume bool, +) error { + if e.onSessionStartingWithOptions != nil { + return e.onSessionStartingWithOptions( + ctx, taskID, session, expectedState, promoteTask, allowCompletedResume, + ) + } if e.onSessionStarting != nil { return e.onSessionStarting(ctx, taskID, session, expectedState, promoteTask) } @@ -620,6 +647,11 @@ func (e *Executor) updateSessionStarting( allowedTerminalRecovery := allowsSessionStartingRecovery( session.State, expectedState, current.State, promoteTask, ) + if allowCompletedResume { + allowedTerminalRecovery = allowsSessionStartingRecoveryWithPermission( + session.State, expectedState, current.State, promoteTask, true, + ) + } if isStopTerminalSessionState(current.State) && !allowedTerminalRecovery { return &SessionStateSupersededError{SessionID: session.ID, State: current.State} } diff --git a/apps/backend/internal/orchestrator/executor/executor_resume.go b/apps/backend/internal/orchestrator/executor/executor_resume.go index b3f7e98cec..3002a3b332 100644 --- a/apps/backend/internal/orchestrator/executor/executor_resume.go +++ b/apps/backend/internal/orchestrator/executor/executor_resume.go @@ -744,6 +744,10 @@ func buildPrepareResultMetadata(result *lifecycle.EnvPrepareResult) map[string]i // original worktree branch and reports when it is unrecoverable. type ResumeOptions struct { AllowBranchReplacement bool + // AllowCompletedSessionResume is granted only by an explicit user recovery + // or a pinned follow-up dispatch. It does not change the global terminal + // session predicate or permit implicit resume paths. + AllowCompletedSessionResume bool } // ResumeSession restarts an existing task session using its stored worktree. @@ -775,7 +779,7 @@ func (e *Executor) resumeSession( resumeSnapshot.Metadata = cloneMetadata(session.Metadata) session = &resumeSnapshot } - task, unlock, err := e.validateAndLockResume(ctx, session) + task, unlock, err := e.validateAndLockResume(ctx, session, options) if err != nil { return nil, err } @@ -783,7 +787,9 @@ func (e *Executor) resumeSession( resumeInitialState := session.State previousCredentialSnapshot := captureResumeCredentialSnapshot(session) - wasTerminalResume := isTerminalSessionState(resumeInitialState) + completedResume := options.AllowCompletedSessionResume && + resumeInitialState == models.TaskSessionStateCompleted + wasTerminalResume := isTerminalSessionState(resumeInitialState) || completedResume // Force-cleanup any stale in-memory execution / agentctl state for terminal-state // sessions. Their agent process is dead by definition, so "already running" signals // from the execution store or agentctl's "starting" status are stale and would @@ -800,10 +806,12 @@ func (e *Executor) resumeSession( var beforeCredentialLease func() error if startAgent { beforeCredentialLease = func() error { - if persistErr := e.persistResumeState(ctx, task.ID, session, true); persistErr != nil { + // The rollback path must remain armed if persistence fails after the + // session has entered STARTING. + resumeStatePersisted = true + if persistErr := e.persistResumeStateWithOptions(ctx, task.ID, session, true, options); persistErr != nil { return persistErr } - resumeStatePersisted = true return nil } } @@ -923,7 +931,13 @@ func (e *Executor) resumeSession( } if startAgent { - e.startAgentProcessOnResume(ctx, task.ID, session, resp.AgentExecutionID) + e.startAgentProcessOnResumeWithTaskPromotion( + ctx, + task.ID, + session, + resp.AgentExecutionID, + !completedResume, + ) } return execution, nil @@ -1030,9 +1044,13 @@ func (e *Executor) rollbackResumeStateAfterFailure( // validateAndLockResume validates the session is resumable, acquires the per-session lock, // and loads the associated task. Returns the task, an unlock function, and any error. // The caller must call unlock() when the critical section is complete. -func (e *Executor) validateAndLockResume(ctx context.Context, session *models.TaskSession) (*v1.Task, func(), error) { - if session == nil { - return nil, func() {}, ErrExecutionNotFound +func (e *Executor) validateAndLockResume( + ctx context.Context, + session *models.TaskSession, + options ResumeOptions, +) (*v1.Task, func(), error) { + if err := validateResumeSession(session, options); err != nil { + return nil, func() {}, err } requestedState := session.State @@ -1043,32 +1061,11 @@ func (e *Executor) validateAndLockResume(ctx context.Context, session *models.Ta sessionLock.Lock() unlock := func() { sessionLock.Unlock() } - taskModel, err := e.repo.GetTask(ctx, session.TaskID) + task, err := e.loadResumeTask(ctx, session) if err != nil { unlock() - e.logger.Error("failed to load task for session resume", - zap.String("task_id", session.TaskID), - zap.String("session_id", session.ID), - zap.Error(err)) return nil, func() {}, err } - if taskModel.ArchivedAt != nil { - unlock() - return nil, func() {}, ErrTaskArchived - } - task := taskModel.ToAPI() - if task == nil { - unlock() - return nil, func() {}, ErrExecutionNotFound - } - - if session.AgentProfileID == "" { - unlock() - e.logger.Error("task session has no agent_profile_id configured", - zap.String("task_id", session.TaskID), - zap.String("session_id", session.ID)) - return nil, func() {}, ErrNoAgentProfileID - } // Re-read session state after acquiring the lock. The caller fetched the // session before the lock, so on concurrent resumes the state may be stale — @@ -1078,38 +1075,97 @@ func (e *Executor) validateAndLockResume(ctx context.Context, session *models.Ta // registered, launching a duplicate. If the re-read fails, abort rather than // proceeding with uncertain state — silently falling back to the stale state // would reintroduce the exact race this re-read prevents. - fresh, fetchErr := e.repo.GetTaskSession(ctx, session.ID) - if fetchErr != nil { + if err := e.refreshResumeSessionState(ctx, session, requestedState); err != nil { unlock() - e.logger.Warn("failed to re-read session state inside lock; aborting resume to avoid duplicate agent", - zap.String("session_id", session.ID), - zap.Error(fetchErr)) - return nil, func() {}, fetchErr - } - if fresh != nil { - if isTerminalSessionState(fresh.State) && fresh.State != requestedState { - unlock() - return nil, func() {}, &SessionStateSupersededError{ - SessionID: session.ID, - State: fresh.State, - } - } - session.State = fresh.State + return nil, func() {}, err } // Skip the "already running" rejection for terminal-state sessions — the agent // process is dead by definition, and ResumeSession will force-cleanup stale // state before the relaunch. - if !isTerminalSessionState(session.State) { - if existing, ok := e.GetExecutionBySession(session.ID); ok && existing != nil { - unlock() - return nil, func() {}, ErrExecutionAlreadyRunning - } + if err := e.rejectRunningResume(session, options); err != nil { + unlock() + return nil, func() {}, err } return task, unlock, nil } +func validateResumeSession(session *models.TaskSession, options ResumeOptions) error { + if session == nil { + return ErrExecutionNotFound + } + if session.State == models.TaskSessionStateCompleted && !options.AllowCompletedSessionResume { + return &SessionStateSupersededError{ + SessionID: session.ID, + State: session.State, + } + } + return nil +} + +func (e *Executor) loadResumeTask(ctx context.Context, session *models.TaskSession) (*v1.Task, error) { + taskModel, err := e.repo.GetTask(ctx, session.TaskID) + if err != nil { + e.logger.Error("failed to load task for session resume", + zap.String("task_id", session.TaskID), + zap.String("session_id", session.ID), + zap.Error(err)) + return nil, err + } + if taskModel.ArchivedAt != nil { + return nil, ErrTaskArchived + } + task := taskModel.ToAPI() + if task == nil { + return nil, ErrExecutionNotFound + } + if session.AgentProfileID == "" { + e.logger.Error("task session has no agent_profile_id configured", + zap.String("task_id", session.TaskID), + zap.String("session_id", session.ID)) + return nil, ErrNoAgentProfileID + } + return task, nil +} + +func (e *Executor) refreshResumeSessionState( + ctx context.Context, + session *models.TaskSession, + requestedState models.TaskSessionState, +) error { + fresh, err := e.repo.GetTaskSession(ctx, session.ID) + if err != nil { + e.logger.Warn("failed to re-read session state inside lock; aborting resume to avoid duplicate agent", + zap.String("session_id", session.ID), + zap.Error(err)) + return err + } + if fresh == nil { + return nil + } + if isTerminalSessionState(fresh.State) && fresh.State != requestedState { + return &SessionStateSupersededError{ + SessionID: session.ID, + State: fresh.State, + } + } + session.State = fresh.State + return nil +} + +func (e *Executor) rejectRunningResume(session *models.TaskSession, options ResumeOptions) error { + completedResume := options.AllowCompletedSessionResume && + session.State == models.TaskSessionStateCompleted + if isTerminalSessionState(session.State) || completedResume { + return nil + } + if existing, ok := e.GetExecutionBySession(session.ID); ok && existing != nil { + return ErrExecutionAlreadyRunning + } + return nil +} + // buildResumeRequest constructs the LaunchAgentRequest for a session resume, resolving executor config, // repository details, worktree settings, and ACP resume token. // Returns the request, repository ID, executor config, existing ExecutorRunning record (may be nil), and error. @@ -1507,14 +1563,26 @@ func (e *Executor) applyRunningRecordToResumeRequest( ) *models.ExecutorRunning { if running == nil { // Archive cleanup tears down the executors_running row entirely, so an - // archive-cancelled session reaches this point with running == nil — - // exactly the shape GetTaskSessionStatus's resumeReasonArchiveCancelledResumable - // auto-resumes once the task is unarchived. There is no resume token or - // running record to fall back on here, but the launch still must not - // replay task.Description as a fresh prompt — see the else-if branch - // below for the same guard on the running-record path. - if startAgent && isArchiveCancelledResumeSession(session) { - req.TaskDescription = "" + // archive-cancelled session reaches this point with running == nil. The + // session metadata mirrors the provider conversation identity so an + // explicit completed follow-up can still restore the same conversation + // after runtime cleanup removed the operational row. + noAutoPromptState := session.State == models.TaskSessionStateWaitingForInput || + isArchiveCancelledResumeSession(session) || + session.State == models.TaskSessionStateCompleted + if startAgent && noAutoPromptState { + if token := persistedSessionResumeToken(session); token != "" { + req.ACPSessionID = token + req.TaskDescription = "" + e.logger.Info("found persisted session resume token after runtime cleanup", + zap.String("task_id", task.ID), + zap.String("session_id", session.ID), + zap.Bool("has_resume_token", true)) + } else { + // A missing token must still never turn recovery into an automatic + // task-description prompt. + req.TaskDescription = "" + } } return nil } @@ -1547,11 +1615,12 @@ func (e *Executor) applyRunningRecordToResumeRequest( zap.String("task_id", task.ID), zap.String("session_id", session.ID), zap.Bool("has_resume_token", running.ResumeToken != "")) - } else if startAgent && (session.State == models.TaskSessionStateWaitingForInput || isArchiveCancelledResumeSession(session)) { + } else if startAgent && (session.State == models.TaskSessionStateWaitingForInput || + isArchiveCancelledResumeSession(session) || session.State == models.TaskSessionStateCompleted) { // Fresh-start resume (no resume token): don't auto-prompt with the task - // description. Also covers an archive-cancelled session whose running - // record survived cleanup but carries no token — same auto-resume shape - // as the running==nil branch above. + // description. Also covers completed and archive-cancelled sessions whose + // running record survived cleanup but carries no token — the same + // auto-resume shape as the running==nil branch above. req.TaskDescription = "" e.logger.Info("fresh-start resume, clearing task description to avoid auto-prompt", zap.String("task_id", task.ID), @@ -1561,6 +1630,34 @@ func (e *Executor) applyRunningRecordToResumeRequest( return running } +// persistedSessionResumeToken returns the provider conversation identity that +// survives executors_running cleanup. The dynamic route projection is checked +// first; the generic ACP metadata mirror is the fallback for ordinary sessions. +func persistedSessionResumeToken(session *models.TaskSession) string { + if session == nil { + return "" + } + if session.DownstreamACPSessionID != "" { + return session.DownstreamACPSessionID + } + if session.Metadata == nil { + return "" + } + acp, ok := session.Metadata["acp"] + if !ok { + return "" + } + switch value := acp.(type) { + case map[string]interface{}: + token, _ := value["session_id"].(string) + return token + case map[string]string: + return value["session_id"] + default: + return "" + } +} + // applyResumeRepoConfig resolves repository details and applies them to req. // Returns the resolved repositoryID. existingEnv is the task_environments row // resolved upstream in buildResumeRequest; it is passed in so we can derive @@ -1815,16 +1912,41 @@ func resolveResumeTaskDirName(existingEnv *models.TaskEnvironment, task *v1.Task // lifecycle.persistExecutorRunning. The orchestrator's remaining // responsibility is the session-row state machine (STARTING / CompletedAt-clear). func (e *Executor) persistResumeState(ctx context.Context, taskID string, session *models.TaskSession, startAgent bool) error { + return e.persistResumeStateWithOptions(ctx, taskID, session, startAgent, ResumeOptions{}) +} + +func (e *Executor) persistResumeStateWithOptions( + ctx context.Context, + taskID string, + session *models.TaskSession, + startAgent bool, + options ResumeOptions, +) error { expectedState := session.State + completedResume := startAgent && options.AllowCompletedSessionResume && + expectedState == models.TaskSessionStateCompleted session.ErrorMessage = "" if startAgent { session.State = models.TaskSessionStateStarting session.CompletedAt = nil + if completedResume { + if session.Metadata == nil { + session.Metadata = make(map[string]interface{}) + } + session.Metadata[models.SessionMetaKeyCompletionFollowUp] = true + } } var updateErr error if startAgent { - updateErr = e.updateSessionStarting(ctx, taskID, session, expectedState, false) + updateErr = e.updateSessionStartingWithOptions( + ctx, + taskID, + session, + expectedState, + false, + completedResume, + ) } else { updateErr = e.persistSessionFullRowIfCurrentState(ctx, session, expectedState) } @@ -1835,9 +1957,41 @@ func (e *Executor) persistResumeState(ctx context.Context, taskID string, sessio zap.Error(updateErr)) return updateErr } + if completedResume { + return e.persistCompletionFollowUpMarker(ctx, session.ID) + } return nil } +func (e *Executor) persistCompletionFollowUpMarker(ctx context.Context, sessionID string) error { + setter, ok := e.repo.(sessionMetadataKeyStateSetter) + if !ok { + return e.repo.SetSessionMetadataKey( + ctx, + sessionID, + models.SessionMetaKeyCompletionFollowUp, + true, + ) + } + changed, err := setter.SetSessionMetadataKeyIfState( + ctx, + sessionID, + models.SessionMetaKeyCompletionFollowUp, + true, + models.TaskSessionStateStarting, + ) + if err != nil { + return err + } + if changed { + return nil + } + return &SessionStateSupersededError{ + SessionID: sessionID, + State: models.TaskSessionStateStarting, + } +} + // prNumberFromMetadata extracts a GitHub PR number from a task_repository's // metadata bag. Stored as JSON, so on retrieval the value can decode as either // float64 (default for json.Unmarshal into interface{}) or int. Returns 0 when @@ -1871,12 +2025,24 @@ func prNumberFromMetadata(metadata map[string]interface{}) int { // Task state is managed by workflow triggers and stream handlers elsewhere; this callback // just logs successful process start. func (e *Executor) startAgentProcessOnResume(ctx context.Context, taskID string, session *models.TaskSession, agentExecutionID string) { + e.startAgentProcessOnResumeWithTaskPromotion(ctx, taskID, session, agentExecutionID, true) +} + +func (e *Executor) startAgentProcessOnResumeWithTaskPromotion( + ctx context.Context, + taskID string, + session *models.TaskSession, + agentExecutionID string, + promoteTask bool, +) { e.runAgentProcessAsync(ctx, taskID, session.ID, agentExecutionID, func(updCtx context.Context) { - if updateErr := e.writeTaskInProgressForRuntime(updCtx, taskID, session.ID); updateErr != nil { - e.logger.Warn("failed to update task state to IN_PROGRESS after resume start", - zap.String("task_id", taskID), - zap.String("session_id", session.ID), - zap.Error(updateErr)) + if promoteTask { + if updateErr := e.writeTaskInProgressForRuntime(updCtx, taskID, session.ID); updateErr != nil { + e.logger.Warn("failed to update task state to IN_PROGRESS after resume start", + zap.String("task_id", taskID), + zap.String("session_id", session.ID), + zap.Error(updateErr)) + } } e.logger.Debug("agent resumed successfully", zap.String("task_id", taskID), diff --git a/apps/backend/internal/orchestrator/executor/executor_resume_test.go b/apps/backend/internal/orchestrator/executor/executor_resume_test.go index ea6dadcf2c..89f6623d17 100644 --- a/apps/backend/internal/orchestrator/executor/executor_resume_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_resume_test.go @@ -983,6 +983,45 @@ func TestResumeSession_ArchiveCancelledWithoutRunningRow_ClearsTaskDescription(t } } +func TestApplyRunningRecordToResumeRequest_FailedSessionKeepsTaskDescription(t *testing.T) { + repo := newMockRepository() + exec := newTestExecutor(t, &mockAgentManager{}, repo) + req := &LaunchAgentRequest{TaskDescription: "recover the failed task"} + task := &v1.Task{ID: "task-1"} + session := &models.TaskSession{ + ID: "sess-1", + State: models.TaskSessionStateFailed, + DownstreamACPSessionID: "previous-conversation", + } + + exec.applyRunningRecordToResumeRequest(req, task, session, true, nil) + + if req.TaskDescription != "recover the failed task" { + t.Fatalf("failed-session TaskDescription = %q, want original prompt", req.TaskDescription) + } + if req.ACPSessionID != "" { + t.Fatalf("failed-session ACP session ID = %q, want empty", req.ACPSessionID) + } +} + +func TestApplyRunningRecordToResumeRequest_CompletedTokenlessRunningRowClearsTaskDescription(t *testing.T) { + repo := newMockRepository() + exec := newTestExecutor(t, &mockAgentManager{}, repo) + req := &LaunchAgentRequest{TaskDescription: "recover the completed task"} + task := &v1.Task{ID: "task-1"} + session := &models.TaskSession{ + ID: "sess-1", + State: models.TaskSessionStateCompleted, + } + running := &models.ExecutorRunning{SessionID: "sess-1", TaskID: "task-1"} + + exec.applyRunningRecordToResumeRequest(req, task, session, true, running) + + if req.TaskDescription != "" { + t.Fatalf("completed-session TaskDescription = %q, want empty", req.TaskDescription) + } +} + // TestResumeSession_UserCancelledWithoutRunningRow_KeepsTaskDescription is // the scoping counterpart to the archive-cancelled test above: a session the // user explicitly stopped (not an archive side effect) must not have its diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index 05912ce122..e822b9f298 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -1640,6 +1640,18 @@ func NewService( ) error { return s.setSessionStarting(ctx, taskID, session, expectedState, promoteTask) }) + exec.SetOnSessionStartingWithOptions(func( + ctx context.Context, + taskID string, + session *models.TaskSession, + expectedState models.TaskSessionState, + promoteTask bool, + allowCompletedResume bool, + ) error { + return s.setSessionStartingWithOptions( + ctx, taskID, session, expectedState, promoteTask, allowCompletedResume, + ) + }) exec.SetOnExecutionCleanupClaim(s.claimForcedExecutionCleanup) exec.SetOnExecutionStopOwnerRegistration(s.RegisterExecutionStopOwner) exec.SetOnTaskReviewStateReconcile(func(ctx context.Context, taskID, completedSessionID string) { diff --git a/apps/backend/internal/orchestrator/session_launch.go b/apps/backend/internal/orchestrator/session_launch.go index b2adee5133..e1befdf90a 100644 --- a/apps/backend/internal/orchestrator/session_launch.go +++ b/apps/backend/internal/orchestrator/session_launch.go @@ -82,6 +82,11 @@ type LaunchSessionRequest struct { // AllowBranchReplacement is set only by RecoverSession for the explicit // resume_new_branch action. Clients cannot grant this permission directly. AllowBranchReplacement bool `json:"-"` + // AllowCompletedSessionResume is set only by explicit recovery or a pinned + // follow-up dispatcher. It is intentionally not part of the wire request: + // ordinary launch, ensure, and startup recovery paths must keep completed + // sessions terminal. + AllowCompletedSessionResume bool `json:"-"` } // SpawnOrigin describes the agent session that spawned a new sibling session. @@ -350,7 +355,8 @@ func (s *Service) launchStartCreated(ctx context.Context, req *LaunchSessionRequ // launchResume resumes a stopped session. func (s *Service) launchResume(ctx context.Context, req *LaunchSessionRequest) (*LaunchSessionResponse, error) { execution, err := s.ResumeTaskSessionWithOptions(ctx, req.TaskID, req.SessionID, executor.ResumeOptions{ - AllowBranchReplacement: req.AllowBranchReplacement, + AllowBranchReplacement: req.AllowBranchReplacement, + AllowCompletedSessionResume: req.AllowCompletedSessionResume, }) if err != nil { return nil, err @@ -451,10 +457,11 @@ func (s *Service) RecoverSession(ctx context.Context, taskID, sessionID, action } resp, err := s.LaunchSession(ctx, &LaunchSessionRequest{ - TaskID: taskID, - SessionID: sessionID, - Intent: IntentResume, - AllowBranchReplacement: action == "resume_new_branch", + TaskID: taskID, + SessionID: sessionID, + Intent: IntentResume, + AllowBranchReplacement: action == "resume_new_branch", + AllowCompletedSessionResume: action == "resume", }) if err != nil { return nil, normalizeRecoverSessionError(err) diff --git a/apps/backend/internal/orchestrator/task_launch_pr_gate_test.go b/apps/backend/internal/orchestrator/task_launch_pr_gate_test.go index 304df8e7c9..9b4b26d110 100644 --- a/apps/backend/internal/orchestrator/task_launch_pr_gate_test.go +++ b/apps/backend/internal/orchestrator/task_launch_pr_gate_test.go @@ -130,13 +130,14 @@ func TestTaskPRLaunchErrorStampIsStableAndIncludesState(t *testing.T) { func TestWorkflowHasValidTerminalFinalStep(t *testing.T) { getter := newMockStepGetter() getter.steps["review"] = &wfmodels.WorkflowStep{ID: "review", WorkflowID: "workflow", Position: 1, Name: "Review"} - getter.steps["done"] = &wfmodels.WorkflowStep{ID: "done", WorkflowID: "workflow", Position: 2, Name: "Done"} + getter.steps["done"] = &wfmodels.WorkflowStep{ID: "done", WorkflowID: "workflow", Position: 2, Name: "Done", CompleteTaskOnEnter: true} if !workflowHasValidTerminalFinalStep(t.Context(), getter, "review") { t.Fatal("final Done step should enable mark_review_done") } getter.steps["done"].Name = "Archive" + getter.steps["done"].CompleteTaskOnEnter = false if workflowHasValidTerminalFinalStep(t.Context(), getter, "review") { t.Fatal("non-terminal final step should not enable mark_review_done") } @@ -159,7 +160,7 @@ func TestShouldSkipTerminalPRAutoStartPersistsAndClearsStampedError(t *testing.T } steps := newMockStepGetter() steps.steps["review"] = &wfmodels.WorkflowStep{ID: "review", WorkflowID: "wf1", Position: 1, Name: "Review"} - steps.steps["done"] = &wfmodels.WorkflowStep{ID: "done", WorkflowID: "wf1", Position: 2, Name: "Done"} + steps.steps["done"] = &wfmodels.WorkflowStep{ID: "done", WorkflowID: "wf1", Position: 2, Name: "Done", CompleteTaskOnEnter: true} svc := createTestService(repo, steps, newMockTaskRepo()) svc.SetGitHubService(&mockGitHubService{taskPRs: []*github.TaskPR{ {TaskID: "task-pr-gate", RepositoryID: "repo-1", PRNumber: 42, State: githubPRStateMerged}, diff --git a/apps/backend/internal/orchestrator/task_launch_recovery_test.go b/apps/backend/internal/orchestrator/task_launch_recovery_test.go index 1cac6b1188..987a81ca94 100644 --- a/apps/backend/internal/orchestrator/task_launch_recovery_test.go +++ b/apps/backend/internal/orchestrator/task_launch_recovery_test.go @@ -125,7 +125,7 @@ func recoveryFixtureService(t *testing.T, repo *sqliterepo.Repository, fake *tas t.Helper() steps := newMockStepGetter() steps.steps["review-recovery"] = &wfmodels.WorkflowStep{ID: "review-recovery", WorkflowID: "wf-recovery", Position: 0, Name: "Review"} - steps.steps["done-recovery"] = &wfmodels.WorkflowStep{ID: "done-recovery", WorkflowID: "wf-recovery", Position: 1, Name: "Done"} + steps.steps["done-recovery"] = &wfmodels.WorkflowStep{ID: "done-recovery", WorkflowID: "wf-recovery", Position: 1, Name: "Done", CompleteTaskOnEnter: true} svc := createTestService(repo, steps, newMockTaskRepo()) svc.taskLaunchRecoveryRepo = repo svc.taskLaunchRecoveryTasks = fake diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index 85a6fd50a7..442f72e913 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -2301,26 +2301,33 @@ func (s *Service) ResumeTaskSessionWithOptions( if session.TaskID != taskID { return nil, fmt.Errorf("task session does not belong to task") } + allowCompletedResume := options.AllowCompletedSessionResume && + session.State == models.TaskSessionStateCompleted + // The completed-session permission is valid only for the exact completed + // row admitted by the caller. Do not let an option intended for that state + // alter the ordinary FAILED/CANCELLED recovery paths. + options.AllowCompletedSessionResume = allowCompletedResume + // Completed sessions remain closed to every implicit resume path. Check this + // before looking for an executor row because cleanup commonly removes that + // row, and the caller should receive the terminal-state rejection rather + // than an incidental "no executor record" error. + if session.State == models.TaskSessionStateCompleted && !allowCompletedResume { + return nil, fmt.Errorf("session is completed and cannot be resumed; create a new session instead") + } running, err := s.repo.GetExecutorRunningBySessionID(ctx, sessionID) if (err != nil || running == nil) && session.State != models.TaskSessionStateCancelled && - session.State != models.TaskSessionStateFailed { + session.State != models.TaskSessionStateFailed && + !allowCompletedResume { // Executor record is required for non-terminal sessions. For cancelled/failed sessions // the record may already have been cleaned up before the user clicked Resume — allow it. + // Explicit completed follow-up has the same cleanup shape and is admitted + // only through the narrow permission above. return nil, fmt.Errorf("session is not resumable: no executor record") } s.noteMissingWorktreesBeforeResume(sessionID, session) branchRecoveryBefore := s.captureBranchRecoveryBeforeResume(ctx, taskID, sessionID, options) - // Completed sessions cannot be restarted — they require a new session. - // Failed and cancelled sessions keep the resume token so the relaunched - // agent continues the previous conversation (via ACP session/load for - // native-resume agents, or --resume on CLI). Users who want a fresh start - // after a failure can invoke RecoverSession with action="fresh_start". - if session.State == models.TaskSessionStateCompleted { - return nil, fmt.Errorf("session is completed and cannot be resumed; create a new session instead") - } - isOfficeTask, err := s.lookupOfficeTask(ctx, taskID) if err != nil { return nil, fmt.Errorf("failed to determine office task status: %w", err) diff --git a/apps/backend/internal/orchestrator/task_operations_test.go b/apps/backend/internal/orchestrator/task_operations_test.go index b4c5c5c21a..6ae9c6adc6 100644 --- a/apps/backend/internal/orchestrator/task_operations_test.go +++ b/apps/backend/internal/orchestrator/task_operations_test.go @@ -1050,10 +1050,11 @@ func cancelCompletionStepGetter(enabled, signalRequired bool) *mockStepGetter { }}}, } getter.steps["step3"] = &wfmodels.WorkflowStep{ - ID: "step3", - WorkflowID: "wf1", - Name: "Done", - Position: 2, + ID: "step3", + WorkflowID: "wf1", + Name: "Done", + Position: 2, + CompleteTaskOnEnter: true, } return getter } @@ -1383,6 +1384,7 @@ func TestCancelAgent_TerminalTransitionSkipsIntermediateReview(t *testing.T) { seedSession(t, repo, taskID, sessionID, "step1") steps := cancelCompletionStepGetter(true, false) steps.steps["step2"].Name = "Done" + steps.steps["step2"].CompleteTaskOnEnter = true delete(steps.steps, "step3") taskRepo := newMockTaskRepo() diff --git a/apps/backend/internal/orchestrator/workflow_e2e_test.go b/apps/backend/internal/orchestrator/workflow_e2e_test.go index 3cfb129d6e..2ab1228b0d 100644 --- a/apps/backend/internal/orchestrator/workflow_e2e_test.go +++ b/apps/backend/internal/orchestrator/workflow_e2e_test.go @@ -280,6 +280,9 @@ func buildWorkflowFromJSON(t *testing.T, jsonStr string) (*mockStepGetter, map[s if err := json.Unmarshal([]byte(jsonStr), &export); err != nil { t.Fatalf("failed to unmarshal workflow JSON: %v", err) } + if err := export.NormalizeCompletionPolicy(); err != nil { + t.Fatalf("failed to normalize workflow completion policy: %v", err) + } if len(export.Workflows) == 0 { t.Fatal("workflow JSON contains no workflows") } @@ -297,7 +300,7 @@ func buildWorkflowFromJSON(t *testing.T, jsonStr string) (*mockStepGetter, map[s events := wfmodels.ConvertPositionToStepID(sp.Events, posToID) sg.steps[id] = &wfmodels.WorkflowStep{ ID: id, WorkflowID: "wf1", Name: sp.Name, Position: sp.Position, - Prompt: sp.Prompt, Events: events, + Prompt: sp.Prompt, Events: events, CompleteTaskOnEnter: sp.CompleteTaskOnEnter, } nameToID[sp.Name] = id } diff --git a/apps/backend/internal/task/dto/converters.go b/apps/backend/internal/task/dto/converters.go index ca77d57873..927cdb2c0f 100644 --- a/apps/backend/internal/task/dto/converters.go +++ b/apps/backend/internal/task/dto/converters.go @@ -28,6 +28,7 @@ func FromWorkflowStep(step *wfmodels.WorkflowStep) WorkflowStepDTO { StageType: string(step.StageType), AutoAdvanceRequiresSignal: step.AutoAdvanceRequiresSignal, CancelTriggersTurnComplete: step.CancelTriggersTurnComplete, + CompleteTaskOnEnter: step.CompleteTaskOnEnter, } if hasStepEvents(step.Events) { events := &StepEventsDTO{} diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index 5b921acd68..89618c2ea8 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -1180,6 +1180,7 @@ type WorkflowStepDTO struct { StageType string `json:"stage_type,omitempty"` AutoAdvanceRequiresSignal bool `json:"auto_advance_requires_signal"` CancelTriggersTurnComplete bool `json:"cancel_triggers_turn_complete"` + CompleteTaskOnEnter bool `json:"complete_task_on_enter"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index d0b1650f8b..221e0b25d3 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -269,6 +269,10 @@ func HasAutoStartOnCreateIntent(metadata map[string]interface{}) bool { const ( SessionMetaKeyCreatedBy = "created_by" SessionCreatedByWorkflowSwitch = "workflow_switch" + // SessionMetaKeyCompletionFollowUp marks a completed conversation that was + // explicitly resumed for conversational follow-up. It is not workflow + // ownership and must not be treated as a task-state transition. + SessionMetaKeyCompletionFollowUp = "completion_follow_up" // SessionMetaKeyWorkflowProfileSwitchStopIntent identifies the transient // coordination record used to suppress the lifecycle event caused by a // parked workflow profile switch. @@ -285,6 +289,14 @@ const ( SessionMetaKeyRecoveryResolvedAt = "recovery_resolved_at" ) +// IsCompletionFollowUpSession reports whether a session was explicitly +// reopened only to continue its completed conversation. The marker remains +// meaningful after the session settles back to WAITING_FOR_INPUT. +func IsCompletionFollowUpSession(metadata map[string]interface{}) bool { + followUp, ok := metadata[SessionMetaKeyCompletionFollowUp].(bool) + return ok && followUp +} + // WorkflowProfileSwitchStopIntent binds a deliberate parked-session stop to // one exact runtime execution. Stamp is compared before the metadata value is // marked consumed, so a delayed event cannot consume a newer switch intent. @@ -983,12 +995,11 @@ type OfficeDecisionWaitCursor struct { // ChildCompletionRow is the compact active-child projection used to decide // whether a parent task's on_children_completed trigger is ready to fire. type ChildCompletionRow struct { - ID string `json:"id" db:"id"` - State v1.TaskState `json:"state" db:"state"` - Title string `json:"title" db:"title"` - WorkflowStepID string `json:"workflow_step_id" db:"workflow_step_id"` - TerminalWorkflowStep bool `json:"terminal_workflow_step"` // computed by annotateTerminalChildSteps, not a DB column - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + ID string `json:"id" db:"id"` + State v1.TaskState `json:"state" db:"state"` + Title string `json:"title" db:"title"` + WorkflowStepID string `json:"workflow_step_id" db:"workflow_step_id"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` } // HasStartWhenUnblockedIntent reports whether a task's deferred launch intent diff --git a/apps/backend/internal/task/models/models_test.go b/apps/backend/internal/task/models/models_test.go index 9cfe9d135d..731ab672dd 100644 --- a/apps/backend/internal/task/models/models_test.go +++ b/apps/backend/internal/task/models/models_test.go @@ -95,6 +95,19 @@ func TestIsOriginalTaskSessionUsesImmutableOriginMarker(t *testing.T) { })) } +func TestIsCompletionFollowUpSessionRequiresTrueBoolean(t *testing.T) { + require.True(t, IsCompletionFollowUpSession(map[string]interface{}{ + SessionMetaKeyCompletionFollowUp: true, + })) + require.False(t, IsCompletionFollowUpSession(map[string]interface{}{ + SessionMetaKeyCompletionFollowUp: false, + })) + require.False(t, IsCompletionFollowUpSession(map[string]interface{}{ + SessionMetaKeyCompletionFollowUp: "true", + })) + require.False(t, IsCompletionFollowUpSession(nil)) +} + func TestLoadSessionACPConfigBaselinePreservesEmptyJSONValues(t *testing.T) { baseline, ok := LoadSessionACPConfigBaseline(map[string]interface{}{ SessionMetaKeyACPConfigBaseline: map[string]interface{}{ diff --git a/apps/backend/internal/task/repository/sqlite/base_migrations.go b/apps/backend/internal/task/repository/sqlite/base_migrations.go index 3fb58ed36c..958c04abd6 100644 --- a/apps/backend/internal/task/repository/sqlite/base_migrations.go +++ b/apps/backend/internal/task/repository/sqlite/base_migrations.go @@ -307,6 +307,7 @@ func (r *Repository) runMigrations() error { // migrations are idempotent and preserve the false default for legacy rows. r.migrate.Apply("workflow_steps.auto_advance_requires_signal", `ALTER TABLE workflow_steps ADD COLUMN auto_advance_requires_signal INTEGER NOT NULL DEFAULT 0`) r.migrate.Apply("workflow_steps.cancel_triggers_turn_complete", `ALTER TABLE workflow_steps ADD COLUMN cancel_triggers_turn_complete INTEGER NOT NULL DEFAULT 0`) + _ = r.migrate.Apply("workflow_steps.complete_task_on_enter", `ALTER TABLE workflow_steps ADD COLUMN complete_task_on_enter INTEGER NOT NULL DEFAULT 0`) r.migrate.Apply("workflow_steps.profile_session_start_policy", `ALTER TABLE workflow_steps ADD COLUMN profile_session_start_policy TEXT NOT NULL DEFAULT 'reuse'`) r.migrate.Apply("workflow_steps.profile_session_end_policy", `ALTER TABLE workflow_steps ADD COLUMN profile_session_end_policy TEXT NOT NULL DEFAULT 'complete'`) diff --git a/apps/backend/internal/task/repository/sqlite/base_schema.go b/apps/backend/internal/task/repository/sqlite/base_schema.go index 3121fab67a..3fa212926a 100644 --- a/apps/backend/internal/task/repository/sqlite/base_schema.go +++ b/apps/backend/internal/task/repository/sqlite/base_schema.go @@ -238,6 +238,7 @@ func (r *Repository) ensureRunnerProjectionTables() error { stage_type TEXT NOT NULL DEFAULT 'custom', auto_advance_requires_signal INTEGER NOT NULL DEFAULT 0, cancel_triggers_turn_complete INTEGER NOT NULL DEFAULT 0, + complete_task_on_enter INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP )`); err != nil { diff --git a/apps/backend/internal/task/repository/sqlite/builtin_workflow_test.go b/apps/backend/internal/task/repository/sqlite/builtin_workflow_test.go index c1ba1acd7e..6e736c730b 100644 --- a/apps/backend/internal/task/repository/sqlite/builtin_workflow_test.go +++ b/apps/backend/internal/task/repository/sqlite/builtin_workflow_test.go @@ -184,6 +184,34 @@ func TestCreateWorkspaceWithKanban_CancelTriggersTurnCompleteDefaults(t *testing } } +func TestCreateWorkspaceWithKanban_PersistsCompletionAndWIPFields(t *testing.T) { + repo := newRepoForBuiltinWorkflowTests(t) + ctx := context.Background() + workflow, err := repo.CreateWorkspaceWithKanban(ctx, &taskmodels.Workspace{ID: "ws-kanban-completion", Name: "Kanban Completion"}) + if err != nil { + t.Fatalf("create Kanban workspace: %v", err) + } + + var wipLimit, completeTaskOnEnter int + var pullFromStepID string + if err := repo.db.QueryRowContext(ctx, repo.db.Rebind(` + SELECT wip_limit, pull_from_step_id, complete_task_on_enter + FROM workflow_steps + WHERE workflow_id = ? AND name = 'Done' + `), workflow.ID).Scan(&wipLimit, &pullFromStepID, &completeTaskOnEnter); err != nil { + t.Fatalf("query Kanban completion fields: %v", err) + } + if wipLimit != 0 { + t.Fatalf("Done WIP limit = %d, want 0", wipLimit) + } + if pullFromStepID != "" { + t.Fatalf("Done pull-from step = %q, want empty", pullFromStepID) + } + if completeTaskOnEnter != 1 { + t.Fatalf("Done complete-task-on-enter = %d, want 1", completeTaskOnEnter) + } +} + func TestEnsureOfficeDefaultWorkflow_TriggersJSONShape(t *testing.T) { repo := newRepoForBuiltinWorkflowTests(t) ctx := context.Background() diff --git a/apps/backend/internal/task/repository/sqlite/defaults.go b/apps/backend/internal/task/repository/sqlite/defaults.go index 103f6fdecb..ad02a93f7e 100644 --- a/apps/backend/internal/task/repository/sqlite/defaults.go +++ b/apps/backend/internal/task/repository/sqlite/defaults.go @@ -473,14 +473,14 @@ func (r *Repository) insertTemplateStep( allow_manual_move, is_start_step, show_in_command_panel, auto_archive_after_hours, agent_profile_id, profile_session_start_policy, profile_session_end_policy, stage_type, auto_advance_requires_signal, cancel_triggers_turn_complete, - created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + complete_task_on_enter, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), idMap[def.ID], workflowID, def.Name, def.Position, def.Color, def.Prompt, string(eventsJSON), dialect.BoolToInt(def.AllowManualMove), dialect.BoolToInt(def.IsStartStep), dialect.BoolToInt(def.ShowInCommandPanel), def.AutoArchiveAfterHours, def.AgentProfileID, models.NormalizeWorkflowProfileSessionStartPolicy(string(def.ProfileSessionStartPolicy)), models.NormalizeWorkflowProfileSessionEndPolicy(string(def.ProfileSessionEndPolicy)), string(stage), - dialect.BoolToInt(def.AutoAdvanceRequiresSignal), dialect.BoolToInt(def.CancelTriggersTurnComplete), + dialect.BoolToInt(def.AutoAdvanceRequiresSignal), dialect.BoolToInt(def.CancelTriggersTurnComplete), dialect.BoolToInt(def.CompleteTaskOnEnter), now, now, ) if err != nil { diff --git a/apps/backend/internal/task/repository/sqlite/repository_entity_test.go b/apps/backend/internal/task/repository/sqlite/repository_entity_test.go index 13e6b8a6ae..12bfbf102b 100644 --- a/apps/backend/internal/task/repository/sqlite/repository_entity_test.go +++ b/apps/backend/internal/task/repository/sqlite/repository_entity_test.go @@ -691,6 +691,7 @@ func TestRunnerProjectionWorkflowStepColumnsReplayMigration(t *testing.T) { }{ {name: "auto_advance_requires_signal", sql: `ALTER TABLE workflow_steps DROP COLUMN auto_advance_requires_signal`}, {name: "cancel_triggers_turn_complete", sql: `ALTER TABLE workflow_steps DROP COLUMN cancel_triggers_turn_complete`}, + {name: "complete_task_on_enter", sql: `ALTER TABLE workflow_steps DROP COLUMN complete_task_on_enter`}, } for _, column := range legacyColumns { if _, err := repo.db.Exec(column.sql); err != nil { @@ -713,12 +714,12 @@ func TestRunnerProjectionWorkflowStepColumnsReplayMigration(t *testing.T) { t.Fatalf("workflow_steps.%s column count = %d, want 1", column, count) } } - var autoAdvance, cancelComplete int - if err := repo.db.QueryRow(`SELECT auto_advance_requires_signal, cancel_triggers_turn_complete FROM workflow_steps WHERE id = 'legacy-projection-step'`).Scan(&autoAdvance, &cancelComplete); err != nil { + var autoAdvance, cancelComplete, completeTask int + if err := repo.db.QueryRow(`SELECT auto_advance_requires_signal, cancel_triggers_turn_complete, complete_task_on_enter FROM workflow_steps WHERE id = 'legacy-projection-step'`).Scan(&autoAdvance, &cancelComplete, &completeTask); err != nil { t.Fatalf("read migrated workflow step: %v", err) } - if autoAdvance != 0 || cancelComplete != 0 { - t.Fatalf("legacy workflow step defaults = (%d, %d), want (0, 0)", autoAdvance, cancelComplete) + if autoAdvance != 0 || cancelComplete != 0 || completeTask != 0 { + t.Fatalf("legacy workflow step defaults = (%d, %d, %d), want (0, 0, 0)", autoAdvance, cancelComplete, completeTask) } } diff --git a/apps/backend/internal/task/repository/sqlite/session.go b/apps/backend/internal/task/repository/sqlite/session.go index 92a75e8169..749862e1be 100644 --- a/apps/backend/internal/task/repository/sqlite/session.go +++ b/apps/backend/internal/task/repository/sqlite/session.go @@ -1863,6 +1863,34 @@ func (r *Repository) SetSessionMetadataKey(ctx context.Context, sessionID, key s return nil } +// SetSessionMetadataKeyIfState atomically sets one metadata key only while the +// session remains in expectedState. Runtime recovery uses this to keep a +// follow-up marker from being attached to a session that was stopped between +// its guarded state transition and metadata persistence. +func (r *Repository) SetSessionMetadataKeyIfState( + ctx context.Context, + sessionID, key string, + value interface{}, + expectedState models.TaskSessionState, +) (bool, error) { + valueJSON, err := json.Marshal(value) + if err != nil { + return false, fmt.Errorf("failed to serialize metadata value: %w", err) + } + query := metadataKeyUpdateQuery("task_sessions", r.db.DriverName()) + " AND state = ?" + args := metadataKeyUpdateArgs(r.db.DriverName(), key, string(valueJSON), r.nowUTC(), sessionID) + args = append(args, string(expectedState)) + result, err := r.db.ExecContext(ctx, r.db.Rebind(query), args...) + if err != nil { + return false, err + } + rows, err := result.RowsAffected() + if err != nil { + return false, err + } + return rows > 0, nil +} + // UpdateSessionContextWindow stores a context-window sample and atomically // increments the session's inferred compaction count when the new used-token // value is lower than the previous persisted sample. diff --git a/apps/backend/internal/task/repository/sqlite/session_test.go b/apps/backend/internal/task/repository/sqlite/session_test.go index 4d5c5e7e28..1dbacd9376 100644 --- a/apps/backend/internal/task/repository/sqlite/session_test.go +++ b/apps/backend/internal/task/repository/sqlite/session_test.go @@ -810,6 +810,45 @@ func TestSetSessionMetadataKeyIfAbsentSQLiteIsWriteOnce(t *testing.T) { } } +func TestSetSessionMetadataKeyIfStateGuardsTheSessionState(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + seedForMsgTest(t, repo, "task-session-marker", "session-session-marker", "turn-session-marker") + require.NoError(t, repo.SetSessionMetadataKey(ctx, "session-session-marker", "keep", "value")) + + changed, err := repo.SetSessionMetadataKeyIfState( + ctx, + "session-session-marker", + "completion_follow_up", + true, + models.TaskSessionStateCreated, + ) + require.NoError(t, err) + require.True(t, changed) + + require.NoError(t, repo.UpdateTaskSessionState( + ctx, + "session-session-marker", + models.TaskSessionStateCancelled, + "stopped", + )) + changed, err = repo.SetSessionMetadataKeyIfState( + ctx, + "session-session-marker", + "completion_follow_up", + false, + models.TaskSessionStateCreated, + ) + require.NoError(t, err) + require.False(t, changed) + + stored, err := repo.GetTaskSession(ctx, "session-session-marker") + require.NoError(t, err) + require.Equal(t, models.TaskSessionStateCancelled, stored.State) + require.Equal(t, true, stored.Metadata["completion_follow_up"]) + require.Equal(t, "value", stored.Metadata["keep"]) +} + func TestSetSessionMetadataKeyIfAbsentOrDifferentStepSQLiteReplacesOnlyStaleStep(t *testing.T) { repo := newRepoForSessionTests(t) seedForMsgTest(t, repo, "task-step-claim", "session-step-claim", "turn-step-claim") diff --git a/apps/backend/internal/task/repository/sqlite/task.go b/apps/backend/internal/task/repository/sqlite/task.go index 1a3cc20657..b1e3570126 100644 --- a/apps/backend/internal/task/repository/sqlite/task.go +++ b/apps/backend/internal/task/repository/sqlite/task.go @@ -3346,6 +3346,9 @@ func (r *Repository) tryUpdateTaskStateIfSessionState( if err != nil { return "", false, false, err } + if oldState == v1.TaskStateCompleted && state == v1.TaskStateInProgress { + return oldState, false, false, nil + } if archivedAt.Valid || currentSessionState != expectedSessionState || (requirePrimary && !currentSessionIsPrimary) { return oldState, false, false, nil diff --git a/apps/backend/internal/task/repository/sqlite/task_state_cas_test.go b/apps/backend/internal/task/repository/sqlite/task_state_cas_test.go index 8739d55328..55d9fc64b7 100644 --- a/apps/backend/internal/task/repository/sqlite/task_state_cas_test.go +++ b/apps/backend/internal/task/repository/sqlite/task_state_cas_test.go @@ -77,6 +77,40 @@ func TestUpdateTaskStateIfSessionState_SkipsArchivedTask(t *testing.T) { } } +func TestUpdateTaskStateIfSessionState_DoesNotReopenCompletedTask(t *testing.T) { + repo := newRepoForHealTests(t) + ctx := context.Background() + insertTask(t, repo.db, "task-session-completed") + insertSession(t, repo, "session-completed", "task-session-completed", string(models.TaskSessionStateStarting)) + + if err := repo.UpdateTaskState(ctx, "task-session-completed", v1.TaskStateCompleted); err != nil { + t.Fatalf("seed COMPLETED: %v", err) + } + oldState, updated, err := repo.UpdateTaskStateIfSessionState( + ctx, + "task-session-completed", + "session-completed", + models.TaskSessionStateStarting, + v1.TaskStateInProgress, + ) + if err != nil { + t.Fatalf("UpdateTaskStateIfSessionState: %v", err) + } + if updated { + t.Fatal("completed task was reopened by a runtime state writer") + } + if oldState != v1.TaskStateCompleted { + t.Fatalf("old state = %q, want %q", oldState, v1.TaskStateCompleted) + } + task, err := repo.GetTask(ctx, "task-session-completed") + if err != nil { + t.Fatalf("get task: %v", err) + } + if task.State != v1.TaskStateCompleted { + t.Fatalf("task state = %q, want %q", task.State, v1.TaskStateCompleted) + } +} + func TestUpdateTaskStateIfSessionState_UpdatesMatchingNonPrimarySession(t *testing.T) { repo := newRepoForHealTests(t) ctx := context.Background() diff --git a/apps/backend/internal/task/repository/sqlite/workspace_bootstrap.go b/apps/backend/internal/task/repository/sqlite/workspace_bootstrap.go index b16ec946cd..cd2eace8c0 100644 --- a/apps/backend/internal/task/repository/sqlite/workspace_bootstrap.go +++ b/apps/backend/internal/task/repository/sqlite/workspace_bootstrap.go @@ -84,12 +84,12 @@ func (r *Repository) insertTemplateSteps(ctx context.Context, tx *sqlx.Tx, workf allow_manual_move, is_start_step, show_in_command_panel, auto_archive_after_hours, agent_profile_id, profile_session_start_policy, profile_session_end_policy, stage_type, auto_advance_requires_signal, cancel_triggers_turn_complete, wip_limit, pull_from_step_id, - created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + complete_task_on_enter, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), idMap[stepDef.ID], workflowID, stepDef.Name, stepDef.Position, stepDef.Color, stepDef.Prompt, string(events), dialect.BoolToInt(stepDef.AllowManualMove), dialect.BoolToInt(stepDef.IsStartStep), dialect.BoolToInt(stepDef.ShowInCommandPanel), stepDef.AutoArchiveAfterHours, stepDef.AgentProfileID, models.NormalizeWorkflowProfileSessionStartPolicy(string(stepDef.ProfileSessionStartPolicy)), models.NormalizeWorkflowProfileSessionEndPolicy(string(stepDef.ProfileSessionEndPolicy)), normalizeBootstrapStageType(stepDef.StageType), - dialect.BoolToInt(stepDef.AutoAdvanceRequiresSignal), dialect.BoolToInt(stepDef.CancelTriggersTurnComplete), stepDef.WIPLimit, wfmodels.RemapStepID(stepDef.PullFromStepID, idMap), now, now); err != nil { + dialect.BoolToInt(stepDef.AutoAdvanceRequiresSignal), dialect.BoolToInt(stepDef.CancelTriggersTurnComplete), stepDef.WIPLimit, wfmodels.RemapStepID(stepDef.PullFromStepID, idMap), dialect.BoolToInt(stepDef.CompleteTaskOnEnter), now, now); err != nil { return fmt.Errorf("create Kanban step %q: %w", stepDef.Name, err) } } diff --git a/apps/backend/internal/task/service/service_workflow_test.go b/apps/backend/internal/task/service/service_workflow_test.go index 95b215cf64..2f07b09957 100644 --- a/apps/backend/internal/task/service/service_workflow_test.go +++ b/apps/backend/internal/task/service/service_workflow_test.go @@ -192,7 +192,7 @@ func TestService_MoveTaskToTerminalStepCompletesTask(t *testing.T) { seedMoveSteps(svc) getter := svc.workflowStepGetter.(*fakeWorkflowStepGetter) getter.steps["step-done"] = &wfmodels.WorkflowStep{ - ID: "step-done", WorkflowID: "wf-source", Name: "Done", Position: 2, + ID: "step-done", WorkflowID: "wf-source", Name: "Done", Position: 2, CompleteTaskOnEnter: true, } createMoveTask(t, ctx, repo, "task-terminal", "wf-source", "step-source", nil) eventBus.ClearEvents() @@ -232,7 +232,7 @@ func TestService_MoveTaskToTerminalStepPreservesTerminalFailureStates(t *testing seedMoveSteps(svc) getter := svc.workflowStepGetter.(*fakeWorkflowStepGetter) getter.steps["step-done"] = &wfmodels.WorkflowStep{ - ID: "step-done", WorkflowID: "wf-source", Name: "Done", Position: 2, + ID: "step-done", WorkflowID: "wf-source", Name: "Done", Position: 2, CompleteTaskOnEnter: true, } createMoveTask(t, ctx, repo, "task-terminal-"+tc.name, "wf-source", "step-source", nil) task, err := repo.GetTask(ctx, "task-terminal-"+tc.name) @@ -268,7 +268,7 @@ func TestService_MoveTaskRecoveryCompletesFailedTaskAtTerminalStep(t *testing.T) seedMoveSteps(svc) getter := svc.workflowStepGetter.(*fakeWorkflowStepGetter) getter.steps["step-done"] = &wfmodels.WorkflowStep{ - ID: "step-done", WorkflowID: "wf-source", Name: "Done", Position: 2, + ID: "step-done", WorkflowID: "wf-source", Name: "Done", Position: 2, CompleteTaskOnEnter: true, } createMoveTask(t, ctx, repo, "task-recovery", "wf-source", "step-source", nil) task, err := repo.GetTask(ctx, "task-recovery") @@ -304,7 +304,7 @@ func TestService_MoveTaskRecoveryIsIdempotentAtTerminalStep(t *testing.T) { seedMoveSteps(svc) getter := svc.workflowStepGetter.(*fakeWorkflowStepGetter) getter.steps["step-done"] = &wfmodels.WorkflowStep{ - ID: "step-done", WorkflowID: "wf-source", Name: "Done", Position: 2, + ID: "step-done", WorkflowID: "wf-source", Name: "Done", Position: 2, CompleteTaskOnEnter: true, } createMoveTask(t, ctx, repo, "task-recovery-idempotent", "wf-source", "step-done", nil) @@ -349,7 +349,7 @@ func TestService_MoveTaskOutOfTerminalStepReopensTask(t *testing.T) { seedMoveSteps(svc) getter := svc.workflowStepGetter.(*fakeWorkflowStepGetter) getter.steps["step-done"] = &wfmodels.WorkflowStep{ - ID: "step-done", WorkflowID: "wf-source", Name: "Done", Position: 2, + ID: "step-done", WorkflowID: "wf-source", Name: "Done", Position: 2, CompleteTaskOnEnter: true, } createMoveTask(t, ctx, repo, "task-reopened", "wf-source", "step-done", nil) task, err := repo.GetTask(ctx, "task-reopened") @@ -385,7 +385,7 @@ func TestService_ApproveSessionToTerminalStepCompletesTask(t *testing.T) { seedMoveSteps(svc) getter := svc.workflowStepGetter.(*fakeWorkflowStepGetter) getter.steps["step-done"] = &wfmodels.WorkflowStep{ - ID: "step-done", WorkflowID: "wf-source", Name: "Approved", Position: 2, + ID: "step-done", WorkflowID: "wf-source", Name: "Approved", Position: 2, CompleteTaskOnEnter: true, } createMoveTask(t, ctx, repo, "task-approved", "wf-source", "step-review-target", nil) createMoveSession(t, ctx, repo, "session-approved", "task-approved", models.TaskSessionStateWaitingForInput, models.ReviewStatusPending) @@ -795,7 +795,7 @@ func TestService_BulkMoveTasksToTerminalStepCompletesTasks(t *testing.T) { seedMoveSteps(svc) getter := svc.workflowStepGetter.(*fakeWorkflowStepGetter) getter.steps["step-done"] = &wfmodels.WorkflowStep{ - ID: "step-done", WorkflowID: "wf-source", Name: "Done", Position: 2, + ID: "step-done", WorkflowID: "wf-source", Name: "Done", Position: 2, CompleteTaskOnEnter: true, } createMoveTask(t, ctx, repo, "task-bulk-terminal", "wf-source", "step-source", nil) eventBus.ClearEvents() @@ -832,7 +832,7 @@ func TestService_BulkMoveTasksToTerminalStepPreservesTerminalFailureStates(t *te seedMoveSteps(svc) getter := svc.workflowStepGetter.(*fakeWorkflowStepGetter) getter.steps["step-done"] = &wfmodels.WorkflowStep{ - ID: "step-done", WorkflowID: "wf-source", Name: "Done", Position: 2, + ID: "step-done", WorkflowID: "wf-source", Name: "Done", Position: 2, CompleteTaskOnEnter: true, } createMoveTask(t, ctx, repo, "task-bulk-terminal-"+tc.name, "wf-source", "step-source", nil) task, err := repo.GetTask(ctx, "task-bulk-terminal-"+tc.name) diff --git a/apps/backend/internal/workflow/controller/controller.go b/apps/backend/internal/workflow/controller/controller.go index c50590432b..7f1c4da625 100644 --- a/apps/backend/internal/workflow/controller/controller.go +++ b/apps/backend/internal/workflow/controller/controller.go @@ -1,7 +1,9 @@ package controller import ( + "bytes" "context" + "encoding/json" "fmt" "strings" @@ -136,12 +138,40 @@ type CreateStepRequest struct { ShowInCommandPanel *bool `json:"show_in_command_panel,omitempty"` AutoAdvanceRequiresSignal *bool `json:"auto_advance_requires_signal,omitempty"` CancelTriggersTurnComplete *bool `json:"cancel_triggers_turn_complete,omitempty"` + CompleteTaskOnEnter *bool `json:"complete_task_on_enter,omitempty"` ProfileSessionStartPolicy *string `json:"profile_session_start_policy,omitempty"` ProfileSessionEndPolicy *string `json:"profile_session_end_policy,omitempty"` WIPLimit *int `json:"wip_limit,omitempty"` PullFromStepID *string `json:"pull_from_step_id,omitempty"` } +func rejectNullCompleteTaskOnEnter(data []byte) error { + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return err + } + if raw, ok := fields["complete_task_on_enter"]; ok && bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return fmt.Errorf("complete_task_on_enter must be a boolean") + } + return nil +} + +// UnmarshalJSON keeps omission distinct from an explicit null. The REST +// boundary uses nil to mean the documented default for create and no-op for +// update, so null must be rejected instead of silently taking either path. +func (r *CreateStepRequest) UnmarshalJSON(data []byte) error { + if err := rejectNullCompleteTaskOnEnter(data); err != nil { + return err + } + type requestAlias CreateStepRequest + var decoded requestAlias + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + *r = CreateStepRequest(decoded) + return nil +} + // CreateStep creates a new workflow step. func (c *Controller) CreateStep(ctx context.Context, req CreateStepRequest) (*GetStepResponse, error) { // The new step names its workflow in the body; authorize that workflow @@ -189,6 +219,9 @@ func (c *Controller) CreateStep(ctx context.Context, req CreateStepRequest) (*Ge if req.CancelTriggersTurnComplete != nil { step.CancelTriggersTurnComplete = *req.CancelTriggersTurnComplete } + if req.CompleteTaskOnEnter != nil { + step.CompleteTaskOnEnter = *req.CompleteTaskOnEnter + } if req.WIPLimit != nil { if *req.WIPLimit < 0 { return nil, fmt.Errorf("wip_limit must be non-negative") @@ -224,12 +257,27 @@ type UpdateStepRequest struct { AgentProfileID *string `json:"agent_profile_id,omitempty"` AutoAdvanceRequiresSignal *bool `json:"auto_advance_requires_signal,omitempty"` CancelTriggersTurnComplete *bool `json:"cancel_triggers_turn_complete,omitempty"` + CompleteTaskOnEnter *bool `json:"complete_task_on_enter,omitempty"` ProfileSessionStartPolicy *string `json:"profile_session_start_policy,omitempty"` ProfileSessionEndPolicy *string `json:"profile_session_end_policy,omitempty"` WIPLimit *int `json:"wip_limit,omitempty"` PullFromStepID *string `json:"pull_from_step_id,omitempty"` } +// UnmarshalJSON rejects null while preserving omission semantics for updates. +func (r *UpdateStepRequest) UnmarshalJSON(data []byte) error { + if err := rejectNullCompleteTaskOnEnter(data); err != nil { + return err + } + type requestAlias UpdateStepRequest + var decoded requestAlias + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + *r = UpdateStepRequest(decoded) + return nil +} + // UpdateStep updates an existing workflow step. func (c *Controller) UpdateStep(ctx context.Context, req UpdateStepRequest) (*GetStepResponse, error) { if err := c.svc.AuthorizeStep(ctx, req.ID); err != nil { @@ -287,6 +335,9 @@ func (c *Controller) UpdateStep(ctx context.Context, req UpdateStepRequest) (*Ge if req.CancelTriggersTurnComplete != nil { step.CancelTriggersTurnComplete = *req.CancelTriggersTurnComplete } + if req.CompleteTaskOnEnter != nil { + step.CompleteTaskOnEnter = *req.CompleteTaskOnEnter + } if req.WIPLimit != nil { if *req.WIPLimit < 0 { return nil, fmt.Errorf("wip_limit must be non-negative") diff --git a/apps/backend/internal/workflow/handlers/http_handlers_test.go b/apps/backend/internal/workflow/handlers/http_handlers_test.go index ba3331ff56..3368a2bdec 100644 --- a/apps/backend/internal/workflow/handlers/http_handlers_test.go +++ b/apps/backend/internal/workflow/handlers/http_handlers_test.go @@ -212,6 +212,41 @@ func TestStepMutationValidationRejections(t *testing.T) { } }) + t.Run("create rejects explicit null completion policy", func(t *testing.T) { + h := setupStepRouter(t) + rec := recordStepEvents(t, h.eventBus) + resp := doRaw(t, h, http.MethodPost, "/api/v1/workflow/steps", `{"workflow_id":"workflow-1","name":"Null completion","complete_task_on_enter":null}`) + if resp.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body = %s", resp.Code, resp.Body.String()) + } + if len(rec.events) != 0 { + t.Fatalf("rejected create published %v", rec.subjects()) + } + }) + + t.Run("update rejects explicit null completion policy without mutation", func(t *testing.T) { + h := setupStepRouter(t) + step := createStepViaHTTP(t, h.router, map[string]interface{}{ + "workflow_id": "workflow-1", "name": "Done", "position": 0, + "complete_task_on_enter": true, + }) + rec := recordStepEvents(t, h.eventBus) + resp := doRaw(t, h, http.MethodPut, "/api/v1/workflow/steps/"+step.ID, `{"complete_task_on_enter":null}`) + if resp.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body = %s", resp.Code, resp.Body.String()) + } + if len(rec.events) != 0 { + t.Fatalf("rejected update published %v", rec.subjects()) + } + stored, err := h.service.GetStep(context.Background(), step.ID) + if err != nil { + t.Fatalf("reload step: %v", err) + } + if !stored.CompleteTaskOnEnter { + t.Fatal("explicit null update changed the saved completion policy") + } + }) + t.Run("create without workflow_id or name", func(t *testing.T) { h := setupStepRouter(t) rec := recordStepEvents(t, h.eventBus) diff --git a/apps/backend/internal/workflow/models/export.go b/apps/backend/internal/workflow/models/export.go index 584bfc77f1..5b4f698938 100644 --- a/apps/backend/internal/workflow/models/export.go +++ b/apps/backend/internal/workflow/models/export.go @@ -1,16 +1,20 @@ package models import ( + "bytes" + "encoding/json" "fmt" "maps" "math" taskmodels "github.com/kandev/kandev/internal/task/models" + "gopkg.in/yaml.v3" ) const ( - ExportVersion = 1 - ExportType = "kandev_workflow" + LegacyExportVersion = 1 + ExportVersion = 2 + ExportType = "kandev_workflow" ) // WorkflowExport is the portable format for sharing workflows. @@ -49,22 +53,92 @@ type WorkflowPortable struct { // StepPortable is a workflow step without instance-specific fields. type StepPortable struct { - Name string `json:"name" yaml:"name"` - Position int `json:"position" yaml:"position"` - Color string `json:"color" yaml:"color"` - Prompt string `json:"prompt,omitempty" yaml:"prompt,omitempty"` - Events StepEvents `json:"events" yaml:"events"` - IsStartStep bool `json:"is_start_step" yaml:"is_start_step"` - ShowInCommandPanel bool `json:"show_in_command_panel" yaml:"show_in_command_panel"` - AllowManualMove bool `json:"allow_manual_move" yaml:"allow_manual_move"` - AutoArchiveAfterHours int `json:"auto_archive_after_hours,omitempty" yaml:"auto_archive_after_hours,omitempty"` - AgentProfile *AgentProfilePortable `json:"agent_profile,omitempty" yaml:"agent_profile,omitempty"` - ProfileSessionStartPolicy taskmodels.WorkflowProfileSessionStartPolicy `json:"profile_session_start_policy,omitempty" yaml:"profile_session_start_policy,omitempty"` - ProfileSessionEndPolicy taskmodels.WorkflowProfileSessionEndPolicy `json:"profile_session_end_policy,omitempty" yaml:"profile_session_end_policy,omitempty"` - AutoAdvanceRequiresSignal bool `json:"auto_advance_requires_signal" yaml:"auto_advance_requires_signal"` - CancelTriggersTurnComplete bool `json:"cancel_triggers_turn_complete" yaml:"cancel_triggers_turn_complete"` - WIPLimit int `json:"wip_limit,omitempty" yaml:"wip_limit,omitempty"` - PullFromStepPosition *int `json:"pull_from_step_position,omitempty" yaml:"pull_from_step_position,omitempty"` + Name string `json:"name" yaml:"name"` + Position int `json:"position" yaml:"position"` + Color string `json:"color" yaml:"color"` + Prompt string `json:"prompt,omitempty" yaml:"prompt,omitempty"` + Events StepEvents `json:"events" yaml:"events"` + IsStartStep bool `json:"is_start_step" yaml:"is_start_step"` + ShowInCommandPanel bool `json:"show_in_command_panel" yaml:"show_in_command_panel"` + AllowManualMove bool `json:"allow_manual_move" yaml:"allow_manual_move"` + AutoArchiveAfterHours int `json:"auto_archive_after_hours,omitempty" yaml:"auto_archive_after_hours,omitempty"` + AgentProfile *AgentProfilePortable `json:"agent_profile,omitempty" yaml:"agent_profile,omitempty"` + ProfileSessionStartPolicy taskmodels.WorkflowProfileSessionStartPolicy `json:"profile_session_start_policy,omitempty" yaml:"profile_session_start_policy,omitempty"` + ProfileSessionEndPolicy taskmodels.WorkflowProfileSessionEndPolicy `json:"profile_session_end_policy,omitempty" yaml:"profile_session_end_policy,omitempty"` + AutoAdvanceRequiresSignal bool `json:"auto_advance_requires_signal" yaml:"auto_advance_requires_signal"` + CancelTriggersTurnComplete bool `json:"cancel_triggers_turn_complete" yaml:"cancel_triggers_turn_complete"` + CompleteTaskOnEnter bool `json:"complete_task_on_enter" yaml:"complete_task_on_enter"` + WIPLimit int `json:"wip_limit,omitempty" yaml:"wip_limit,omitempty"` + PullFromStepPosition *int `json:"pull_from_step_position,omitempty" yaml:"pull_from_step_position,omitempty"` + completionTaskOnEnterDecoded bool `json:"-" yaml:"-"` + completionTaskOnEnterPresent bool `json:"-" yaml:"-"` +} + +// UnmarshalJSON records whether a decoded document explicitly supplied the +// version-2 completion field. The domain field remains a bool so callers that +// build exports in Go keep the same ergonomic API. +func (s *StepPortable) UnmarshalJSON(data []byte) error { + type plainStepPortable StepPortable + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return err + } + if raw, ok := fields["complete_task_on_enter"]; ok && bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return fmt.Errorf("complete_task_on_enter must be a boolean, not null") + } + var decoded plainStepPortable + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + *s = StepPortable(decoded) + _, s.completionTaskOnEnterPresent = fields["complete_task_on_enter"] + s.completionTaskOnEnterDecoded = true + return nil +} + +// UnmarshalYAML records whether a decoded document explicitly supplied the +// version-2 completion field. +func (s *StepPortable) UnmarshalYAML(node *yaml.Node) error { + type plainStepPortable StepPortable + if node.Kind == yaml.MappingNode { + for i := 0; i+1 < len(node.Content); i += 2 { + if node.Content[i].Value != "complete_task_on_enter" { + continue + } + valueNode := node.Content[i+1] + if valueNode.Tag == "!!null" || valueNode.ShortTag() == "!!null" { + return fmt.Errorf("complete_task_on_enter must be a boolean, not null") + } + } + } + var decoded plainStepPortable + if err := node.Decode(&decoded); err != nil { + return err + } + *s = StepPortable(decoded) + present := false + if node.Kind == yaml.MappingNode { + for i := 0; i+1 < len(node.Content); i += 2 { + if node.Content[i].Value == "complete_task_on_enter" { + present = true + break + } + } + } + s.completionTaskOnEnterPresent = present + s.completionTaskOnEnterDecoded = true + return nil +} + +func (s StepPortable) completionTaskOnEnterIsExplicit() bool { + if !s.completionTaskOnEnterDecoded { + return true + } + return s.completionTaskOnEnterPresent +} + +func (s *StepPortable) markCompletionTaskOnEnterExplicit() { + s.completionTaskOnEnterPresent = true } // BuildWorkflowExport builds a portable WorkflowExport from domain models. @@ -105,6 +179,7 @@ func buildWorkflowPortable(wf *taskmodels.Workflow, steps []*WorkflowStep, resol ProfileSessionEndPolicy: taskmodels.NormalizeWorkflowProfileSessionEndPolicy(string(s.ProfileSessionEndPolicy)), AutoAdvanceRequiresSignal: s.AutoAdvanceRequiresSignal, CancelTriggersTurnComplete: s.CancelTriggersTurnComplete, + CompleteTaskOnEnter: s.CompleteTaskOnEnter, WIPLimit: s.WIPLimit, } if pos, ok := idToPos[s.PullFromStepID]; ok { @@ -130,12 +205,15 @@ func buildWorkflowPortable(wf *taskmodels.Workflow, steps []*WorkflowStep, resol // Validate checks that the export data is well-formed. func (e *WorkflowExport) Validate() error { - if e.Version != ExportVersion { - return fmt.Errorf("unsupported export version: %d (expected %d)", e.Version, ExportVersion) + if e.Version != LegacyExportVersion && e.Version != ExportVersion { + return fmt.Errorf("unsupported export version: %d (expected %d or %d)", e.Version, LegacyExportVersion, ExportVersion) } if e.Type != ExportType { return fmt.Errorf("unsupported export type: %q (expected %q)", e.Type, ExportType) } + if err := e.NormalizeCompletionPolicy(); err != nil { + return err + } if len(e.Workflows) == 0 { return fmt.Errorf("export contains no workflows") } @@ -152,6 +230,9 @@ func (e *WorkflowExport) Validate() error { return fmt.Errorf("workflow %d: duplicate step position %d", i, step.Position) } positions[step.Position] = true + if e.Version == ExportVersion && !step.completionTaskOnEnterIsExplicit() { + return fmt.Errorf("workflow %d step %d: complete_task_on_enter must be explicitly set", i, j) + } if err := validateOnEnterActions(step); err != nil { return fmt.Errorf("workflow %d step %d: %w", i, j, err) } @@ -170,6 +251,37 @@ func (e *WorkflowExport) Validate() error { return nil } +// NormalizeCompletionPolicy converts version-1 exports at the import/sync +// boundary. Version 1 had no explicit completion field, so only its final +// step inherits the legacy terminal-name convention. Version-2 values are +// never inferred or changed. +func (e *WorkflowExport) NormalizeCompletionPolicy() error { + if e.Version != LegacyExportVersion { + return nil + } + for workflowIndex := range e.Workflows { + workflow := &e.Workflows[workflowIndex] + if len(workflow.Steps) == 0 { + continue + } + maxPosition := workflow.Steps[0].Position + for _, step := range workflow.Steps[1:] { + if step.Position > maxPosition { + maxPosition = step.Position + } + } + for stepIndex := range workflow.Steps { + step := &workflow.Steps[stepIndex] + if step.completionTaskOnEnterDecoded && step.completionTaskOnEnterPresent { + continue + } + step.CompleteTaskOnEnter = step.Position == maxPosition && IsTerminalStepName(step.Name) + step.markCompletionTaskOnEnterExplicit() + } + } + return nil +} + func validatePullSourceRefs(steps []StepPortable, validPositions map[int]bool) error { pullSources := make(map[int]int, len(steps)) for _, step := range steps { diff --git a/apps/backend/internal/workflow/models/export_test.go b/apps/backend/internal/workflow/models/export_test.go index 5937090151..27a888d68b 100644 --- a/apps/backend/internal/workflow/models/export_test.go +++ b/apps/backend/internal/workflow/models/export_test.go @@ -1,6 +1,7 @@ package models import ( + "encoding/json" "reflect" "strings" "testing" @@ -639,6 +640,76 @@ func TestCancelTriggersTurnCompleteYAMLExportIncludesFalse(t *testing.T) { } } +func TestCompletionPolicyPortableContract(t *testing.T) { + wf := &taskmodels.Workflow{ID: "wf-completion", Name: "Completion"} + export := BuildWorkflowExport([]*taskmodels.Workflow{wf}, map[string][]*WorkflowStep{"wf-completion": { + {ID: "work", Name: "Work", Position: 0}, + {ID: "done", Name: "Done", Position: 1, CompleteTaskOnEnter: true}, + }}, nil) + require.Equal(t, ExportVersion, export.Version) + payload, err := json.Marshal(export) + require.NoError(t, err) + var fields map[string]any + require.NoError(t, json.Unmarshal(payload, &fields)) + steps := fields["workflows"].([]any)[0].(map[string]any)["steps"].([]any) + for i, raw := range steps { + if _, ok := raw.(map[string]any)["complete_task_on_enter"]; !ok { + t.Fatalf("exported step %d omitted complete_task_on_enter", i) + } + } + + var decoded WorkflowExport + require.NoError(t, json.Unmarshal([]byte(`{ + "version": 1, + "type": "kandev_workflow", + "workflows": [{"name":"Legacy","steps":[ + {"name":"Work","position":0,"color":"blue"}, + {"name":" dOnE ","position":1,"color":"green"} + ]}] + }`), &decoded)) + require.NoError(t, decoded.Validate()) + assert.False(t, decoded.Workflows[0].Steps[0].CompleteTaskOnEnter) + assert.True(t, decoded.Workflows[0].Steps[1].CompleteTaskOnEnter) + + var missing WorkflowExport + require.NoError(t, json.Unmarshal([]byte(`{ + "version": 2, + "type": "kandev_workflow", + "workflows": [{"name":"Current","steps":[{"name":"Work","position":0,"color":"blue"}]}] + }`), &missing)) + assert.ErrorContains(t, missing.Validate(), "must be explicitly set") +} + +func TestCompletionPolicyPortableContractRejectsNull(t *testing.T) { + t.Run("json", func(t *testing.T) { + var decoded WorkflowExport + err := json.Unmarshal([]byte(`{ + "version": 2, + "type": "kandev_workflow", + "workflows": [{"name":"Current","steps":[ + {"name":"Work","position":0,"color":"blue","complete_task_on_enter":null} + ]}] + }`), &decoded) + assert.ErrorContains(t, err, "complete_task_on_enter") + }) + + t.Run("yaml", func(t *testing.T) { + var decoded WorkflowExport + err := yaml.Unmarshal([]byte(` +version: 2 +type: kandev_workflow +workflows: + - name: Current + steps: + - name: Work + position: 0 + color: blue + complete_task_on_enter: null +`), &decoded) + assert.ErrorContains(t, err, "complete_task_on_enter") + }) +} + func TestPullFromStepPositionToID(t *testing.T) { position := 0 step := StepPortable{ diff --git a/apps/backend/internal/workflow/models/models.go b/apps/backend/internal/workflow/models/models.go index 648f5da31e..b2ee553858 100644 --- a/apps/backend/internal/workflow/models/models.go +++ b/apps/backend/internal/workflow/models/models.go @@ -211,6 +211,8 @@ type StepDefinition struct { // CancelTriggersTurnComplete allows an explicit user cancellation to run // this step's ordinary on_turn_complete actions. CancelTriggersTurnComplete bool `json:"cancel_triggers_turn_complete,omitempty" yaml:"cancel_triggers_turn_complete,omitempty"` + // CompleteTaskOnEnter marks the final step as completing its task on entry. + CompleteTaskOnEnter bool `json:"complete_task_on_enter" yaml:"complete_task_on_enter"` } // WorkflowStep represents a step in a workflow @@ -244,9 +246,11 @@ type WorkflowStep struct { AutoAdvanceRequiresSignal bool `json:"auto_advance_requires_signal"` // CancelTriggersTurnComplete allows an explicit user cancellation to run // this step's ordinary on_turn_complete actions. - CancelTriggersTurnComplete bool `json:"cancel_triggers_turn_complete"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + CancelTriggersTurnComplete bool `json:"cancel_triggers_turn_complete"` + // CompleteTaskOnEnter marks the final step as completing its task on entry. + CompleteTaskOnEnter bool `json:"complete_task_on_enter"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // HasOnEnterAction checks if the step has a specific on_enter action type. diff --git a/apps/backend/internal/workflow/models/terminal.go b/apps/backend/internal/workflow/models/terminal.go index e03a1d58a5..caa87bc167 100644 --- a/apps/backend/internal/workflow/models/terminal.go +++ b/apps/backend/internal/workflow/models/terminal.go @@ -2,13 +2,13 @@ package models import "strings" -// IsTerminalStep reports whether a workflow step represents finished work by -// the existing board convention: a final column named Done/Complete/Approved. +// IsTerminalStep reports whether a workflow step is configured to complete its +// task and has no successor in the committed workflow order. func IsTerminalStep(step, nextStep *WorkflowStep) bool { if step == nil || nextStep != nil { return false } - return IsTerminalStepName(step.Name) + return step.CompleteTaskOnEnter } // IsTerminalStepName recognizes the existing built-in terminal column names. diff --git a/apps/backend/internal/workflow/models/terminal_test.go b/apps/backend/internal/workflow/models/terminal_test.go index 335301b2bd..a713c83972 100644 --- a/apps/backend/internal/workflow/models/terminal_test.go +++ b/apps/backend/internal/workflow/models/terminal_test.go @@ -42,25 +42,32 @@ func TestIsTerminalStep(t *testing.T) { } }) - t.Run("matching final step", func(t *testing.T) { - step := &WorkflowStep{Name: "Done"} + t.Run("checked final step", func(t *testing.T) { + step := &WorkflowStep{Name: "Release", CompleteTaskOnEnter: true} if !IsTerminalStep(step, nil) { - t.Fatalf("final Done step was not terminal") + t.Fatalf("checked final step was not terminal") } }) - t.Run("matching non-final step", func(t *testing.T) { + t.Run("unchecked final step", func(t *testing.T) { step := &WorkflowStep{Name: "Done"} + if IsTerminalStep(step, nil) { + t.Fatalf("unchecked final step reported terminal") + } + }) + + t.Run("checked non-final step", func(t *testing.T) { + step := &WorkflowStep{Name: "Release", CompleteTaskOnEnter: true} nextStep := &WorkflowStep{Name: "Archive"} if IsTerminalStep(step, nextStep) { - t.Fatalf("non-final Done step reported terminal") + t.Fatalf("checked non-final step reported terminal") } }) - t.Run("non-matching final step", func(t *testing.T) { - step := &WorkflowStep{Name: "Work"} - if IsTerminalStep(step, nil) { - t.Fatalf("final Work step reported terminal") + t.Run("checked arbitrary final step", func(t *testing.T) { + step := &WorkflowStep{Name: "Ready for launch", CompleteTaskOnEnter: true} + if !IsTerminalStep(step, nil) { + t.Fatalf("checked arbitrary final step reported non-terminal") } }) } diff --git a/apps/backend/internal/workflow/repository/completion_policy.go b/apps/backend/internal/workflow/repository/completion_policy.go new file mode 100644 index 0000000000..9d2255e2a7 --- /dev/null +++ b/apps/backend/internal/workflow/repository/completion_policy.go @@ -0,0 +1,203 @@ +package repository + +import ( + "database/sql" + "encoding/json" + "fmt" + + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/workflow/models" +) + +const completionPolicyBackfillMetaKey = "workflow_complete_task_on_enter_backfill_v1" + +// backfillCompletionPolicy converts the legacy name-based terminal convention +// once, before system templates and default workflow steps are seeded. The +// marker and all data changes are committed together so a failed startup can +// safely retry the migration. +func (r *Repository) backfillCompletionPolicy() error { + if _, err := r.db.Exec(` + CREATE TABLE IF NOT EXISTS kandev_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL DEFAULT '' + )`); err != nil { + return fmt.Errorf("create metadata table: %w", err) + } + + tx, err := r.db.Beginx() + if err != nil { + return fmt.Errorf("begin completion policy backfill: %w", err) + } + defer func() { _ = tx.Rollback() }() + + var marker string + err = tx.QueryRowx(tx.Rebind(` + SELECT value + FROM kandev_meta + WHERE key = ? + `), completionPolicyBackfillMetaKey).Scan(&marker) + if err == nil { + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit completed completion policy backfill: %w", err) + } + return nil + } + if err != sql.ErrNoRows { + return fmt.Errorf("read completion policy backfill marker: %w", err) + } + + if _, err := tx.Exec(tx.Rebind(` + UPDATE workflow_steps + SET complete_task_on_enter = 1 + WHERE complete_task_on_enter = 0 + AND LOWER(TRIM(workflow_steps.name)) IN ('done', 'complete', 'completed', 'approved') + AND NOT EXISTS ( + SELECT 1 + FROM workflow_steps AS successor + WHERE successor.workflow_id = workflow_steps.workflow_id + AND successor.position > workflow_steps.position + ) + `)); err != nil { + return fmt.Errorf("backfill workflow step completion policy: %w", err) + } + + if err := r.backfillTemplateCompletionPolicy(tx); err != nil { + return err + } + + if _, err := tx.Exec(tx.Rebind(` + INSERT INTO kandev_meta (key, value) + VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value + `), completionPolicyBackfillMetaKey, "completed"); err != nil { + return fmt.Errorf("write completion policy backfill marker: %w", err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit completion policy backfill: %w", err) + } + return nil +} + +func (r *Repository) backfillTemplateCompletionPolicy(tx *sqlx.Tx) error { + rows, err := tx.Queryx(tx.Rebind(` + SELECT id, steps + FROM workflow_templates + `)) + if err != nil { + return fmt.Errorf("read workflow template steps: %w", err) + } + defer func() { _ = rows.Close() }() + + type templateRow struct { + id string + stepsJSON string + } + templates := make([]templateRow, 0) + for rows.Next() { + var id, stepsJSON string + if err := rows.Scan(&id, &stepsJSON); err != nil { + return fmt.Errorf("scan workflow template %q: %w", id, err) + } + templates = append(templates, templateRow{id: id, stepsJSON: stepsJSON}) + } + if err := rows.Err(); err != nil { + return fmt.Errorf("read workflow template steps: %w", err) + } + if err := rows.Close(); err != nil { + return fmt.Errorf("close workflow template steps: %w", err) + } + + for _, template := range templates { + normalized, changed, err := normalizeTemplateCompletionPolicy([]byte(template.stepsJSON)) + if err != nil { + return fmt.Errorf("normalize workflow template %q: %w", template.id, err) + } + if !changed { + continue + } + if _, err := tx.Exec(tx.Rebind(` + UPDATE workflow_templates + SET steps = ? + WHERE id = ? + `), string(normalized), template.id); err != nil { + return fmt.Errorf("update workflow template %q: %w", template.id, err) + } + } + return nil +} + +func normalizeTemplateCompletionPolicy(data []byte) ([]byte, bool, error) { + var steps []map[string]json.RawMessage + if err := json.Unmarshal(data, &steps); err != nil { + return nil, false, err + } + if len(steps) == 0 { + return data, false, nil + } + + positions := make([]int, len(steps)) + maxPosition := 0 + for i, step := range steps { + position, err := templateStepPosition(step) + if err != nil { + return nil, false, err + } + positions[i] = position + if i == 0 || position > maxPosition { + maxPosition = position + } + } + + changed := false + for i, step := range steps { + if _, exists := step["complete_task_on_enter"]; exists { + continue + } + name, err := templateStepName(step) + if err != nil { + return nil, false, err + } + enabled := positions[i] == maxPosition && models.IsTerminalStepName(name) + encoded, err := json.Marshal(enabled) + if err != nil { + return nil, false, err + } + step["complete_task_on_enter"] = encoded + changed = true + } + if !changed { + return data, false, nil + } + + normalized, err := json.Marshal(steps) + if err != nil { + return nil, false, err + } + return normalized, true, nil +} + +func templateStepPosition(step map[string]json.RawMessage) (int, error) { + raw, ok := step["position"] + if !ok { + return 0, fmt.Errorf("step is missing position") + } + var position int + if err := json.Unmarshal(raw, &position); err != nil { + return 0, fmt.Errorf("decode step position: %w", err) + } + return position, nil +} + +func templateStepName(step map[string]json.RawMessage) (string, error) { + raw, ok := step["name"] + if !ok { + return "", fmt.Errorf("step is missing name") + } + var name string + if err := json.Unmarshal(raw, &name); err != nil { + return "", fmt.Errorf("decode step name: %w", err) + } + return name, nil +} diff --git a/apps/backend/internal/workflow/repository/completion_policy_test.go b/apps/backend/internal/workflow/repository/completion_policy_test.go new file mode 100644 index 0000000000..8e7e55bb99 --- /dev/null +++ b/apps/backend/internal/workflow/repository/completion_policy_test.go @@ -0,0 +1,301 @@ +package repository + +import ( + "context" + "database/sql" + "encoding/json" + "testing" + "time" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" + + "github.com/kandev/kandev/internal/workflow/models" +) + +func TestCompletionPolicyMigration(t *testing.T) { + db, err := sql.Open("sqlite3", ":memory:?_foreign_keys=on") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + db.SetMaxOpenConns(1) + database := sqlx.NewDb(db, "sqlite3") + t.Cleanup(func() { _ = database.Close() }) + + _, err = database.Exec(` + CREATE TABLE workflows ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL DEFAULT '', + workflow_template_id TEXT DEFAULT '', + name TEXT NOT NULL, + description TEXT DEFAULT '', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ); + CREATE TABLE task_sessions (id TEXT PRIMARY KEY); + CREATE TABLE workflow_templates ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + is_system INTEGER DEFAULT 0, + steps TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ); + INSERT INTO workflows (id, name, created_at, updated_at) + VALUES ('workflow-completion-migration', 'Legacy', datetime('now'), datetime('now')); + INSERT INTO workflow_templates (id, name, description, steps, created_at, updated_at) + VALUES ('legacy-template', 'Legacy template', '', '[ + {"id":"template-work","name":"Work","position":0,"custom":"preserve"}, + {"id":"template-done","name":" dOnE ","position":1} + ]', datetime('now'), datetime('now')); + CREATE TABLE workflow_steps ( + id TEXT PRIMARY KEY, + workflow_id TEXT NOT NULL, + name TEXT NOT NULL, + position INTEGER NOT NULL, + color TEXT, + prompt TEXT, + events TEXT, + allow_manual_move INTEGER DEFAULT 1, + is_start_step INTEGER DEFAULT 0, + show_in_command_panel INTEGER DEFAULT 1, + auto_archive_after_hours INTEGER DEFAULT 0, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + FOREIGN KEY (workflow_id) REFERENCES workflows(id) ON DELETE CASCADE + ); + INSERT INTO workflow_steps (id, workflow_id, name, position, created_at, updated_at) + VALUES + ('legacy-work', 'workflow-completion-migration', 'Work', 0, datetime('now'), datetime('now')), + ('legacy-done', 'workflow-completion-migration', ' dOnE ', 1, datetime('now'), datetime('now')); + `) + if err != nil { + t.Fatalf("seed legacy schema: %v", err) + } + + repo, err := NewWithDB(database, database, nil) + if err != nil { + t.Fatalf("initialize workflow repository: %v", err) + } + ctx := context.Background() + work, err := repo.GetStep(ctx, "legacy-work") + if err != nil { + t.Fatalf("get legacy work step: %v", err) + } + done, err := repo.GetStep(ctx, "legacy-done") + if err != nil { + t.Fatalf("get legacy done step: %v", err) + } + if work.CompleteTaskOnEnter { + t.Fatal("non-terminal legacy step was enabled") + } + if !done.CompleteTaskOnEnter { + t.Fatal("legacy final Done step was not enabled") + } + template, err := repo.GetTemplate(ctx, "legacy-template") + if err != nil { + t.Fatalf("get migrated template: %v", err) + } + if template.Steps[0].CompleteTaskOnEnter { + t.Fatal("non-final template step was enabled") + } + if !template.Steps[1].CompleteTaskOnEnter { + t.Fatal("legacy final template step was not enabled") + } + var stepsJSON string + if err := database.Get(&stepsJSON, `SELECT steps FROM workflow_templates WHERE id = 'legacy-template'`); err != nil { + t.Fatalf("read migrated template JSON: %v", err) + } + var rawSteps []map[string]json.RawMessage + if err := json.Unmarshal([]byte(stepsJSON), &rawSteps); err != nil { + t.Fatalf("decode migrated template JSON: %v", err) + } + var custom string + if err := json.Unmarshal(rawSteps[0]["custom"], &custom); err != nil { + t.Fatalf("decode preserved template field: %v", err) + } + if custom != "preserve" { + t.Fatalf("preserved template field = %q, want preserve", custom) + } + for i, rawStep := range rawSteps { + if _, ok := rawStep["complete_task_on_enter"]; !ok { + t.Fatalf("template step %d is missing completion policy", i) + } + } +} + +func TestCompletionPolicyReplayPreservesDisabled(t *testing.T) { + repo, database := setupTestRepoWithDB(t) + ctx := context.Background() + step := &models.WorkflowStep{ + WorkflowID: "wf-test", + Name: "Done", + Position: 0, + CompleteTaskOnEnter: true, + } + if err := repo.CreateStep(ctx, step); err != nil { + t.Fatalf("create completion step: %v", err) + } + step.CompleteTaskOnEnter = false + if err := repo.UpdateStep(ctx, step); err != nil { + t.Fatalf("disable completion step: %v", err) + } + if err := repo.backfillCompletionPolicy(); err != nil { + t.Fatalf("replay completion policy backfill: %v", err) + } + got, err := repo.GetStep(ctx, step.ID) + if err != nil { + t.Fatalf("reload disabled completion step: %v", err) + } + if got.CompleteTaskOnEnter { + t.Fatal("replayed backfill overwrote an explicit false") + } + var marker string + if err := database.Get(&marker, database.Rebind(`SELECT value FROM kandev_meta WHERE key = ?`), completionPolicyBackfillMetaKey); err != nil { + t.Fatalf("read completion policy marker: %v", err) + } + if marker == "" { + t.Fatal("completion policy marker is empty") + } +} + +func TestBackfillTemplateCompletionPolicyUpdatesMultipleTemplates(t *testing.T) { + repo, database := setupTestRepoWithDB(t) + ctx := context.Background() + now := time.Now().UTC() + for _, template := range []struct { + id string + steps string + }{ + {id: "template-one", steps: `[{"name":"Work","position":0},{"name":"Done","position":1}]`}, + {id: "template-two", steps: `[{"name":"Queue","position":0},{"name":"Approved","position":1}]`}, + } { + _, err := database.ExecContext(ctx, ` + INSERT INTO workflow_templates (id, name, description, is_system, steps, created_at, updated_at) + VALUES (?, ?, '', 0, ?, ?, ?) + `, template.id, template.id, template.steps, now, now) + if err != nil { + t.Fatalf("insert %s: %v", template.id, err) + } + } + + tx, err := database.Beginx() + if err != nil { + t.Fatalf("begin template backfill: %v", err) + } + if err := repo.backfillTemplateCompletionPolicy(tx); err != nil { + _ = tx.Rollback() + t.Fatalf("backfill template completion policy: %v", err) + } + if err := tx.Commit(); err != nil { + t.Fatalf("commit template backfill: %v", err) + } + + for _, templateID := range []string{"template-one", "template-two"} { + var stepsJSON string + if err := database.Get(&stepsJSON, database.Rebind(`SELECT steps FROM workflow_templates WHERE id = ?`), templateID); err != nil { + t.Fatalf("read %s: %v", templateID, err) + } + var steps []map[string]json.RawMessage + if err := json.Unmarshal([]byte(stepsJSON), &steps); err != nil { + t.Fatalf("decode %s: %v", templateID, err) + } + if len(steps) != 2 { + t.Fatalf("%s step count = %d, want 2", templateID, len(steps)) + } + var enabled bool + if err := json.Unmarshal(steps[1]["complete_task_on_enter"], &enabled); err != nil { + t.Fatalf("decode %s completion policy: %v", templateID, err) + } + if !enabled { + t.Fatalf("%s final step completion policy = false, want true", templateID) + } + } +} + +func TestCompletionPolicyMigrationRollsBackAndRetries(t *testing.T) { + db, err := sql.Open("sqlite3", ":memory:?_foreign_keys=on") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + db.SetMaxOpenConns(1) + database := sqlx.NewDb(db, "sqlite3") + t.Cleanup(func() { _ = database.Close() }) + + _, err = database.Exec(` + CREATE TABLE workflows ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL DEFAULT '', + workflow_template_id TEXT DEFAULT '', + name TEXT NOT NULL, + description TEXT DEFAULT '', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ); + CREATE TABLE task_sessions (id TEXT PRIMARY KEY); + CREATE TABLE kandev_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT ''); + INSERT INTO workflows (id, name, created_at, updated_at) + VALUES ('workflow-completion-retry', 'Legacy', datetime('now'), datetime('now')); + CREATE TABLE workflow_steps ( + id TEXT PRIMARY KEY, + workflow_id TEXT NOT NULL, + name TEXT NOT NULL, + position INTEGER NOT NULL, + color TEXT, + prompt TEXT, + events TEXT, + allow_manual_move INTEGER DEFAULT 1, + is_start_step INTEGER DEFAULT 0, + show_in_command_panel INTEGER DEFAULT 1, + auto_archive_after_hours INTEGER DEFAULT 0, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + FOREIGN KEY (workflow_id) REFERENCES workflows(id) ON DELETE CASCADE + ); + INSERT INTO workflow_steps (id, workflow_id, name, position, created_at, updated_at) + VALUES ('legacy-retry-done', 'workflow-completion-retry', 'Done', 0, datetime('now'), datetime('now')); + CREATE TRIGGER fail_completion_policy_marker + BEFORE INSERT ON kandev_meta + WHEN NEW.key = 'workflow_complete_task_on_enter_backfill_v1' + BEGIN + SELECT RAISE(ABORT, 'injected completion policy failure'); + END; + `) + if err != nil { + t.Fatalf("seed retry schema: %v", err) + } + + if _, err := NewWithDB(database, database, nil); err == nil { + t.Fatal("completion policy migration succeeded despite injected failure") + } + var enabled int + if err := database.Get(&enabled, `SELECT complete_task_on_enter FROM workflow_steps WHERE id = 'legacy-retry-done'`); err != nil { + t.Fatalf("read rolled-back completion policy: %v", err) + } + if enabled != 0 { + t.Fatalf("rolled-back completion policy = %d, want 0", enabled) + } + var marker string + if err := database.Get(&marker, database.Rebind(`SELECT value FROM kandev_meta WHERE key = ?`), completionPolicyBackfillMetaKey); err != sql.ErrNoRows { + t.Fatalf("rolled-back marker error = %v, want sql.ErrNoRows", err) + } + + if _, err := database.Exec(`DROP TRIGGER fail_completion_policy_marker`); err != nil { + t.Fatalf("remove injected failure: %v", err) + } + repo, err := NewWithDB(database, database, nil) + if err != nil { + t.Fatalf("retry completion policy migration: %v", err) + } + step, err := repo.GetStep(context.Background(), "legacy-retry-done") + if err != nil { + t.Fatalf("read retried completion policy: %v", err) + } + if !step.CompleteTaskOnEnter { + t.Fatal("retried completion policy was not enabled") + } + if err := database.Get(&marker, database.Rebind(`SELECT value FROM kandev_meta WHERE key = ?`), completionPolicyBackfillMetaKey); err != nil { + t.Fatalf("read retried marker: %v", err) + } +} diff --git a/apps/backend/internal/workflow/repository/sqlite.go b/apps/backend/internal/workflow/repository/sqlite.go index 09604cb4e3..b593987d6f 100644 --- a/apps/backend/internal/workflow/repository/sqlite.go +++ b/apps/backend/internal/workflow/repository/sqlite.go @@ -78,6 +78,7 @@ func (r *Repository) initSchema() error { pull_from_step_id TEXT NOT NULL DEFAULT '', auto_advance_requires_signal INTEGER NOT NULL DEFAULT 0, cancel_triggers_turn_complete INTEGER NOT NULL DEFAULT 0, + complete_task_on_enter INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, FOREIGN KEY (workflow_id) REFERENCES workflows(id) ON DELETE CASCADE @@ -129,6 +130,7 @@ func (r *Repository) initSchema() error { // template supplies its own true values; this migration must never backfill // existing workflows. r.migrate.Apply("workflow_steps.cancel_triggers_turn_complete", `ALTER TABLE workflow_steps ADD COLUMN cancel_triggers_turn_complete INTEGER NOT NULL DEFAULT 0`) + _ = r.migrate.Apply("workflow_steps.complete_task_on_enter", `ALTER TABLE workflow_steps ADD COLUMN complete_task_on_enter INTEGER NOT NULL DEFAULT 0`) r.migrate.Apply("workflow_steps.wip_limit", `ALTER TABLE workflow_steps ADD COLUMN wip_limit INTEGER NOT NULL DEFAULT 0`) r.migrate.Apply("workflow_steps.pull_from_step_id", `ALTER TABLE workflow_steps ADD COLUMN pull_from_step_id TEXT NOT NULL DEFAULT ''`) r.migrate.Apply("idx_workflow_steps_pull_from", ` @@ -142,6 +144,9 @@ func (r *Repository) initSchema() error { if err := r.initPhase2Schema(); err != nil { return err } + if err := r.backfillCompletionPolicy(); err != nil { + return fmt.Errorf("backfill task completion policy: %w", err) + } // Seed system templates if err := r.seedSystemTemplates(); err != nil { @@ -245,15 +250,18 @@ func (r *Repository) seedDefaultWorkflowSteps() error { if _, err := r.db.Exec(r.db.Rebind(` INSERT INTO workflow_steps ( id, workflow_id, name, position, color, - prompt, events, allow_manual_move, is_start_step, show_in_command_panel, agent_profile_id, profile_session_start_policy, profile_session_end_policy, wip_limit, pull_from_step_id, auto_advance_requires_signal, cancel_triggers_turn_complete, created_at, updated_at + prompt, events, allow_manual_move, is_start_step, show_in_command_panel, + agent_profile_id, profile_session_start_policy, profile_session_end_policy, + wip_limit, pull_from_step_id, auto_advance_requires_signal, + cancel_triggers_turn_complete, complete_task_on_enter, created_at, updated_at ) VALUES ( - ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) `), idMap[stepDef.ID], workflowID, stepDef.Name, stepDef.Position, stepDef.Color, stepDef.Prompt, string(eventsJSON), dialect.BoolToInt(stepDef.AllowManualMove), - dialect.BoolToInt(stepDef.IsStartStep), dialect.BoolToInt(stepDef.ShowInCommandPanel), stepDef.AgentProfileID, taskmodels.NormalizeWorkflowProfileSessionStartPolicy(string(stepDef.ProfileSessionStartPolicy)), taskmodels.NormalizeWorkflowProfileSessionEndPolicy(string(stepDef.ProfileSessionEndPolicy)), stepDef.WIPLimit, models.RemapStepID(stepDef.PullFromStepID, idMap), dialect.BoolToInt(stepDef.AutoAdvanceRequiresSignal), dialect.BoolToInt(stepDef.CancelTriggersTurnComplete), now, now, + dialect.BoolToInt(stepDef.IsStartStep), dialect.BoolToInt(stepDef.ShowInCommandPanel), stepDef.AgentProfileID, taskmodels.NormalizeWorkflowProfileSessionStartPolicy(string(stepDef.ProfileSessionStartPolicy)), taskmodels.NormalizeWorkflowProfileSessionEndPolicy(string(stepDef.ProfileSessionEndPolicy)), stepDef.WIPLimit, models.RemapStepID(stepDef.PullFromStepID, idMap), dialect.BoolToInt(stepDef.AutoAdvanceRequiresSignal), dialect.BoolToInt(stepDef.CancelTriggersTurnComplete), dialect.BoolToInt(stepDef.CompleteTaskOnEnter), now, now, ); err != nil { return err } @@ -638,13 +646,13 @@ func (r *Repository) CreateStepWithDemotedStartSteps(ctx context.Context, step * } _, err = tx.ExecContext(ctx, tx.Rebind(` - INSERT INTO workflow_steps ( - id, workflow_id, name, position, color, - prompt, events, allow_manual_move, is_start_step, show_in_command_panel, auto_archive_after_hours, agent_profile_id, profile_session_start_policy, profile_session_end_policy, stage_type, auto_advance_requires_signal, cancel_triggers_turn_complete, wip_limit, pull_from_step_id, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO workflow_steps ( + id, workflow_id, name, position, color, + prompt, events, allow_manual_move, is_start_step, show_in_command_panel, auto_archive_after_hours, agent_profile_id, profile_session_start_policy, profile_session_end_policy, stage_type, auto_advance_requires_signal, cancel_triggers_turn_complete, complete_task_on_enter, wip_limit, pull_from_step_id, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), step.ID, step.WorkflowID, step.Name, step.Position, step.Color, step.Prompt, string(eventsJSON), dialect.BoolToInt(step.AllowManualMove), - dialect.BoolToInt(step.IsStartStep), dialect.BoolToInt(step.ShowInCommandPanel), step.AutoArchiveAfterHours, step.AgentProfileID, taskmodels.NormalizeWorkflowProfileSessionStartPolicy(string(step.ProfileSessionStartPolicy)), taskmodels.NormalizeWorkflowProfileSessionEndPolicy(string(step.ProfileSessionEndPolicy)), normalizeStageType(step.StageType), dialect.BoolToInt(step.AutoAdvanceRequiresSignal), dialect.BoolToInt(step.CancelTriggersTurnComplete), step.WIPLimit, step.PullFromStepID, step.CreatedAt, step.UpdatedAt) + dialect.BoolToInt(step.IsStartStep), dialect.BoolToInt(step.ShowInCommandPanel), step.AutoArchiveAfterHours, step.AgentProfileID, taskmodels.NormalizeWorkflowProfileSessionStartPolicy(string(step.ProfileSessionStartPolicy)), taskmodels.NormalizeWorkflowProfileSessionEndPolicy(string(step.ProfileSessionEndPolicy)), normalizeStageType(step.StageType), dialect.BoolToInt(step.AutoAdvanceRequiresSignal), dialect.BoolToInt(step.CancelTriggersTurnComplete), dialect.BoolToInt(step.CompleteTaskOnEnter), step.WIPLimit, step.PullFromStepID, step.CreatedAt, step.UpdatedAt) if err != nil { return nil, err } @@ -671,12 +679,12 @@ func (r *Repository) scanStep(row interface { Scan(dest ...interface{}) error }) (*models.WorkflowStep, error) { step := &models.WorkflowStep{} - var allowManualMove, isStartStep, showInCommandPanel, autoAdvanceRequiresSignal, cancelTriggersTurnComplete int + var allowManualMove, isStartStep, showInCommandPanel, autoAdvanceRequiresSignal, cancelTriggersTurnComplete, completeTaskOnEnter int var autoArchiveAfterHours sql.NullInt64 var color, prompt, eventsJSON, agentProfileID, profileSessionStartPolicy, profileSessionEndPolicy, stageType, pullFromStepID sql.NullString err := row.Scan(&step.ID, &step.WorkflowID, &step.Name, &step.Position, &color, - &prompt, &eventsJSON, &allowManualMove, &isStartStep, &showInCommandPanel, &autoArchiveAfterHours, &agentProfileID, &profileSessionStartPolicy, &profileSessionEndPolicy, &stageType, &autoAdvanceRequiresSignal, &cancelTriggersTurnComplete, &step.WIPLimit, &pullFromStepID, &step.CreatedAt, &step.UpdatedAt) + &prompt, &eventsJSON, &allowManualMove, &isStartStep, &showInCommandPanel, &autoArchiveAfterHours, &agentProfileID, &profileSessionStartPolicy, &profileSessionEndPolicy, &stageType, &autoAdvanceRequiresSignal, &cancelTriggersTurnComplete, &completeTaskOnEnter, &step.WIPLimit, &pullFromStepID, &step.CreatedAt, &step.UpdatedAt) if err != nil { return nil, err @@ -687,6 +695,7 @@ func (r *Repository) scanStep(row interface { step.ShowInCommandPanel = showInCommandPanel == 1 step.AutoAdvanceRequiresSignal = autoAdvanceRequiresSignal == 1 step.CancelTriggersTurnComplete = cancelTriggersTurnComplete == 1 + step.CompleteTaskOnEnter = completeTaskOnEnter == 1 if autoArchiveAfterHours.Valid { step.AutoArchiveAfterHours = int(autoArchiveAfterHours.Int64) } @@ -718,7 +727,7 @@ func (r *Repository) scanStep(row interface { return step, nil } -const stepSelectColumns = `id, workflow_id, name, position, color, prompt, events, allow_manual_move, is_start_step, show_in_command_panel, auto_archive_after_hours, agent_profile_id, profile_session_start_policy, profile_session_end_policy, stage_type, auto_advance_requires_signal, cancel_triggers_turn_complete, wip_limit, pull_from_step_id, created_at, updated_at` +const stepSelectColumns = `id, workflow_id, name, position, color, prompt, events, allow_manual_move, is_start_step, show_in_command_panel, auto_archive_after_hours, agent_profile_id, profile_session_start_policy, profile_session_end_policy, stage_type, auto_advance_requires_signal, cancel_triggers_turn_complete, complete_task_on_enter, wip_limit, pull_from_step_id, created_at, updated_at` // GetStep retrieves a workflow step by ID. func (r *Repository) GetStep(ctx context.Context, id string) (*models.WorkflowStep, error) { @@ -773,11 +782,11 @@ func (r *Repository) UpdateStepWithDemotedStartSteps(ctx context.Context, step * UPDATE workflow_steps SET name = ?, position = ?, color = ?, prompt = ?, events = ?, - allow_manual_move = ?, is_start_step = ?, show_in_command_panel = ?, auto_archive_after_hours = ?, agent_profile_id = ?, profile_session_start_policy = ?, profile_session_end_policy = ?, stage_type = ?, auto_advance_requires_signal = ?, cancel_triggers_turn_complete = ?, wip_limit = ?, pull_from_step_id = ?, updated_at = ? + allow_manual_move = ?, is_start_step = ?, show_in_command_panel = ?, auto_archive_after_hours = ?, agent_profile_id = ?, profile_session_start_policy = ?, profile_session_end_policy = ?, stage_type = ?, auto_advance_requires_signal = ?, cancel_triggers_turn_complete = ?, complete_task_on_enter = ?, wip_limit = ?, pull_from_step_id = ?, updated_at = ? WHERE id = ? `), step.Name, step.Position, step.Color, step.Prompt, string(eventsJSON), - dialect.BoolToInt(step.AllowManualMove), dialect.BoolToInt(step.IsStartStep), dialect.BoolToInt(step.ShowInCommandPanel), step.AutoArchiveAfterHours, step.AgentProfileID, taskmodels.NormalizeWorkflowProfileSessionStartPolicy(string(step.ProfileSessionStartPolicy)), taskmodels.NormalizeWorkflowProfileSessionEndPolicy(string(step.ProfileSessionEndPolicy)), normalizeStageType(step.StageType), dialect.BoolToInt(step.AutoAdvanceRequiresSignal), dialect.BoolToInt(step.CancelTriggersTurnComplete), step.WIPLimit, step.PullFromStepID, step.UpdatedAt, step.ID) + dialect.BoolToInt(step.AllowManualMove), dialect.BoolToInt(step.IsStartStep), dialect.BoolToInt(step.ShowInCommandPanel), step.AutoArchiveAfterHours, step.AgentProfileID, taskmodels.NormalizeWorkflowProfileSessionStartPolicy(string(step.ProfileSessionStartPolicy)), taskmodels.NormalizeWorkflowProfileSessionEndPolicy(string(step.ProfileSessionEndPolicy)), normalizeStageType(step.StageType), dialect.BoolToInt(step.AutoAdvanceRequiresSignal), dialect.BoolToInt(step.CancelTriggersTurnComplete), dialect.BoolToInt(step.CompleteTaskOnEnter), step.WIPLimit, step.PullFromStepID, step.UpdatedAt, step.ID) if err != nil { return nil, err } @@ -987,7 +996,7 @@ func (r *Repository) ListStepsByWorkflow(ctx context.Context, workflowID string) func (r *Repository) ListStepsByWorkspaceID(ctx context.Context, workspaceID string) ([]*models.WorkflowStep, error) { rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` SELECT ws.id, ws.workflow_id, ws.name, ws.position, ws.color, ws.prompt, ws.events, - ws.allow_manual_move, ws.is_start_step, ws.show_in_command_panel, ws.auto_archive_after_hours, ws.agent_profile_id, ws.profile_session_start_policy, ws.profile_session_end_policy, ws.stage_type, ws.auto_advance_requires_signal, ws.cancel_triggers_turn_complete, ws.wip_limit, ws.pull_from_step_id, ws.created_at, ws.updated_at + ws.allow_manual_move, ws.is_start_step, ws.show_in_command_panel, ws.auto_archive_after_hours, ws.agent_profile_id, ws.profile_session_start_policy, ws.profile_session_end_policy, ws.stage_type, ws.auto_advance_requires_signal, ws.cancel_triggers_turn_complete, ws.complete_task_on_enter, ws.wip_limit, ws.pull_from_step_id, ws.created_at, ws.updated_at FROM workflow_steps ws JOIN workflows w ON ws.workflow_id = w.id WHERE w.workspace_id = ? diff --git a/apps/backend/internal/workflow/service/service.go b/apps/backend/internal/workflow/service/service.go index 7ca3826a2a..36cec5a355 100644 --- a/apps/backend/internal/workflow/service/service.go +++ b/apps/backend/internal/workflow/service/service.go @@ -392,6 +392,7 @@ func (s *Service) CreateStepsFromTemplate(ctx context.Context, workflowID, templ ProfileSessionEndPolicy: taskmodels.NormalizeWorkflowProfileSessionEndPolicy(string(stepDef.ProfileSessionEndPolicy)), AutoAdvanceRequiresSignal: stepDef.AutoAdvanceRequiresSignal, CancelTriggersTurnComplete: stepDef.CancelTriggersTurnComplete, + CompleteTaskOnEnter: stepDef.CompleteTaskOnEnter, WIPLimit: stepDef.WIPLimit, PullFromStepID: models.RemapStepID(stepDef.PullFromStepID, idMap), StageType: stepDef.StageType, @@ -734,6 +735,9 @@ func (s *Service) ImportWorkflows(ctx context.Context, workspaceID string, expor if err := s.AuthorizeWorkspace(ctx, workspaceID); err != nil { return nil, err } + if err := export.NormalizeCompletionPolicy(); err != nil { + return nil, fmt.Errorf("invalid export data: %w", err) + } if err := export.Validate(); err != nil { return nil, fmt.Errorf("invalid export data: %w", err) } @@ -879,6 +883,7 @@ func (s *Service) stepFromPortableWithMatcher(workflowID string, sp models.StepP ProfileSessionEndPolicy: taskmodels.NormalizeWorkflowProfileSessionEndPolicy(string(sp.ProfileSessionEndPolicy)), AutoAdvanceRequiresSignal: sp.AutoAdvanceRequiresSignal, CancelTriggersTurnComplete: sp.CancelTriggersTurnComplete, + CompleteTaskOnEnter: sp.CompleteTaskOnEnter, WIPLimit: sp.WIPLimit, PullFromStepID: sp.PullFromStepID(posToID), } diff --git a/apps/backend/internal/workflow/service/sync_apply.go b/apps/backend/internal/workflow/service/sync_apply.go index a0c75d1432..3f2a4fce4d 100644 --- a/apps/backend/internal/workflow/service/sync_apply.go +++ b/apps/backend/internal/workflow/service/sync_apply.go @@ -367,6 +367,7 @@ func stepMatchesDefinition(existing, desired *models.WorkflowStep) bool { existing.PullFromStepID == desired.PullFromStepID && existing.AutoAdvanceRequiresSignal == desired.AutoAdvanceRequiresSignal && existing.CancelTriggersTurnComplete == desired.CancelTriggersTurnComplete && + existing.CompleteTaskOnEnter == desired.CompleteTaskOnEnter && eventsEqual(existing.Events, desired.Events) } diff --git a/apps/backend/internal/workflow/service/sync_apply_test.go b/apps/backend/internal/workflow/service/sync_apply_test.go index 264e4393e4..3014d2dba1 100644 --- a/apps/backend/internal/workflow/service/sync_apply_test.go +++ b/apps/backend/internal/workflow/service/sync_apply_test.go @@ -382,6 +382,37 @@ func TestApplySyncedWorkflows_PreservesCancelTriggersTurnComplete(t *testing.T) assert.False(t, steps[0].CancelTriggersTurnComplete) } +func TestApplySyncedWorkflows_PreservesCompleteTaskOnEnterFalse(t *testing.T) { + svc, _, _ := setupSyncService(t) + ctx := context.Background() + first := models.WorkflowPortable{ + Name: "Completion Flow", + Steps: []models.StepPortable{ + {Name: "Work", Position: 0, IsStartStep: true, CompleteTaskOnEnter: false}, + {Name: "Done", Position: 1, CompleteTaskOnEnter: true}, + }, + } + result, err := svc.ApplySyncedWorkflows(ctx, "ws-1", []SyncFileExport{{Path: "flows/completion.yml", Export: exportOf(first)}}) + require.NoError(t, err) + require.Equal(t, []string{"Completion Flow"}, result.Created) + + steps, err := svc.ListStepsByWorkflow(ctx, "imported-Completion Flow") + require.NoError(t, err) + require.Len(t, steps, 2) + assert.False(t, steps[0].CompleteTaskOnEnter) + assert.True(t, steps[1].CompleteTaskOnEnter) + + updated := first + updated.Steps = append([]models.StepPortable(nil), first.Steps...) + updated.Steps[1].CompleteTaskOnEnter = false + result, err = svc.ApplySyncedWorkflows(ctx, "ws-1", []SyncFileExport{{Path: "flows/completion.yml", Export: exportOf(updated)}}) + require.NoError(t, err) + require.Equal(t, []string{"Completion Flow"}, result.Updated) + steps, err = svc.ListStepsByWorkflow(ctx, "imported-Completion Flow") + require.NoError(t, err) + assert.False(t, steps[1].CompleteTaskOnEnter) +} + func TestApplySyncedWorkflows_SecondApplyIsNoOp(t *testing.T) { svc, _, _ := setupSyncService(t) ctx := context.Background() diff --git a/apps/backend/internal/workflow/stepevents/stepevents.go b/apps/backend/internal/workflow/stepevents/stepevents.go index ebd4d87a89..8be04d49be 100644 --- a/apps/backend/internal/workflow/stepevents/stepevents.go +++ b/apps/backend/internal/workflow/stepevents/stepevents.go @@ -84,6 +84,7 @@ func payload(step *models.WorkflowStep) map[string]interface{} { "stage_type": string(step.StageType), "auto_advance_requires_signal": step.AutoAdvanceRequiresSignal, "cancel_triggers_turn_complete": step.CancelTriggersTurnComplete, + "complete_task_on_enter": step.CompleteTaskOnEnter, "created_at": step.CreatedAt, "updated_at": step.UpdatedAt, } diff --git a/apps/backend/internal/workflow/stepevents/stepevents_test.go b/apps/backend/internal/workflow/stepevents/stepevents_test.go index f5f320b08b..51698625a6 100644 --- a/apps/backend/internal/workflow/stepevents/stepevents_test.go +++ b/apps/backend/internal/workflow/stepevents/stepevents_test.go @@ -57,6 +57,7 @@ func fullStep() *models.WorkflowStep { AutoAdvanceRequiresSignal: true, CancelTriggersTurnComplete: true, + CompleteTaskOnEnter: true, CreatedAt: created, UpdatedAt: updated, } @@ -104,6 +105,7 @@ func TestPublishCarriesEveryStepField(t *testing.T) { "stage_type": string(step.StageType), "auto_advance_requires_signal": step.AutoAdvanceRequiresSignal, "cancel_triggers_turn_complete": step.CancelTriggersTurnComplete, + "complete_task_on_enter": step.CompleteTaskOnEnter, "created_at": step.CreatedAt, "updated_at": step.UpdatedAt, } diff --git a/apps/web/app/actions/workspaces.test.ts b/apps/web/app/actions/workspaces.test.ts index b9de71c5e3..ef03b7cda5 100644 --- a/apps/web/app/actions/workspaces.test.ts +++ b/apps/web/app/actions/workspaces.test.ts @@ -266,6 +266,76 @@ describe("workflow step cancellation fields", () => { }); }); +describe("workflow step completion fields", () => { + beforeEach(() => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json({ + id: "step-1", + workflow_id: "wf-1", + name: REVIEW_STEP_NAME, + position: 1, + color: STEP_COLOR, + complete_task_on_enter: true, + created_at: "", + updated_at: "", + }), + ), + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("preserves the completion policy returned from the step API", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json({ + steps: [ + { + id: "step-1", + workflow_id: "wf-1", + name: REVIEW_STEP_NAME, + position: 1, + color: STEP_COLOR, + complete_task_on_enter: true, + created_at: "", + updated_at: "", + }, + ], + }), + ), + ); + + const result = await listWorkflowStepsAction("wf-1"); + + expect(result.steps[0].complete_task_on_enter).toBe(true); + }); + + it("sends explicit false when creating and updating completion policy", async () => { + await createWorkflowStepAction({ + workflow_id: "wf-1", + name: REVIEW_STEP_NAME, + position: 1, + color: STEP_COLOR, + complete_task_on_enter: false, + }); + await updateWorkflowStepAction("step-1", { complete_task_on_enter: false }); + + const fetchMock = fetch as unknown as ReturnType; + expect(JSON.parse(fetchMock.mock.calls[0][1].body as string)).toMatchObject({ + complete_task_on_enter: false, + }); + expect(JSON.parse(fetchMock.mock.calls[1][1].body as string)).toMatchObject({ + complete_task_on_enter: false, + }); + }); +}); + describe("workflow step stage types", () => { beforeEach(() => { vi.stubGlobal( diff --git a/apps/web/app/actions/workspaces.ts b/apps/web/app/actions/workspaces.ts index 25467c76c6..5383d87ad2 100644 --- a/apps/web/app/actions/workspaces.ts +++ b/apps/web/app/actions/workspaces.ts @@ -361,6 +361,7 @@ type BackendTemplateStep = { agent_profile_id?: StepDefinition["agent_profile_id"]; profile_session_start_policy?: WorkflowStep["profile_session_start_policy"]; profile_session_end_policy?: WorkflowStep["profile_session_end_policy"]; + complete_task_on_enter?: boolean; auto_advance_requires_signal?: boolean; cancel_triggers_turn_complete?: boolean; wip_limit?: number; @@ -418,6 +419,7 @@ type BackendWorkflowStep = { agent_profile_id?: string; profile_session_start_policy?: WorkflowStep["profile_session_start_policy"]; profile_session_end_policy?: WorkflowStep["profile_session_end_policy"]; + complete_task_on_enter?: boolean; auto_advance_requires_signal?: boolean; cancel_triggers_turn_complete?: boolean; wip_limit?: number; @@ -446,6 +448,7 @@ const transformWorkflowStep = (step: BackendWorkflowStep): WorkflowStep => ({ profile_session_end_policy: normalizeWorkflowProfileSessionEndPolicy( step.profile_session_end_policy, ), + complete_task_on_enter: step.complete_task_on_enter, auto_advance_requires_signal: step.auto_advance_requires_signal, cancel_triggers_turn_complete: step.cancel_triggers_turn_complete, wip_limit: step.wip_limit ?? 0, @@ -488,6 +491,7 @@ export async function createWorkflowStepAction(payload: { color: string; prompt?: string; events?: StepEvents; + complete_task_on_enter?: boolean; is_start_step?: boolean; show_in_command_panel?: boolean; agent_profile_id?: string; @@ -514,6 +518,7 @@ export async function createWorkflowStepAction(payload: { wip_limit: payload.wip_limit ?? 0, pull_from_step_id: payload.pull_from_step_id ?? "", stage_type: payload.stage_type, + complete_task_on_enter: payload.complete_task_on_enter ?? false, cancel_triggers_turn_complete: payload.cancel_triggers_turn_complete ?? false, profile_session_start_policy: payload.profile_session_start_policy, profile_session_end_policy: payload.profile_session_end_policy, @@ -548,6 +553,7 @@ export async function updateWorkflowStepAction( | "stage_type" | "profile_session_start_policy" | "profile_session_end_policy" + | "complete_task_on_enter" > >, ): Promise { diff --git a/apps/web/app/settings/workspace/use-workflow-creation.test.ts b/apps/web/app/settings/workspace/use-workflow-creation.test.ts index fbedc302fa..85f5d99e61 100644 --- a/apps/web/app/settings/workspace/use-workflow-creation.test.ts +++ b/apps/web/app/settings/workspace/use-workflow-creation.test.ts @@ -13,6 +13,7 @@ const template = { name: "Template Step", position: 0, color: "bg-blue-500", + complete_task_on_enter: true, cancel_triggers_turn_complete: true, }, ], @@ -117,6 +118,7 @@ describe("useWorkflowCreation", () => { expect(result.current.initialStepsByWorkflowId.get(workflow.id)?.[0]).toMatchObject({ name: "Template Step", color: "bg-blue-500", + complete_task_on_enter: true, cancel_triggers_turn_complete: true, }); }); diff --git a/apps/web/app/settings/workspace/use-workflow-creation.ts b/apps/web/app/settings/workspace/use-workflow-creation.ts index 01e7b289f3..3864c70062 100644 --- a/apps/web/app/settings/workspace/use-workflow-creation.ts +++ b/apps/web/app/settings/workspace/use-workflow-creation.ts @@ -76,6 +76,7 @@ function toDraftStep( profile_session_end_policy: normalizeWorkflowProfileSessionEndPolicy( definition.profile_session_end_policy, ), + complete_task_on_enter: definition.complete_task_on_enter ?? false, auto_advance_requires_signal: definition.auto_advance_requires_signal, cancel_triggers_turn_complete: definition.cancel_triggers_turn_complete, wip_limit: definition.wip_limit, diff --git a/apps/web/app/settings/workspace/workflow-duplication.test.ts b/apps/web/app/settings/workspace/workflow-duplication.test.ts index e7125b303e..774aa7106b 100644 --- a/apps/web/app/settings/workspace/workflow-duplication.test.ts +++ b/apps/web/app/settings/workspace/workflow-duplication.test.ts @@ -49,6 +49,7 @@ function sourceSteps(): WorkflowStep[] { agent_profile_id: "step-agent", profile_session_start_policy: "reuse", profile_session_end_policy: "complete", + complete_task_on_enter: false, auto_advance_requires_signal: true, cancel_triggers_turn_complete: true, wip_limit: 2, @@ -70,6 +71,7 @@ function sourceSteps(): WorkflowStep[] { show_in_command_panel: false, auto_archive_after_hours: 0, agent_profile_id: "", + complete_task_on_enter: true, auto_advance_requires_signal: false, cancel_triggers_turn_complete: false, wip_limit: 0, @@ -157,6 +159,7 @@ describe("createWorkflowDuplication", () => { agent_profile_id: "step-agent", profile_session_start_policy: "reuse", profile_session_end_policy: "complete", + complete_task_on_enter: false, auto_advance_requires_signal: true, cancel_triggers_turn_complete: true, wip_limit: 2, @@ -165,6 +168,7 @@ describe("createWorkflowDuplication", () => { updated_at: "", workflow_id: result.workflow.id, }); + expect(copiedDone.complete_task_on_enter).toBe(true); expect(copiedDone.pull_from_step_id).toBe(copiedReview.id); expect(copiedReview.events?.on_turn_complete).toEqual([ { diff --git a/apps/web/app/settings/workspace/workflow-duplication.ts b/apps/web/app/settings/workspace/workflow-duplication.ts index fa2629ed92..b0f6b12a18 100644 --- a/apps/web/app/settings/workspace/workflow-duplication.ts +++ b/apps/web/app/settings/workspace/workflow-duplication.ts @@ -85,6 +85,7 @@ function cloneWorkflowStep( profile_session_end_policy: normalizeWorkflowProfileSessionEndPolicy( source.profile_session_end_policy, ), + complete_task_on_enter: source.complete_task_on_enter, auto_advance_requires_signal: source.auto_advance_requires_signal, cancel_triggers_turn_complete: source.cancel_triggers_turn_complete, wip_limit: source.wip_limit, diff --git a/apps/web/app/settings/workspace/workspace-workflows-dialogs.tsx b/apps/web/app/settings/workspace/workspace-workflows-dialogs.tsx index 5228fa36fe..808a3375b8 100644 --- a/apps/web/app/settings/workspace/workspace-workflows-dialogs.tsx +++ b/apps/web/app/settings/workspace/workspace-workflows-dialogs.tsx @@ -12,7 +12,7 @@ import { WorkflowExportDialog } from "@/components/settings/workflow-export-dial import type { WorkflowTemplate } from "@/lib/types/http"; const YAML_PLACEHOLDER = - "version: 1\ntype: kandev_workflow\nworkflows:\n - name: My Workflow\n steps: [...]"; + "version: 2\ntype: kandev_workflow\nworkflows:\n - name: My Workflow\n steps: [...]"; type ImportWorkflowsDialogProps = { open: boolean; diff --git a/apps/web/components/settings/workflow-card-actions.ts b/apps/web/components/settings/workflow-card-actions.ts index ee0005d895..3082b67c21 100644 --- a/apps/web/components/settings/workflow-card-actions.ts +++ b/apps/web/components/settings/workflow-card-actions.ts @@ -13,6 +13,8 @@ import { useToast } from "@/components/toast-provider"; import { useSerializedMutationQueue } from "./use-serialized-mutation-queue"; import type { WorkflowMutationGuardController } from "./workflow-mutation-guard"; import { applyWorkflowStepUpdates } from "./workflow-step-mutations"; +import { areStepDraftsEqual, stepUpdatePayload } from "./workflow-step-equality"; +export { areStepDraftsEqual } from "./workflow-step-equality"; import { createWorkflowAction, createWorkflowStepAction, @@ -94,6 +96,7 @@ function createDraftStep(workflow: Workflow, position: number): WorkflowStep { ...NEW_STEP_DEFAULTS, position, allow_manual_move: true, + complete_task_on_enter: false, created_at: "", updated_at: "", }; @@ -323,6 +326,7 @@ async function createMissingSteps( position: step.position, color: step.color, stage_type: step.stage_type ?? "custom", + complete_task_on_enter: step.complete_task_on_enter ?? false, cancel_triggers_turn_complete: step.cancel_triggers_turn_complete ?? false, agent_profile_id: step.agent_profile_id, profile_session_start_policy: normalizeWorkflowProfileSessionStartPolicy( @@ -402,46 +406,6 @@ function remapStepReferences(value: T, mappings: Map): T { return Object.fromEntries(mapped) as T; } -function stepUpdatePayload(step: WorkflowStep): Partial { - return { - name: step.name, - position: step.position, - color: step.color, - stage_type: step.stage_type ?? "custom", - prompt: step.prompt ?? "", - events: step.events ?? {}, - allow_manual_move: step.allow_manual_move ?? true, - is_start_step: step.is_start_step ?? false, - show_in_command_panel: step.show_in_command_panel ?? false, - auto_archive_after_hours: step.auto_archive_after_hours ?? 0, - agent_profile_id: step.agent_profile_id ?? "", - profile_session_start_policy: normalizeWorkflowProfileSessionStartPolicy( - step.profile_session_start_policy, - ), - profile_session_end_policy: normalizeWorkflowProfileSessionEndPolicy( - step.profile_session_end_policy, - ), - auto_advance_requires_signal: step.auto_advance_requires_signal ?? false, - cancel_triggers_turn_complete: step.cancel_triggers_turn_complete ?? false, - wip_limit: step.wip_limit ?? 0, - pull_from_step_id: step.pull_from_step_id ?? "", - }; -} - -export function areStepDraftsEqual(left: WorkflowStep[], right: WorkflowStep[]): boolean; -export function areStepDraftsEqual(left: WorkflowStep, right: WorkflowStep): boolean; -export function areStepDraftsEqual( - left: WorkflowStep[] | WorkflowStep, - right: WorkflowStep[] | WorkflowStep, -): boolean { - if (Array.isArray(left) && Array.isArray(right)) { - if (left.length !== right.length) return false; - return left.every((step, index) => areStepDraftsEqual(step, right[index])); - } - if (Array.isArray(left) || Array.isArray(right)) return false; - return JSON.stringify(stepUpdatePayload(left)) === JSON.stringify(stepUpdatePayload(right)); -} - type WorkflowDeleteHandlersParams = { workflow: Workflow; /** See `WorkflowStepActionsParams.readOnly` — defense-in-depth for synced workflows. */ diff --git a/apps/web/components/settings/workflow-dirty-state.ts b/apps/web/components/settings/workflow-dirty-state.ts index af1ff3ce73..a4631a4bee 100644 --- a/apps/web/components/settings/workflow-dirty-state.ts +++ b/apps/web/components/settings/workflow-dirty-state.ts @@ -1,5 +1,5 @@ import type { Workflow, WorkflowStep } from "@/lib/types/http"; -import { areStepDraftsEqual } from "./workflow-card-actions"; +import { areStepDraftsEqual } from "./workflow-step-equality"; function valuesEqual(left: unknown, right: unknown): boolean { if (Object.is(left, right)) return true; diff --git a/apps/web/components/settings/workflow-pipeline-editor-helpers.tsx b/apps/web/components/settings/workflow-pipeline-editor-helpers.tsx index 0ce4d23296..48eeb0af5b 100644 --- a/apps/web/components/settings/workflow-pipeline-editor-helpers.tsx +++ b/apps/web/components/settings/workflow-pipeline-editor-helpers.tsx @@ -1,11 +1,21 @@ "use client"; import { IconInfoCircle } from "@tabler/icons-react"; -import type { ReactNode } from "react"; +import { useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@kandev/ui/drawer"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip"; import type { WorkflowStep } from "@/lib/types/http"; import type { ScriptPlaceholder } from "@/components/settings/profile-edit/script-editor-completions"; +import { useTouchDrawer } from "@/hooks/use-compact-task-chrome"; +import { cn } from "@/lib/utils"; /** * Monaco completion entries for the step-prompt editor. `key` is the @@ -28,26 +38,52 @@ export function HelpTip({ text, testId, ariaLabel, + mobileTouchTarget = false, }: { text: ReactNode; testId?: string; ariaLabel?: string; + mobileTouchTarget?: boolean; }) { const { t } = useTranslation(); const label = ariaLabel ?? t("workflows:moreInformation"); + const touchDrawer = useTouchDrawer(); + const usesTouchDrawer = mobileTouchTarget && touchDrawer; + const [drawerOpen, setDrawerOpen] = useState(false); + const button = ( + + ); + + if (usesTouchDrawer) { + return ( + + {button} + + + {label} + {text} + + + + ); + } + return ( - - - + {button} {text} diff --git a/apps/web/components/settings/workflow-pipeline-editor-panels.tsx b/apps/web/components/settings/workflow-pipeline-editor-panels.tsx index 0deccb8e88..5f645c8126 100644 --- a/apps/web/components/settings/workflow-pipeline-editor-panels.tsx +++ b/apps/web/components/settings/workflow-pipeline-editor-panels.tsx @@ -19,6 +19,7 @@ import { } from "./workflow-pipeline-editor-helpers"; import { useStepActions, + CompleteTaskOnEnterToggle, TurnStartSelect, TurnCompleteSelect, ChildrenCompletedSelect, @@ -220,10 +221,11 @@ type StepBehaviorSectionProps = { steps: WorkflowStep[]; onUpdate: (updates: Partial) => void; toggleOnEnterAction: (type: string) => void; + isFinalStep: boolean; readOnly: boolean; }; -type StepToggleRowsProps = Omit; +type StepToggleRowsProps = Omit; // The six on-enter / board-behavior toggles. Split out of StepBehaviorSection // so that component stays under the 100-line function cap. @@ -325,6 +327,7 @@ function StepBehaviorSection({ steps, onUpdate, toggleOnEnterAction, + isFinalStep, readOnly, }: StepBehaviorSectionProps) { return ( @@ -338,6 +341,13 @@ function StepBehaviorSection({ readOnly={readOnly} /> + = {}): WorkflowStep { return { - id: "step-1", + id: STEP_ID, workflow_id: toWorkflowId("workflow-1"), name: "In Progress", position: 0, @@ -40,10 +52,10 @@ function renderTurnComplete(current: WorkflowStep, readOnly = false) { describe("TurnCompleteSelect cancel completion policy", () => { it("renders the policy disabled by default and updates it when checked", () => { const { onUpdate } = renderTurnComplete(step()); - const checkbox = screen.getByTestId("step-1-cancel-completion-checkbox"); + const checkbox = screen.getByTestId(CANCEL_COMPLETION_CHECKBOX_TEST_ID); - expect(checkbox.getAttribute("aria-checked")).toBe("false"); - expect(screen.getByTestId("step-1-cancel-completion-help").getAttribute("aria-label")).toBe( + expect(checkbox.getAttribute(ARIA_CHECKED_ATTRIBUTE)).toBe(UNCHECKED_ARIA_VALUE); + expect(screen.getByTestId(CANCEL_COMPLETION_HELP_TEST_ID).getAttribute("aria-label")).toBe( "More information", ); expect(screen.queryByText(/Applies only when a user explicitly cancels a turn\./)).toBeNull(); @@ -53,9 +65,9 @@ describe("TurnCompleteSelect cancel completion policy", () => { it("preserves the persisted value and disables it in read-only mode", () => { renderTurnComplete(step({ cancel_triggers_turn_complete: true }), true); - const checkbox = screen.getByTestId("step-1-cancel-completion-checkbox"); + const checkbox = screen.getByTestId(CANCEL_COMPLETION_CHECKBOX_TEST_ID); - expect(checkbox.getAttribute("aria-checked")).toBe("true"); + expect(checkbox.getAttribute(ARIA_CHECKED_ATTRIBUTE)).toBe(CHECKED_ARIA_VALUE); expect((checkbox as HTMLButtonElement).disabled).toBe(true); }); @@ -64,6 +76,49 @@ describe("TurnCompleteSelect cancel completion policy", () => { step({ events: { on_turn_complete: [] }, cancel_triggers_turn_complete: true }), ); - expect(screen.queryByTestId("step-1-cancel-completion-checkbox")).toBeNull(); + expect(screen.queryByTestId(CANCEL_COMPLETION_CHECKBOX_TEST_ID)).toBeNull(); + }); +}); + +describe("CompleteTaskOnEnterToggle", () => { + it("renders only for the final step and keeps help separate from the checkbox", () => { + const onUpdate = vi.fn(); + render( + , + ); + + const checkbox = screen.getByTestId(COMPLETE_TASK_CHECKBOX_TEST_ID); + const help = screen.getByTestId(COMPLETE_TASK_HELP_TEST_ID); + expect(checkbox.getAttribute(ARIA_CHECKED_ATTRIBUTE)).toBe(CHECKED_ARIA_VALUE); + expect(help).not.toBe(checkbox); + expect(help.className).toContain("h-11"); + + fireEvent.click(help); + expect(checkbox.getAttribute(ARIA_CHECKED_ATTRIBUTE)).toBe(CHECKED_ARIA_VALUE); + expect(onUpdate).not.toHaveBeenCalled(); + + fireEvent.click(checkbox); + expect(onUpdate).toHaveBeenCalledWith({ complete_task_on_enter: false }); + }); + + it("does not expose the control for a non-final step", () => { + render( + , + ); + + expect(screen.queryByTestId(COMPLETE_TASK_CHECKBOX_TEST_ID)).toBeNull(); + expect(screen.queryByTestId(COMPLETE_TASK_HELP_TEST_ID)).toBeNull(); }); }); diff --git a/apps/web/components/settings/workflow-pipeline-editor-step-actions.tsx b/apps/web/components/settings/workflow-pipeline-editor-step-actions.tsx index dcbbbce018..9961f71049 100644 --- a/apps/web/components/settings/workflow-pipeline-editor-step-actions.tsx +++ b/apps/web/components/settings/workflow-pipeline-editor-step-actions.tsx @@ -112,6 +112,62 @@ type StepSelectProps = { readOnly: boolean; }; +type CompleteTaskOnEnterToggleProps = { + step: WorkflowStep; + savedStep?: WorkflowStep; + onUpdate: (updates: Partial) => void; + readOnly: boolean; + isFinalStep: boolean; +}; + +export function CompleteTaskOnEnterToggle({ + step, + savedStep, + onUpdate, + readOnly, + isFinalStep, +}: CompleteTaskOnEnterToggleProps) { + const { t } = useTranslation(); + if (!isFinalStep) return null; + + const checkboxId = `${step.id}-complete-task-on-enter`; + return ( +
+ { + if (readOnly) return; + onUpdate({ complete_task_on_enter: checked === true }); + }} + disabled={readOnly} + data-settings-dirty={isWorkflowStepValueDirty( + step, + savedStep, + (item) => item.complete_task_on_enter ?? false, + )} + /> + + +
+ ); +} + export function TurnStartSelect({ step, savedStep, diff --git a/apps/web/components/settings/workflow-step-equality.ts b/apps/web/components/settings/workflow-step-equality.ts new file mode 100644 index 0000000000..1021832efc --- /dev/null +++ b/apps/web/components/settings/workflow-step-equality.ts @@ -0,0 +1,48 @@ +import { + normalizeWorkflowProfileSessionEndPolicy, + normalizeWorkflowProfileSessionStartPolicy, + type WorkflowStep, +} from "@/lib/types/http"; + +function stepUpdatePayload(step: WorkflowStep): Partial { + return { + name: step.name, + position: step.position, + color: step.color, + stage_type: step.stage_type ?? "custom", + prompt: step.prompt ?? "", + events: step.events ?? {}, + allow_manual_move: step.allow_manual_move ?? true, + is_start_step: step.is_start_step ?? false, + show_in_command_panel: step.show_in_command_panel ?? false, + auto_archive_after_hours: step.auto_archive_after_hours ?? 0, + agent_profile_id: step.agent_profile_id ?? "", + profile_session_start_policy: normalizeWorkflowProfileSessionStartPolicy( + step.profile_session_start_policy, + ), + profile_session_end_policy: normalizeWorkflowProfileSessionEndPolicy( + step.profile_session_end_policy, + ), + complete_task_on_enter: step.complete_task_on_enter ?? false, + auto_advance_requires_signal: step.auto_advance_requires_signal ?? false, + cancel_triggers_turn_complete: step.cancel_triggers_turn_complete ?? false, + wip_limit: step.wip_limit ?? 0, + pull_from_step_id: step.pull_from_step_id ?? "", + }; +} + +export function areStepDraftsEqual(left: WorkflowStep[], right: WorkflowStep[]): boolean; +export function areStepDraftsEqual(left: WorkflowStep, right: WorkflowStep): boolean; +export function areStepDraftsEqual( + left: WorkflowStep[] | WorkflowStep, + right: WorkflowStep[] | WorkflowStep, +): boolean { + if (Array.isArray(left) && Array.isArray(right)) { + if (left.length !== right.length) return false; + return left.every((step, index) => areStepDraftsEqual(step, right[index])); + } + if (Array.isArray(left) || Array.isArray(right)) return false; + return JSON.stringify(stepUpdatePayload(left)) === JSON.stringify(stepUpdatePayload(right)); +} + +export { stepUpdatePayload }; diff --git a/apps/web/components/settings/workflow-step-mutations.ts b/apps/web/components/settings/workflow-step-mutations.ts index 9f8d158a93..aed5297fa5 100644 --- a/apps/web/components/settings/workflow-step-mutations.ts +++ b/apps/web/components/settings/workflow-step-mutations.ts @@ -25,6 +25,7 @@ export function newWorkflowStep(workflow: Workflow, position: number, id: string ...NEW_STEP_DEFAULTS, position, allow_manual_move: true, + complete_task_on_enter: false, created_at: "", updated_at: "", }; diff --git a/apps/web/components/task/chat/session-stopped-banner.test.tsx b/apps/web/components/task/chat/session-stopped-banner.test.tsx index 8cd5c904a6..9e6e095c32 100644 --- a/apps/web/components/task/chat/session-stopped-banner.test.tsx +++ b/apps/web/components/task/chat/session-stopped-banner.test.tsx @@ -59,9 +59,12 @@ vi.mock("react-i18next", () => ({ import { SessionStoppedBanner } from "./session-stopped-banner"; +const SESSION_RECOVER_ACTION = "session.recover"; +const TASK_ID = "task-1"; +const SESSION_ID = "session-1"; const baseProps: Omit = { - taskId: "task-1", - sessionId: "session-1", + taskId: TASK_ID, + sessionId: SESSION_ID, workspaceId: "workspace-1", }; const RESUME_BUTTON_TEST_ID = "recovery-resume-button"; @@ -93,19 +96,27 @@ beforeEach(() => { afterEach(cleanup); describe("SessionStoppedBanner basics", () => { - it("offers only New Agent for a completed session and opens the new-session dialog", async () => { + it("offers Resume and New Agent for a completed session", async () => { render(); expect(screen.getByTestId("completed-session-banner")).toBeTruthy(); expect(screen.getByText("This session is complete.")).toBeTruthy(); expect(screen.getByRole("button", { name: "New Agent" })).toBeTruthy(); - expect(screen.queryByTestId(RESUME_BUTTON_TEST_ID)).toBeNull(); + expect(screen.getByTestId(RESUME_BUTTON_TEST_ID)).toBeTruthy(); expect(screen.queryByTestId(FRESH_BUTTON_TEST_ID)).toBeNull(); + fireEvent.click(screen.getByTestId(RESUME_BUTTON_TEST_ID)); + await waitFor(() => + expect(mocks.request).toHaveBeenCalledWith( + SESSION_RECOVER_ACTION, + { task_id: TASK_ID, session_id: SESSION_ID, action: "resume" }, + 30000, + ), + ); + fireEvent.click(screen.getByRole("button", { name: "New Agent" })); expect(await screen.findByTestId("new-session-dialog")).toBeTruthy(); - expect(mocks.request).not.toHaveBeenCalled(); }); it("disables New Agent when no task is available", () => { @@ -136,8 +147,8 @@ describe("SessionStoppedBanner basics", () => { fireEvent.click(screen.getByTestId(RESUME_BUTTON_TEST_ID)); await waitFor(() => expect(mocks.request).toHaveBeenCalledWith( - "session.recover", - { task_id: "task-1", session_id: "session-1", action: "resume" }, + SESSION_RECOVER_ACTION, + { task_id: TASK_ID, session_id: SESSION_ID, action: "resume" }, 30000, ), ); @@ -145,8 +156,8 @@ describe("SessionStoppedBanner basics", () => { fireEvent.click(screen.getByTestId(FRESH_BUTTON_TEST_ID)); await waitFor(() => expect(mocks.request).toHaveBeenCalledWith( - "session.recover", - { task_id: "task-1", session_id: "session-1", action: "fresh_start" }, + SESSION_RECOVER_ACTION, + { task_id: TASK_ID, session_id: SESSION_ID, action: "fresh_start" }, 30000, ), ); @@ -206,8 +217,8 @@ describe("SessionStoppedBanner recovery failures", () => { await waitFor(() => expect(mocks.request).toHaveBeenLastCalledWith( - "session.recover", - { task_id: "task-1", session_id: "session-1", action: "resume_new_branch" }, + SESSION_RECOVER_ACTION, + { task_id: TASK_ID, session_id: SESSION_ID, action: "resume_new_branch" }, 30000, ), ); diff --git a/apps/web/components/task/chat/session-stopped-banner.tsx b/apps/web/components/task/chat/session-stopped-banner.tsx index a9cb1913f9..e06bf7a4e3 100644 --- a/apps/web/components/task/chat/session-stopped-banner.tsx +++ b/apps/web/components/task/chat/session-stopped-banner.tsx @@ -95,6 +95,16 @@ function StoppedRecoveryFeedback({ ); } +function useSessionProfileExists(sessionId: string | null): boolean { + return useAppStore((s) => { + if (!sessionId) return false; + const agentProfileId = s.taskSessions.items[sessionId]?.agent_profile_id; + return ( + !!agentProfileId && s.agentProfiles.items.some((p: { id: string }) => p.id === agentProfileId) + ); + }); +} + function RecoverableSessionActions({ onShowDialog, taskId, @@ -131,13 +141,7 @@ function RecoverableSessionActions({ handleNewBranch, } = recoveryActions ?? localRecoveryActions; - const profileExists = useAppStore((s) => { - if (!sessionId) return false; - const agentProfileId = s.taskSessions.items[sessionId]?.agent_profile_id; - return ( - !!agentProfileId && s.agentProfiles.items.some((p: { id: string }) => p.id === agentProfileId) - ); - }); + const profileExists = useSessionProfileExists(sessionId); const handleResume = useCallback(() => { if (taskId && sessionId) void handleRecover("resume"); @@ -176,6 +180,94 @@ function RecoverableSessionActions({ ); } +function CompletedSessionActions({ + onShowDialog, + taskId, + sessionId, + workspaceId, + recoveryActions, +}: Pick< + SessionStoppedBannerProps, + "onShowDialog" | "taskId" | "sessionId" | "workspaceId" | "recoveryActions" +>) { + const { t } = useTranslation(); + const localRecoveryActions = useSessionRecoveryActions({ + taskId: taskId ?? "", + sessionId: sessionId ?? "", + }); + const { + busyAction, + recoveryError, + branchDetails, + recoveryNotice, + handleRecover, + handleRestore, + handleRetry, + handleNewBranch, + } = recoveryActions ?? localRecoveryActions; + const profileExists = useSessionProfileExists(sessionId); + + const handleResume = useCallback(() => { + if (taskId && sessionId) void handleRecover("resume"); + }, [handleRecover, sessionId, taskId]); + + return ( +
+ void handleRestore()} + onNewBranch={handleNewBranch} + /> +
+ {sessionId && taskId && ( + + + + + + + {!profileExists && ( + {t("task:agentProfileNoLongerExists")} + )} + + )} + +
+
+ ); +} + function RecoverableSessionButtons({ taskId, sessionId, @@ -204,6 +296,7 @@ function RecoverableSessionButtons({ + ) : ( }>; on_turn_start?: Array<{ type: string; config?: Record }>; @@ -840,6 +841,9 @@ export class ApiClient { ...(opts?.profile_session_end_policy ? { profile_session_end_policy: opts.profile_session_end_policy } : {}), + ...(opts?.complete_task_on_enter != null + ? { complete_task_on_enter: opts.complete_task_on_enter } + : {}), ...(opts?.events != null ? { events: opts.events } : {}), }); } @@ -1271,6 +1275,7 @@ export class ApiClient { wip_limit?: number; pull_from_step_id?: string | null; cancel_triggers_turn_complete?: boolean; + complete_task_on_enter?: boolean; stage_type?: "work" | "review" | "approval" | "custom"; profile_session_start_policy?: WorkflowProfileSessionStartPolicy; profile_session_end_policy?: WorkflowProfileSessionEndPolicy; diff --git a/apps/web/e2e/pages/session-page.ts b/apps/web/e2e/pages/session-page.ts index 8090e899ce..1e9e440d27 100644 --- a/apps/web/e2e/pages/session-page.ts +++ b/apps/web/e2e/pages/session-page.ts @@ -699,6 +699,11 @@ export class SessionPage { return this.completedSessionBanner().getByTestId("completed-session-new-agent-button"); } + /** "Resume" action shown for an explicitly completed conversation. */ + completedSessionResumeButton(): Locator { + return this.completedSessionBanner().getByTestId("recovery-resume-button"); + } + /** "Cancel" button shown on the yellow transient-retry (529 Overloaded) card. */ recoveryCancelRetryButton(): Locator { return this.page.getByTestId("recovery-cancel-retry-button"); diff --git a/apps/web/e2e/pages/workflow-settings-page.ts b/apps/web/e2e/pages/workflow-settings-page.ts index 365d0b2206..72af245a63 100644 --- a/apps/web/e2e/pages/workflow-settings-page.ts +++ b/apps/web/e2e/pages/workflow-settings-page.ts @@ -95,6 +95,35 @@ export class WorkflowSettingsPage { return card.locator(".group.relative").filter({ hasText: stepName }); } + completeTaskOnEnterCheckbox(card: Locator, stepId: string): Locator { + return card.getByTestId(`${stepId}-complete-task-on-enter-checkbox`); + } + + completeTaskOnEnterHelp(card: Locator, stepId: string): Locator { + return card.getByTestId(`${stepId}-complete-task-on-enter-help`); + } + + async reorderStep(card: Locator, fromName: string, toName: string): Promise { + const source = this.stepNodeByName(card, fromName).locator("button").first(); + const target = this.stepNodeByName(card, toName); + const sourceBox = await source.boundingBox(); + const targetBox = await target.boundingBox(); + if (!sourceBox || !targetBox) throw new Error(`Cannot drag ${fromName} to ${toName}`); + const sourcePoint = { + x: sourceBox.x + sourceBox.width / 2, + y: sourceBox.y + sourceBox.height / 2, + }; + const targetPoint = { + x: targetBox.x + targetBox.width / 2, + y: targetBox.y + targetBox.height / 2, + }; + await this.page.mouse.move(sourcePoint.x, sourcePoint.y); + await this.page.mouse.down(); + await this.page.mouse.move(sourcePoint.x + 12, sourcePoint.y, { steps: 2 }); + await this.page.mouse.move(targetPoint.x, targetPoint.y, { steps: 8 }); + await this.page.mouse.up(); + } + /** A replay-cycle diagnostic rendered inside a workflow card or guard dialog. */ cycleDiagnostic(container: Locator, autoStartStepId: string): Locator { return container.getByTestId(`workflow-cycle-diagnostic-${autoStartStepId}`); diff --git a/apps/web/e2e/tests/session/completed-session-resume.spec.ts b/apps/web/e2e/tests/session/completed-session-resume.spec.ts new file mode 100644 index 0000000000..b3b76ea358 --- /dev/null +++ b/apps/web/e2e/tests/session/completed-session-resume.spec.ts @@ -0,0 +1,116 @@ +import { test, expect } from "../../fixtures/test-base"; +import type { SeedData } from "../../fixtures/test-base"; +import type { ApiClient } from "../../helpers/api-client"; +import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions"; +import { waitForSessionDone } from "../../helpers/session"; +import { SessionPage } from "../../pages/session-page"; + +async function seedCompletedConversation(apiClient: ApiClient, seedData: SeedData) { + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + `Completed conversation ${Date.now()}`, + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id"); + await waitForSessionDone(apiClient, task.id, task.session_id, "Waiting for initial conversation"); + await apiClient.seedTaskSession(task.id, { + state: "COMPLETED", + sessionId: task.session_id, + agentProfileId: seedData.agentProfileId, + repositoryId: seedData.repositoryId, + completedAt: new Date().toISOString(), + }); + await apiClient.updateTaskState(task.id, "COMPLETED"); + return task; +} + +test.describe("Completed conversation resume", () => { + test("resumes the selected completed conversation and sends a follow-up in place", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(180_000); + const task = await seedCompletedConversation(apiClient, seedData); + const before = await apiClient.listTaskSessions(task.id); + const primary = before.sessions.find((session) => session.is_primary); + expect(primary?.id).toBe(task.session_id); + + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + const banner = session.completedSessionBanner(); + await expect(banner).toBeVisible({ timeout: 30_000 }); + await expect(session.completedSessionResumeButton()).toBeVisible(); + await expect(session.completedSessionNewAgentButton()).toBeVisible(); + + // Opening historical work is passive. Reloading must not launch a new + // execution or replace the selected session. + await testPage.reload(); + await session.waitForLoad(); + await expect(session.completedSessionBanner()).toBeVisible({ timeout: 30_000 }); + const afterReload = await apiClient.listTaskSessions(task.id); + expect(afterReload.sessions).toHaveLength(before.sessions.length); + expect(afterReload.sessions.find((item) => item.is_primary)?.id).toBe(task.session_id); + + await session.completedSessionResumeButton().click(); + await expect(session.completedSessionBanner()).toHaveCount(0, { timeout: 60_000 }); + await expect + .poll( + async () => { + const current = await apiClient.listTaskSessions(task.id); + return current.sessions.find((item) => item.id === task.session_id)?.state ?? "MISSING"; + }, + { timeout: 60_000, message: "Waiting for the resumed conversation to become idle" }, + ) + .toBe("WAITING_FOR_INPUT"); + await expect(session.activeChat().locator(".tiptap.ProseMirror:visible").first()).toBeEditable({ + timeout: 60_000, + }); + + const afterResume = await apiClient.listTaskSessions(task.id); + expect(afterResume.sessions).toHaveLength(before.sessions.length); + expect(afterResume.sessions.find((item) => item.id === task.session_id)?.state).toBe( + "WAITING_FOR_INPUT", + ); + + await session.sendMessage("/e2e:simple-message"); + await session.expectChatResponseVisible("simple mock response", 1, { timeout: 60_000 }); + await expect + .poll( + async () => { + const current = await apiClient.listTaskSessions(task.id); + return current.sessions.find((item) => item.id === task.session_id)?.state ?? "MISSING"; + }, + { timeout: 60_000, message: "Waiting for the follow-up turn to become idle" }, + ) + .toBe("WAITING_FOR_INPUT"); + await expect(session.activeChat().locator(".tiptap.ProseMirror:visible").first()).toBeEditable({ + timeout: 60_000, + }); + + const afterFollowUp = await apiClient.listTaskSessions(task.id); + const taskAfterFollowUp = await apiClient.getTask(task.id); + expect(afterFollowUp.sessions).toHaveLength(before.sessions.length); + expect(afterFollowUp.sessions.find((item) => item.id === task.session_id)?.is_primary).toBe( + true, + ); + expect(taskAfterFollowUp.primary_session_id).toBe(task.session_id); + expect(taskAfterFollowUp.state).toBe("COMPLETED"); + const messages = await apiClient.listSessionMessages(task.session_id); + expect(messages.messages.filter((message) => message.author_type === "user")).toHaveLength(2); + expect( + messages.messages.filter( + (message) => + message.author_type === "agent" && message.content.includes("simple mock response"), + ), + ).toHaveLength(2); + await assertNoDocumentHorizontalOverflow(testPage, "completed conversation resume"); + }); +}); diff --git a/apps/web/e2e/tests/session/mobile-completed-session-resume.spec.ts b/apps/web/e2e/tests/session/mobile-completed-session-resume.spec.ts new file mode 100644 index 0000000000..3894feb903 --- /dev/null +++ b/apps/web/e2e/tests/session/mobile-completed-session-resume.spec.ts @@ -0,0 +1,85 @@ +import { test, expect } from "../../fixtures/test-base"; +import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions"; +import { waitForSessionDone } from "../../helpers/session"; +import { SessionPage } from "../../pages/session-page"; + +test.describe("Completed conversation resume on mobile", () => { + test("resumes in place with touch-sized actions and no overflow", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(180_000); + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + `Mobile completed conversation ${Date.now()}`, + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id"); + await waitForSessionDone( + apiClient, + task.id, + task.session_id, + "Waiting for initial conversation", + ); + await apiClient.seedTaskSession(task.id, { + state: "COMPLETED", + sessionId: task.session_id, + agentProfileId: seedData.agentProfileId, + repositoryId: seedData.repositoryId, + completedAt: new Date().toISOString(), + }); + await apiClient.updateTaskState(task.id, "COMPLETED"); + + const before = await apiClient.listTaskSessions(task.id); + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await expect(session.completedSessionBanner()).toBeVisible({ timeout: 30_000 }); + + const resume = session.completedSessionResumeButton(); + const newAgent = session.completedSessionNewAgentButton(); + await expect(resume).toBeVisible(); + await expect(newAgent).toBeVisible(); + const viewportWidth = await testPage.evaluate(() => window.innerWidth); + for (const control of [resume, newAgent]) { + const box = await control.boundingBox(); + expect(box).not.toBeNull(); + expect(box!.height).toBeGreaterThanOrEqual(44); + expect(box!.x).toBeGreaterThanOrEqual(0); + expect(box!.x + box!.width).toBeLessThanOrEqual(viewportWidth); + } + + await resume.tap(); + await expect(session.completedSessionBanner()).toHaveCount(0, { timeout: 60_000 }); + await expect(session.activeChat().locator(".tiptap.ProseMirror:visible").first()).toBeEditable({ + timeout: 60_000, + }); + await session.sendMessageViaButton("/e2e:simple-message"); + await session.expectChatResponseVisible("simple mock response", 1, { timeout: 60_000 }); + await expect + .poll( + async () => { + const current = await apiClient.listTaskSessions(task.id); + return current.sessions.find((item) => item.id === task.session_id)?.state ?? "MISSING"; + }, + { timeout: 60_000, message: "Waiting for the follow-up turn to become idle" }, + ) + .toBe("WAITING_FOR_INPUT"); + await expect(session.activeChat().locator(".tiptap.ProseMirror:visible").first()).toBeEditable({ + timeout: 60_000, + }); + + const after = await apiClient.listTaskSessions(task.id); + expect(after.sessions).toHaveLength(before.sessions.length); + expect(after.sessions.find((item) => item.id === task.session_id)?.is_primary).toBe(true); + expect((await apiClient.getTask(task.id)).state).toBe("COMPLETED"); + await assertNoDocumentHorizontalOverflow(testPage, "mobile completed conversation resume"); + }); +}); diff --git a/apps/web/e2e/tests/session/new-session-dialog.spec.ts b/apps/web/e2e/tests/session/new-session-dialog.spec.ts index 4aa5de85f0..ab8c058f4a 100644 --- a/apps/web/e2e/tests/session/new-session-dialog.spec.ts +++ b/apps/web/e2e/tests/session/new-session-dialog.spec.ts @@ -75,7 +75,7 @@ test.describe("New session dialog", () => { } }); - test("completed sessions replace the composer with a New Agent action", async ({ + test("completed sessions show Resume and New Agent actions", async ({ testPage, apiClient, seedData, @@ -117,7 +117,8 @@ test.describe("New session dialog", () => { await expect(session.completedSessionNewAgentButton()).toHaveText("New Agent"); await expect(session.activeChat().locator(".tiptap.ProseMirror")).not.toBeVisible(); await expect(session.submitButton()).not.toBeVisible(); - await expect(session.recoveryResumeButton()).not.toBeVisible(); + await expect(session.recoveryResumeButton()).toBeVisible(); + await expect(session.recoveryResumeButton()).toHaveText("Resume"); await expect(session.recoveryFreshButton()).not.toBeVisible(); await session.completedSessionNewAgentButton().click(); diff --git a/apps/web/e2e/tests/task/launch-failure-recovery.spec.ts b/apps/web/e2e/tests/task/launch-failure-recovery.spec.ts index 1ec3dc794f..1848b6f836 100644 --- a/apps/web/e2e/tests/task/launch-failure-recovery.spec.ts +++ b/apps/web/e2e/tests/task/launch-failure-recovery.spec.ts @@ -65,7 +65,9 @@ async function recoveryWorkflow(apiClient: ApiClient, workspaceId: string, name: const workflow = await apiClient.createWorkflow(workspaceId, name); const waiting = await apiClient.createWorkflowStep(workflow.id, "Waiting", 0); const review = await apiClient.createWorkflowStep(workflow.id, "Review", 1); - const done = await apiClient.createWorkflowStep(workflow.id, "Done", 2); + const done = await apiClient.createWorkflowStep(workflow.id, "Done", 2, { + complete_task_on_enter: true, + }); await apiClient.updateWorkflowStep(review.id, { events: { on_enter: [{ type: "auto_start_agent" }] }, }); diff --git a/apps/web/e2e/tests/workflow/mobile-workflow-task-completion.spec.ts b/apps/web/e2e/tests/workflow/mobile-workflow-task-completion.spec.ts new file mode 100644 index 0000000000..ba20133cf0 --- /dev/null +++ b/apps/web/e2e/tests/workflow/mobile-workflow-task-completion.spec.ts @@ -0,0 +1,61 @@ +import { test, expect } from "../../fixtures/test-base"; +import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions"; +import { WorkflowSettingsPage } from "../../pages/workflow-settings-page"; + +test.describe("Workflow task completion on mobile", () => { + test("keeps the final-step control and help disclosure touch accessible", async ({ + testPage, + apiClient, + seedData, + }) => { + const workflow = await apiClient.createWorkflow( + seedData.workspaceId, + `Mobile completion editor ${Date.now()}`, + ); + const first = await apiClient.createWorkflowStep(workflow.id, "Draft", 0, { + is_start_step: true, + }); + const final = await apiClient.createWorkflowStep(workflow.id, "Done", 1, { + complete_task_on_enter: true, + }); + + const settings = new WorkflowSettingsPage(testPage); + await settings.goto(seedData.workspaceId); + const card = await settings.findWorkflowCard(workflow.name, { waitForName: true }); + await settings.selectStep(card, "Draft", true); + await expect(settings.completeTaskOnEnterCheckbox(card, first.id)).toHaveCount(0); + await expect(settings.completeTaskOnEnterHelp(card, first.id)).toHaveCount(0); + + await settings.selectStep(card, "Done", true); + const checkbox = settings.completeTaskOnEnterCheckbox(card, final.id); + const help = settings.completeTaskOnEnterHelp(card, final.id); + await expect(checkbox).toBeChecked(); + const helpBox = await help.boundingBox(); + expect(helpBox).not.toBeNull(); + expect(helpBox!.height).toBeGreaterThanOrEqual(44); + await help.evaluate((element) => (element as HTMLElement).blur()); + await expect(testPage.getByRole("tooltip")).toHaveCount(0); + await help.tap(); + await expect(testPage.getByRole("dialog")).toContainText( + "Marks the task complete when it enters this final step", + ); + await expect(checkbox).toBeChecked(); + await testPage.keyboard.press("Escape"); + await expect(testPage.getByRole("dialog")).toHaveCount(0); + + await checkbox.tap(); + await expect(checkbox).not.toBeChecked(); + const viewportWidth = await testPage.evaluate(() => window.innerWidth); + const saveButton = settings.floatingSave.getByRole("button", { name: "Save changes" }); + for (const control of [checkbox, help, saveButton]) { + const box = await control.boundingBox(); + expect(box).not.toBeNull(); + expect(box!.x).toBeGreaterThanOrEqual(0); + expect(box!.x + box!.width).toBeLessThanOrEqual(viewportWidth); + } + await settings.saveChanges(true); + const { steps } = await apiClient.listWorkflowSteps(workflow.id); + expect(steps.find((step) => step.id === final.id)?.complete_task_on_enter).toBe(false); + await assertNoDocumentHorizontalOverflow(testPage, "mobile workflow task completion"); + }); +}); diff --git a/apps/web/e2e/tests/workflow/workflow-import-export.spec.ts b/apps/web/e2e/tests/workflow/workflow-import-export.spec.ts index 8933b34af7..ecfccd719a 100644 --- a/apps/web/e2e/tests/workflow/workflow-import-export.spec.ts +++ b/apps/web/e2e/tests/workflow/workflow-import-export.spec.ts @@ -17,9 +17,10 @@ test.describe("Workflow import/export", () => { // Textarea should contain valid YAML with the workflow const textarea = dialog.locator("textarea"); const yamlContent = await textarea.inputValue(); - expect(yamlContent).toContain("version: 1"); + expect(yamlContent).toContain("version: 2"); expect(yamlContent).toContain("type: kandev_workflow"); expect(yamlContent).toContain("E2E Workflow"); + expect(yamlContent).toContain("complete_task_on_enter: false"); // Copy button should work await dialog.getByRole("button", { name: "Copy" }).click(); diff --git a/apps/web/e2e/tests/workflow/workflow-task-completion.spec.ts b/apps/web/e2e/tests/workflow/workflow-task-completion.spec.ts new file mode 100644 index 0000000000..e41f493703 --- /dev/null +++ b/apps/web/e2e/tests/workflow/workflow-task-completion.spec.ts @@ -0,0 +1,176 @@ +import { test, expect } from "../../fixtures/test-base"; +import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions"; +import { waitForSessionDone } from "../../helpers/session"; +import { SessionPage } from "../../pages/session-page"; +import { WorkflowSettingsPage } from "../../pages/workflow-settings-page"; + +test.describe("Workflow task completion", () => { + test("shows completion only on the final step and preserves it through edit flows", async ({ + testPage, + apiClient, + seedData, + }) => { + const workflow = await apiClient.createWorkflow( + seedData.workspaceId, + `Completion editor ${Date.now()}`, + ); + const start = await apiClient.createWorkflowStep(workflow.id, "Draft", 0, { + is_start_step: true, + complete_task_on_enter: false, + }); + const middle = await apiClient.createWorkflowStep(workflow.id, "Review", 1, { + complete_task_on_enter: false, + }); + const done = await apiClient.createWorkflowStep(workflow.id, "Done", 2, { + complete_task_on_enter: true, + }); + + const settings = new WorkflowSettingsPage(testPage); + await settings.goto(seedData.workspaceId); + const card = await settings.findWorkflowCard(workflow.name, { waitForName: true }); + await expect(card).toBeVisible(); + + await settings.selectStep(card, "Review"); + await expect(settings.completeTaskOnEnterCheckbox(card, middle.id)).toHaveCount(0); + await expect(settings.completeTaskOnEnterHelp(card, middle.id)).toHaveCount(0); + + await settings.selectStep(card, "Done"); + const checkbox = settings.completeTaskOnEnterCheckbox(card, done.id); + const help = settings.completeTaskOnEnterHelp(card, done.id); + await expect(checkbox).toBeChecked(); + await expect(help).toBeVisible(); + + await help.hover(); + await expect(testPage.getByRole("tooltip")).toContainText( + "Marks the task complete when it enters this final step", + ); + await help.focus(); + await expect(testPage.getByRole("tooltip")).toContainText( + "Marks the task complete when it enters this final step", + ); + await help.click(); + await expect(checkbox).toBeChecked(); + + await checkbox.click(); + await expect(checkbox).not.toBeChecked(); + await settings.floatingSave.getByRole("button", { name: "Reset", exact: true }).click(); + await expect(checkbox).toBeChecked(); + + await checkbox.click(); + await settings.saveChanges(); + await testPage.reload(); + await settings.goto(seedData.workspaceId); + const reloadedCard = await settings.findWorkflowCard(workflow.name, { waitForName: true }); + await settings.selectStep(reloadedCard, "Done"); + await expect(settings.completeTaskOnEnterCheckbox(reloadedCard, done.id)).not.toBeChecked(); + + // Move the saved step away from the end and back. Its persisted value stays + // attached to the step, while the control follows final position. + await settings.reorderStep(reloadedCard, "Done", "Review"); + await settings.selectStep(reloadedCard, "Done"); + await expect(settings.completeTaskOnEnterCheckbox(reloadedCard, done.id)).toHaveCount(0); + await settings.reorderStep(reloadedCard, "Done", "Review"); + await settings.selectStep(reloadedCard, "Done"); + await expect(settings.completeTaskOnEnterCheckbox(reloadedCard, done.id)).toBeVisible(); + await expect(settings.completeTaskOnEnterCheckbox(reloadedCard, done.id)).not.toBeChecked(); + if (await settings.floatingSave.isVisible().catch(() => false)) { + await settings.saveChanges(); + } + + const { steps } = await apiClient.listWorkflowSteps(workflow.id); + expect(steps.find((step) => step.id === start.id)?.position).toBe(0); + expect(steps.find((step) => step.id === done.id)?.position).toBe(2); + expect(steps.find((step) => step.id === done.id)?.complete_task_on_enter).toBe(false); + await assertNoDocumentHorizontalOverflow(testPage, "workflow task completion editor"); + }); + + test("does not complete an unchecked final step and accepts a follow-up turn", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(150_000); + const workflow = await apiClient.createWorkflow( + seedData.workspaceId, + `Unchecked final ${Date.now()}`, + ); + const start = await apiClient.createWorkflowStep(workflow.id, "Start", 0, { + is_start_step: true, + events: { on_turn_complete: [{ type: "move_to_next" }] }, + }); + const final = await apiClient.createWorkflowStep(workflow.id, "Final", 1, { + complete_task_on_enter: false, + }); + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + `Unchecked final task ${Date.now()}`, + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: workflow.id, + workflow_step_id: start.id, + repository_ids: [seedData.repositoryId], + }, + ); + if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id"); + + await waitForSessionDone(apiClient, task.id, task.session_id, "Waiting for start step turn"); + await expect + .poll(async () => (await apiClient.getTask(task.id)).workflow_step_id, { + timeout: 60_000, + message: "Workflow did not enter the unchecked final step", + }) + .toBe(final.id); + expect((await apiClient.getTask(task.id)).state).not.toBe("COMPLETED"); + + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await expect(session.completedSessionBanner()).toHaveCount(0); + await session.waitForChatIdle({ timeout: 30_000, requireEditable: true }); + await session.sendMessage("/e2e:simple-message"); + await session.expectChatResponseVisible("simple mock response", 1, { timeout: 60_000 }); + await session.waitForChatIdle({ timeout: 30_000 }); + + const after = await apiClient.getTask(task.id); + expect(after.state).not.toBe("COMPLETED"); + expect(after.workflow_step_id).toBe(final.id); + await assertNoDocumentHorizontalOverflow(testPage, "unchecked final follow-up"); + }); + + test("completes a task when it enters an enabled final step", async ({ apiClient, seedData }) => { + test.setTimeout(150_000); + const workflow = await apiClient.createWorkflow( + seedData.workspaceId, + `Checked final ${Date.now()}`, + ); + const start = await apiClient.createWorkflowStep(workflow.id, "Start", 0, { + is_start_step: true, + events: { on_turn_complete: [{ type: "move_to_next" }] }, + }); + const final = await apiClient.createWorkflowStep(workflow.id, "Final", 1, { + complete_task_on_enter: true, + }); + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + `Checked final task ${Date.now()}`, + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: workflow.id, + workflow_step_id: start.id, + repository_ids: [seedData.repositoryId], + }, + ); + if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id"); + + await waitForSessionDone(apiClient, task.id, task.session_id, "Waiting for checked final step"); + await expect + .poll(async () => (await apiClient.getTask(task.id)).state, { + timeout: 60_000, + message: "Task did not complete on the enabled final step", + }) + .toBe("COMPLETED"); + expect((await apiClient.getTask(task.id)).workflow_step_id).toBe(final.id); + }); +}); diff --git a/apps/web/hooks/domains/session/use-session-recovery-actions.test.ts b/apps/web/hooks/domains/session/use-session-recovery-actions.test.ts new file mode 100644 index 0000000000..42e91cd401 --- /dev/null +++ b/apps/web/hooks/domains/session/use-session-recovery-actions.test.ts @@ -0,0 +1,110 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useSessionRecoveryActions } from "./use-session-recovery-actions"; + +const mocks = vi.hoisted(() => ({ + requestSessionRecover: vi.fn(), + restoreSessionWorkspace: vi.fn(), +})); + +vi.mock("@/lib/services/session-recovery-service", () => ({ + asRecoveryError: (error: unknown, fallback: string) => + error instanceof Error ? error : new Error(fallback), + branchRecoveryDetails: () => null, + requestSessionRecover: mocks.requestSessionRecover, + restoreSessionWorkspace: mocks.restoreSessionWorkspace, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, values?: Record) => + values ? `${key}:${JSON.stringify(values)}` : key, + }), +})); + +const TASK_ID = "task-1"; +const SESSION_ID = "session-1"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("useSessionRecoveryActions", () => { + it("clears busy and error state after a successful recovery", async () => { + mocks.requestSessionRecover.mockResolvedValueOnce(undefined); + const { result } = renderHook(() => + useSessionRecoveryActions({ taskId: TASK_ID, sessionId: SESSION_ID }), + ); + + let success: boolean | undefined; + await act(async () => { + success = await result.current.handleRecover("resume"); + }); + + expect(success).toBe(true); + expect(result.current.busyAction).toBeNull(); + expect(result.current.recoveryError).toBeNull(); + expect(mocks.requestSessionRecover).toHaveBeenCalledWith( + TASK_ID, + SESSION_ID, + "resume", + "task:failedToResumeSession", + ); + }); + + it("retains a failed action for retry and releases its busy state", async () => { + mocks.requestSessionRecover + .mockRejectedValueOnce(new Error("provider unavailable")) + .mockResolvedValueOnce(undefined); + const { result } = renderHook(() => + useSessionRecoveryActions({ taskId: TASK_ID, sessionId: SESSION_ID }), + ); + + await act(async () => { + await result.current.handleRecover("resume"); + }); + + expect(result.current.recoveryError?.message).toBe("provider unavailable"); + expect(result.current.busyAction).toBeNull(); + + await act(async () => { + await result.current.handleRetry(); + }); + + expect(mocks.requestSessionRecover).toHaveBeenNthCalledWith( + 2, + TASK_ID, + SESSION_ID, + "resume", + "task:failedToResumeSession", + ); + expect(result.current.recoveryError).toBeNull(); + expect(result.current.busyAction).toBeNull(); + }); + + it("ignores an old response after the selected session changes", async () => { + const deferred = Promise.withResolvers(); + mocks.requestSessionRecover.mockReturnValueOnce(deferred.promise); + const { result, rerender } = renderHook( + ({ sessionId }: { sessionId: string }) => + useSessionRecoveryActions({ taskId: TASK_ID, sessionId }), + { initialProps: { sessionId: SESSION_ID } }, + ); + + act(() => { + void result.current.handleRecover("resume"); + }); + expect(result.current.busyAction).toBe("resume"); + + rerender({ sessionId: "session-2" }); + await waitFor(() => expect(result.current.busyAction).toBeNull()); + + await act(async () => { + deferred.resolve(); + await deferred.promise; + }); + + expect(result.current.recoveryError).toBeNull(); + expect(result.current.busyAction).toBeNull(); + }); +}); diff --git a/apps/web/hooks/domains/session/use-session-recovery-actions.ts b/apps/web/hooks/domains/session/use-session-recovery-actions.ts index d169bab961..f9400a750c 100644 --- a/apps/web/hooks/domains/session/use-session-recovery-actions.ts +++ b/apps/web/hooks/domains/session/use-session-recovery-actions.ts @@ -1,4 +1,5 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import { asRecoveryError, @@ -16,9 +17,30 @@ type SessionRecoveryActionsOptions = { sessionId: string; }; +function combineRecoveryErrors( + resumeError: Error | null, + restoreError: Error | null, + translate: TFunction, +): Error | null { + if (!resumeError || !restoreError) return restoreError ?? resumeError; + return new Error( + translate("task:resumeAndRestoreFailed", { + resumeError: resumeError.message, + restoreError: restoreError.message, + }), + ); +} + /** Owns shared manual recovery state while a failed session remains visible. */ export function useSessionRecoveryActions({ taskId, sessionId }: SessionRecoveryActionsOptions) { const { t } = useTranslation(); + const requestKey = `${taskId}\u0000${sessionId}`; + const activeRequestKeyRef = useRef(requestKey); + const operationGenerationRef = useRef(0); + if (activeRequestKeyRef.current !== requestKey) { + activeRequestKeyRef.current = requestKey; + operationGenerationRef.current += 1; + } const [busyAction, setBusyAction] = useState(null); const [resumeError, setResumeError] = useState(null); const [restoreError, setRestoreError] = useState(null); @@ -26,29 +48,46 @@ export function useSessionRecoveryActions({ taskId, sessionId }: SessionRecovery const [lastFailedAction, setLastFailedAction] = useState(null); const [recoveryNotice, setRecoveryNotice] = useState(null); - const recoveryError = useMemo(() => { - if (resumeError && restoreError) { - return new Error( - t("task:resumeAndRestoreFailed", { - resumeError: resumeError.message, - restoreError: restoreError.message, - }), - ); - } - return restoreError ?? resumeError; - }, [restoreError, resumeError, t]); + useEffect(() => { + setBusyAction(null); + setResumeError(null); + setRestoreError(null); + setBranchDetails(null); + setLastFailedAction(null); + setRecoveryNotice(null); + }, [requestKey]); + + const beginOperation = useCallback( + (action: SessionRecoveryBusyAction) => { + const operationId = ++operationGenerationRef.current; + setBusyAction(action); + return { requestKey, operationId }; + }, + [requestKey], + ); + + const isCurrentOperation = useCallback( + (operation: { requestKey: string; operationId: number }) => + activeRequestKeyRef.current === operation.requestKey && + operationGenerationRef.current === operation.operationId, + [], + ); + + const recoveryError = combineRecoveryErrors(resumeError, restoreError, t); const handleRecover = useCallback( async (action: SessionRecoveryAction) => { - setBusyAction(action); + const operation = beginOperation(action); try { await requestSessionRecover(taskId, sessionId, action, t("task:failedToResumeSession")); + if (!isCurrentOperation(operation)) return false; setResumeError(null); setRestoreError(null); setBranchDetails(null); setLastFailedAction(null); setRecoveryNotice(null); } catch (cause) { + if (!isCurrentOperation(operation)) return false; setResumeError(asRecoveryError(cause, t("task:failedToResumeSession"))); setRestoreError(null); setBranchDetails(branchRecoveryDetails(cause)); @@ -56,30 +95,32 @@ export function useSessionRecoveryActions({ taskId, sessionId }: SessionRecovery setRecoveryNotice(null); return false; } finally { - setBusyAction(null); + if (isCurrentOperation(operation)) setBusyAction(null); } return true; }, - [sessionId, taskId, t], + [beginOperation, isCurrentOperation, sessionId, taskId, t], ); const handleRestore = useCallback(async () => { - setBusyAction("restore"); + const operation = beginOperation("restore"); setRestoreError(null); try { await restoreSessionWorkspace(taskId, sessionId, t("task:failedToRestoreWorkspace")); + if (!isCurrentOperation(operation)) return; setResumeError(null); setRestoreError(null); setBranchDetails(null); setLastFailedAction(null); setRecoveryNotice(t("task:resumeFailedWorkspaceReadOnly")); } catch (cause) { + if (!isCurrentOperation(operation)) return; setRestoreError(asRecoveryError(cause, t("task:failedToRestoreWorkspace"))); setRecoveryNotice(null); } finally { - setBusyAction(null); + if (isCurrentOperation(operation)) setBusyAction(null); } - }, [resumeError, sessionId, taskId, t]); + }, [beginOperation, isCurrentOperation, sessionId, taskId, t]); const handleRetry = useCallback(() => { return handleRecover(lastFailedAction ?? "resume"); diff --git a/apps/web/hooks/domains/session/use-session-resumption-operations.ts b/apps/web/hooks/domains/session/use-session-resumption-operations.ts index 4d55ef527d..f5d43b6260 100644 --- a/apps/web/hooks/domains/session/use-session-resumption-operations.ts +++ b/apps/web/hooks/domains/session/use-session-resumption-operations.ts @@ -404,6 +404,9 @@ export type ResumeAction = "running" | "skip" | "resume" | "restore" | "idle"; export function decideResumeAction(status: SessionStatus, preventAutoStart: boolean): ResumeAction { if (status.is_agent_running) return "running"; + // Completed sessions remain passive until the user explicitly chooses the + // completed-chat Resume action. Open-time recovery must not revive them. + if (status.state === "COMPLETED") return "idle"; if (preventAutoStart && status.needs_resume && status.is_resumable) return "skip"; if (status.needs_resume && status.is_resumable) return "resume"; if (status.needs_workspace_restore) return "restore"; diff --git a/apps/web/hooks/domains/session/use-session-resumption.test.ts b/apps/web/hooks/domains/session/use-session-resumption.test.ts index a51deea24e..e97658570d 100644 --- a/apps/web/hooks/domains/session/use-session-resumption.test.ts +++ b/apps/web/hooks/domains/session/use-session-resumption.test.ts @@ -709,6 +709,47 @@ describe("useSessionResumption monotonic terminal hydration", () => { }); }); +describe("useSessionResumption completed-session admission", () => { + it.each([false, true])( + "does not auto-resume a completed session when preventAutoStart is %s", + async (preventAutoStart) => { + vi.clearAllMocks(); + mockConnectionStatus = "connected"; + mockPreventAutoStart = preventAutoStart; + mockSessionItems = { + s1: { + started_at: STARTED_AT, + updated_at: LATER_AT, + state: "COMPLETED", + }, + }; + mockRequest.mockResolvedValueOnce({ + session_id: SESSION_ID, + task_id: TASK_ID, + state: "COMPLETED", + is_agent_running: false, + is_resumable: true, + needs_resume: true, + updated_at: LATER_AT, + }); + + renderHook(() => useSessionResumption(TASK_ID, SESSION_ID)); + + await waitFor(() => { + expect(mockRequest).toHaveBeenCalledWith(STATUS_ACTION, { + task_id: TASK_ID, + session_id: SESSION_ID, + }); + }); + expect(mockRequest).not.toHaveBeenCalledWith( + LAUNCH_ACTION, + expect.anything(), + expect.anything(), + ); + }, + ); +}); + describe("useSessionResumption resume-skipped clearing on running status", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/apps/web/lib/api/domains/workflow-api.test.ts b/apps/web/lib/api/domains/workflow-api.test.ts index d69d8a55d7..8e5a350225 100644 --- a/apps/web/lib/api/domains/workflow-api.test.ts +++ b/apps/web/lib/api/domains/workflow-api.test.ts @@ -27,11 +27,12 @@ describe("normalizeWorkflowTemplate", () => { agent_profile_id: "profile-a", profile_session_start_policy: "new", profile_session_end_policy: "park", + complete_task_on_enter: false, events: { on_turn_complete: [{ type: "move_to_step", config: { step_id: "review" } }], }, }, - { id: "review", name: "Review", position: 1 }, + { id: "review", name: "Review", position: 1, complete_task_on_enter: true }, ], }); @@ -60,6 +61,7 @@ describe("createWorkflowStep", () => { agent_profile_id: "profile-a", profile_session_start_policy: "new", profile_session_end_policy: "park", + complete_task_on_enter: false, cancel_triggers_turn_complete: true, }; await createWorkflowStep(payload, { baseUrl: "http://api.test" }); @@ -82,6 +84,7 @@ describe("createWorkflowStep", () => { color: "", profile_session_start_policy: "unsupported", profile_session_end_policy: "unsupported", + complete_task_on_enter: false, }), { status: 200, @@ -102,5 +105,6 @@ describe("createWorkflowStep", () => { expect(step.profile_session_start_policy).toBe("reuse"); expect(step.profile_session_end_policy).toBe("complete"); + expect(step.complete_task_on_enter).toBe(false); }); }); diff --git a/apps/web/lib/api/domains/workflow-api.ts b/apps/web/lib/api/domains/workflow-api.ts index cd767ea2da..6964ef5e7e 100644 --- a/apps/web/lib/api/domains/workflow-api.ts +++ b/apps/web/lib/api/domains/workflow-api.ts @@ -26,6 +26,7 @@ type BackendTemplateStep = { agent_profile_id?: string; profile_session_start_policy?: StepDefinition["profile_session_start_policy"]; profile_session_end_policy?: StepDefinition["profile_session_end_policy"]; + complete_task_on_enter?: boolean; auto_advance_requires_signal?: boolean; cancel_triggers_turn_complete?: boolean; wip_limit?: number; @@ -55,6 +56,7 @@ export const normalizeWorkflowTemplate = (template: BackendWorkflowTemplate): Wo profile_session_end_policy: normalizeWorkflowProfileSessionEndPolicy( step.profile_session_end_policy, ), + complete_task_on_enter: step.complete_task_on_enter, auto_advance_requires_signal: step.auto_advance_requires_signal, cancel_triggers_turn_complete: step.cancel_triggers_turn_complete, wip_limit: step.wip_limit, @@ -129,6 +131,8 @@ export async function createWorkflowStep( color?: string; prompt?: string; events?: StepEvents; + complete_task_on_enter?: boolean; + auto_advance_requires_signal?: boolean; cancel_triggers_turn_complete?: boolean; agent_profile_id?: string; profile_session_start_policy?: StepDefinition["profile_session_start_policy"]; diff --git a/apps/web/lib/ssr/mapper.test.ts b/apps/web/lib/ssr/mapper.test.ts index 054faf20a4..b5f8911fb0 100644 --- a/apps/web/lib/ssr/mapper.test.ts +++ b/apps/web/lib/ssr/mapper.test.ts @@ -31,6 +31,9 @@ function snapshotWithPendingAction(action: unknown): WorkflowSnapshot { position: 0, color: "bg-neutral-400", allow_manual_move: true, + complete_task_on_enter: false, + auto_advance_requires_signal: false, + cancel_triggers_turn_complete: false, }, ], tasks: [ diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index d29b097433..c7509cb8c0 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -134,6 +134,7 @@ export type StepDefinition = { route_state?: string; route_reason?: string; downstream_acp_session_id?: string; + complete_task_on_enter?: boolean; auto_advance_requires_signal?: boolean; cancel_triggers_turn_complete?: boolean; wip_limit?: number; @@ -156,6 +157,7 @@ export type WorkflowStep = { agent_profile_id?: string; profile_session_start_policy?: WorkflowProfileSessionStartPolicy; profile_session_end_policy?: WorkflowProfileSessionEndPolicy; + complete_task_on_enter?: boolean; wip_limit?: number; pull_from_step_id?: string | null; /** @@ -528,6 +530,9 @@ export type WorkflowStepDTO = { auto_advance_requires_signal?: boolean; wip_limit?: number; pull_from_step_id?: string | null; + complete_task_on_enter: boolean; + auto_advance_requires_signal: boolean; + cancel_triggers_turn_complete: boolean; created_at?: string; updated_at?: string; }; @@ -985,6 +990,7 @@ export type StepPortable = { agent_profile?: AgentProfilePortable; profile_session_start_policy?: WorkflowProfileSessionStartPolicy; profile_session_end_policy?: WorkflowProfileSessionEndPolicy; + complete_task_on_enter: boolean; auto_advance_requires_signal: boolean; cancel_triggers_turn_complete: boolean; wip_limit?: number; diff --git a/apps/web/lib/ws/handlers/workflows.ts b/apps/web/lib/ws/handlers/workflows.ts index bf548c6e3a..54462dd46d 100644 --- a/apps/web/lib/ws/handlers/workflows.ts +++ b/apps/web/lib/ws/handlers/workflows.ts @@ -33,6 +33,9 @@ function stepFromPayload(step: any) { wip_limit: step.wip_limit, pull_from_step_id: step.pull_from_step_id ?? null, stage_type: step.stage_type, + complete_task_on_enter: step.complete_task_on_enter, + auto_advance_requires_signal: step.auto_advance_requires_signal, + cancel_triggers_turn_complete: step.cancel_triggers_turn_complete, }; } diff --git a/apps/web/src/locales/en/workflows.json b/apps/web/src/locales/en/workflows.json index 7e1dae2eb6..3bffe453fd 100644 --- a/apps/web/src/locales/en/workflows.json +++ b/apps/web/src/locales/en/workflows.json @@ -62,6 +62,9 @@ "deleteWorkflowWithTasks_other": "This workflow has {{count}} tasks. Choose where to migrate them, or delete the workflow and archive the tasks.", "directoryLabel": "Directory", "directoryLine": "Directory {{path}}", + "completeTaskOnEnter": "Complete task when entering final step", + "completeTaskOnEnterHelp": "Marks the task complete when it enters this final step. You can still continue the conversation.", + "completeTaskOnEnterHelpAria": "More information about completing the task on the final step", "disablePlanMode": "Disable plan mode", "disablePlanModeOnComplete": "Disable plan mode on complete", "disablePlanModeOnCompleteHelp": "Turn off plan mode after the agent finishes a turn, even when the task remains in this step.", diff --git a/apps/web/src/locales/pseudo/workflows.json b/apps/web/src/locales/pseudo/workflows.json index d68022d4ad..b25f1a4b58 100644 --- a/apps/web/src/locales/pseudo/workflows.json +++ b/apps/web/src/locales/pseudo/workflows.json @@ -62,6 +62,9 @@ "deleteWorkflowWithTasks_other": "Ţĥĩś ŵōŕķƒĺōŵ ĥàś {{count}} ţàśķś. Ćĥōōśē ŵĥēŕē ţō ḿĩĝŕàţē ţĥēḿ, ōŕ ďēĺēţē ţĥē ŵōŕķƒĺōŵ àńď àŕćĥĩvē ţĥē ţàśķś.", "directoryLabel": "Ďĩŕēćţōŕŷ", "directoryLine": "Ďĩŕēćţōŕŷ {{path}}", + "completeTaskOnEnter": "Ćōḿƥĺēţē ţàśķ ŵĥēń ēńţēŕĩńĝ ƒĩńàĺ śţēƥ", + "completeTaskOnEnterHelp": "Ḿàŕķś ţĥē ţàśķ ćōḿƥĺēţē ŵĥēń ĩţ ēńţēŕś ţĥĩś ƒĩńàĺ śţēƥ. Ŷōũ ćàń śţĩĺĺ ćōńţĩńũē ţĥē ćōńvēŕśàţĩōń.", + "completeTaskOnEnterHelpAria": "Ḿōŕē ĩńƒōŕḿàţĩōń àƀōũţ ćōḿƥĺēţĩńĝ ţĥē ţàśķ ōń ţĥē ƒĩńàĺ śţēƥ", "disablePlanMode": "Ďĩśàƀĺē ƥĺàń ḿōďē", "disablePlanModeOnComplete": "Ďĩśàƀĺē ƥĺàń ḿōďē ōń ćōḿƥĺēţē", "disablePlanModeOnCompleteHelp": "Ţũŕń ōƒƒ ƥĺàń ḿōďē àƒţēŕ ţĥē àĝēńţ ƒĩńĩśĥēś à ţũŕń, ēvēń ŵĥēń ţĥē ţàśķ ŕēḿàĩńś ĩń ţĥĩś śţēƥ.", diff --git a/apps/web/src/locales/pt-pt/workflows.json b/apps/web/src/locales/pt-pt/workflows.json index 7a20b25cf1..82a1390e69 100644 --- a/apps/web/src/locales/pt-pt/workflows.json +++ b/apps/web/src/locales/pt-pt/workflows.json @@ -62,6 +62,9 @@ "deleteWorkflowWithTasks_other": "Este fluxo de trabalho tem {{count}} tarefas. Escolha para onde migrá-las ou elimine o fluxo de trabalho e arquive as tarefas.", "directoryLabel": "Diretório", "directoryLine": "Diretório {{path}}", + "completeTaskOnEnter": "Concluir a tarefa ao entrar no passo final", + "completeTaskOnEnterHelp": "Marca a tarefa como concluída quando entra neste passo final. Pode continuar a conversa.", + "completeTaskOnEnterHelpAria": "Mais informações sobre concluir a tarefa no passo final", "disablePlanMode": "Desativar o modo de plano", "disablePlanModeOnComplete": "Desativar o modo de plano ao concluir", "disablePlanModeOnCompleteHelp": "Desliga o modo de plano depois de o agente terminar um turno, mesmo que a tarefa permaneça neste passo.", diff --git a/apps/web/src/locales/zh-cn/workflows.json b/apps/web/src/locales/zh-cn/workflows.json index c6e2d3980c..7e19d22107 100644 --- a/apps/web/src/locales/zh-cn/workflows.json +++ b/apps/web/src/locales/zh-cn/workflows.json @@ -62,6 +62,9 @@ "deleteWorkflowWithTasks_other": "此工作流有 {{count}} 个任务。选择迁移目标,或删除工作流并归档任务。", "directoryLabel": "目录", "directoryLine": "目录 {{path}}", + "completeTaskOnEnter": "进入最后一步时完成任务", + "completeTaskOnEnterHelp": "任务进入此最后一步时将其标记为已完成。你仍然可以继续对话。", + "completeTaskOnEnterHelpAria": "有关在最后一步完成任务的更多信息", "disablePlanMode": "禁用计划模式", "disablePlanModeOnComplete": "完成时禁用计划模式", "disablePlanModeOnCompleteHelp": "智能体完成一轮后关闭计划模式,即使任务仍留在此步骤。", diff --git a/apps/web/src/locales/zh-hk/settings.json b/apps/web/src/locales/zh-hk/settings.json index 9bb8659bc8..9c9f972c1b 100644 --- a/apps/web/src/locales/zh-hk/settings.json +++ b/apps/web/src/locales/zh-hk/settings.json @@ -768,7 +768,7 @@ "keyValueAddItem": "新增條目", "keyValueKeyPlaceholder": "鍵", "keyValueValuePlaceholder": "值", - "modelVariationAdvisory": "主機目錄中有一個可能的 {{model}} 變體:{{variation}}。已儲存的模型在啟動前保持不變。", + "modelVariationAdvisory": "主機目錄中有一個可能的 {{model}} 變體:{{variation}}。儲存的模型在啓動前保持不變。", "navUnits": "組織單元", "unitsTitle": "組織單元", "unitsDescription": "單元包含團隊、工作區和成員。加入某個單元即可存取其下的所有工作區,包括之後建立的。", diff --git a/apps/web/src/locales/zh-hk/task.json b/apps/web/src/locales/zh-hk/task.json index 616d21f36d..3f8681296d 100644 --- a/apps/web/src/locales/zh-hk/task.json +++ b/apps/web/src/locales/zh-hk/task.json @@ -1909,11 +1909,11 @@ "failedToResumeAndRestore": "恢復工作階段失敗 - 工作區還原同樣不可用", "resumeAndRestoreFailed": "恢復失敗:{{resumeError}}。還原工作區失敗:{{restoreError}}。", "sessionRecoveryFailed": "工作階段恢復失敗", - "sessionRecoveryFailedDetail": "無法恢復此工作階段。", - "sessionRecoveryDetails": "恢復詳細資料", + "sessionRecoveryFailedDetail": "無法恢復該工作階段。", + "sessionRecoveryDetails": "恢復詳情", "sessionRecoveryResumeAttempt": "恢復嘗試", "sessionRecoveryRestoreAttempt": "工作區還原嘗試", - "resumeFailedWorkspaceReadOnly": "工作區已以唯讀模式還原", + "resumeFailedWorkspaceReadOnly": "工作區已以只讀模式還原", "continueOnNewBranch": "在新分支上繼續", "restoreReadOnlyWorkspace": "還原只讀工作區", "gitOperationFailedGeneric": "操作失敗", diff --git a/apps/web/src/locales/zh-hk/workflows.json b/apps/web/src/locales/zh-hk/workflows.json index de0f1c000e..fafa48e27b 100644 --- a/apps/web/src/locales/zh-hk/workflows.json +++ b/apps/web/src/locales/zh-hk/workflows.json @@ -62,6 +62,9 @@ "deleteWorkflowWithTasks_other": "此工作流程有 {{count}} 個任務。選擇遷移目標,或刪除工作流程並歸檔任務。", "directoryLabel": "目錄", "directoryLine": "目錄 {{path}}", + "completeTaskOnEnter": "進入最後一步時完成任務", + "completeTaskOnEnterHelp": "任務進入此最後一步時將其標記為已完成。你仍然可以繼續對話。", + "completeTaskOnEnterHelpAria": "有關在最後一步完成任務的更多資訊", "disablePlanMode": "停用計劃模式", "disablePlanModeOnComplete": "完成時停用計劃模式", "disablePlanModeOnCompleteHelp": "代理程式完成一輪後關閉計劃模式,即使任務仍留在此步驟。", diff --git a/apps/web/src/locales/zh-tw/settings.json b/apps/web/src/locales/zh-tw/settings.json index 1a52cab623..eff46d1ae3 100644 --- a/apps/web/src/locales/zh-tw/settings.json +++ b/apps/web/src/locales/zh-tw/settings.json @@ -768,7 +768,7 @@ "keyValueAddItem": "新增條目", "keyValueKeyPlaceholder": "鍵", "keyValueValuePlaceholder": "值", - "modelVariationAdvisory": "主機目錄中有一個可能的 {{model}} 變體:{{variation}}。已儲存的模型在啟動前保持不變。", + "modelVariationAdvisory": "主機目錄中有一個可能的 {{model}} 變體:{{variation}}。儲存的模型在啟動前保持不變。", "navUnits": "組織單元", "unitsTitle": "組織單元", "unitsDescription": "單元包含團隊、工作區和成員。加入某個單元即可存取其下的所有工作區,包括之後建立的。", diff --git a/apps/web/src/locales/zh-tw/task.json b/apps/web/src/locales/zh-tw/task.json index 1a270b4314..a8ba83c478 100644 --- a/apps/web/src/locales/zh-tw/task.json +++ b/apps/web/src/locales/zh-tw/task.json @@ -1909,11 +1909,11 @@ "failedToResumeAndRestore": "恢復工作階段失敗 - 工作區還原同樣不可用", "resumeAndRestoreFailed": "恢復失敗:{{resumeError}}。還原工作區失敗:{{restoreError}}。", "sessionRecoveryFailed": "工作階段恢復失敗", - "sessionRecoveryFailedDetail": "無法恢復此工作階段。", - "sessionRecoveryDetails": "恢復詳細資料", + "sessionRecoveryFailedDetail": "無法恢復該工作階段。", + "sessionRecoveryDetails": "恢復詳情", "sessionRecoveryResumeAttempt": "恢復嘗試", "sessionRecoveryRestoreAttempt": "工作區還原嘗試", - "resumeFailedWorkspaceReadOnly": "工作區已以唯讀模式還原", + "resumeFailedWorkspaceReadOnly": "工作區已以只讀模式還原", "continueOnNewBranch": "在新分支上繼續", "restoreReadOnlyWorkspace": "還原只讀工作區", "gitOperationFailedGeneric": "操作失敗", diff --git a/apps/web/src/locales/zh-tw/workflows.json b/apps/web/src/locales/zh-tw/workflows.json index 9e229516ba..bb1fb47723 100644 --- a/apps/web/src/locales/zh-tw/workflows.json +++ b/apps/web/src/locales/zh-tw/workflows.json @@ -62,6 +62,9 @@ "deleteWorkflowWithTasks_other": "此工作流程有 {{count}} 個任務。選擇遷移目標,或刪除工作流程並歸檔任務。", "directoryLabel": "目錄", "directoryLine": "目錄 {{path}}", + "completeTaskOnEnter": "進入最後一步時完成任務", + "completeTaskOnEnterHelp": "任務進入此最後一步時將其標記為已完成。你仍然可以繼續對話。", + "completeTaskOnEnterHelpAria": "有關在最後一步完成任務的更多資訊", "disablePlanMode": "停用計劃模式", "disablePlanModeOnComplete": "完成時停用計劃模式", "disablePlanModeOnCompleteHelp": "代理程式完成一輪後關閉計劃模式,即使任務仍留在此步驟。", diff --git a/docs/decisions/2026-09-09-task-completion-conversation-availability.md b/docs/decisions/2026-09-09-task-completion-conversation-availability.md new file mode 100644 index 0000000000..0df396cc6e --- /dev/null +++ b/docs/decisions/2026-09-09-task-completion-conversation-availability.md @@ -0,0 +1,74 @@ +# ADR-2026-09-09-task-completion-conversation-availability: Separate task completion from conversation availability + +**Status:** accepted +**Date:** 2026-09-09 +**Area:** workflow + +## Context + +Task completion currently depends on a final step's name. Child terminal +receipts can also complete a session, after which the composer and backend +resume path refuse follow-ups. These decisions conflate workflow outcome, +resource lifetime, and conversation access. + +The requested product contract makes task completion explicit and permits +manual continuation of completed chats. Historical profile sessions also need +this access without taking over the active workflow. + +## Decision + +Use `complete_task_on_enter` as the authoritative step setting. Preserve legacy +definitions through one-time database backfill and version-1 portable conversion. +Version-2 exports require explicit settings. Only the final step is eligible +to complete work, and its setting must be enabled. Names have no completion +authority; final position alone does not complete work. + +The editor shows the checkbox only on the final step, with its explanation +behind an adjacent info icon. Reordering retains each step's stored choice, +but a non-final step's choice is inactive. The new final step uses its own value. + +Task completion is durable workflow state. A chat follow-up does not reopen the +task or replay workflow actions. Explicit moves and task-state operations keep +their existing authority. Parent and dependency processing use persisted task +outcomes, not inferred step membership. + +Keep completed sessions closed to automatic start and reuse. Permit explicit +Resume or a pinned follow-up through a guarded lifecycle transition that retains +conversation identity. Retired profile conversations remain outside workflow +ownership after manual resume. Runtime cleanup preserves the data needed to +recover the same conversation. + +## Consequences + +Users can ask follow-up questions without creating another conversation. +Workflow authors can rename steps without changing task completion. Reordering +changes final-step eligibility without transferring stored completion choices. +The migration and portable version boundary preserve existing definitions while +making false an unambiguous saved choice. + +Resume must coordinate stale callbacks, cleanup, queue admission, and ownership. +The change does not broaden FAILED/CANCELLED message admission or bypass Office +scheduling, archive, credentials, or workspace-recovery constraints. + +This amends the historical-endpoint language in the profile-session design: +completion ends automatic workflow reuse, but permits explicit conversation work. +Public workflow examples that reopen Done on any message must be updated when +the implementation ships. + +## Alternatives considered + +- Add only a Resume button: insufficient because backend terminal guards reject + it and cleanup may have removed the executor record. +- Remove COMPLETED from every terminal-state helper: rejected because late + events and automatic starts would gain authority to revive old executions. +- Keep all child runtimes alive: rejected because chat continuity does not + require a permanent process or provider reservation. +- Infer completion from names as a fallback forever: rejected because renaming + a step or saving false would remain ambiguous. +- Reopen tasks automatically on follow-up: rejected because the requested + conversation continuation must preserve workflow state and notifications. + +## Specifications + +- [Task completion requirements](../specs/tasks/requirements/task-completion.md) +- [Task completion system design](../specs/tasks/system-design/task-completion.md) diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index b4d7ee436e..cdc10b43b3 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -260,4 +260,5 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 2026-09-05-trusted-browser-html-preview | [Treat HTML Preview as Trusted Workspace Code](2026-09-05-trusted-browser-html-preview.md) | accepted | frontend, backend, agentctl, security | 2026-09-05 | | 2026-09-07-activate-managed-runtime-defaults | [Activate Shipped Managed Runtime Defaults](2026-09-07-activate-managed-runtime-defaults.md) | accepted | backend, frontend, protocol | 2026-09-07 | | 2026-09-08-domain-owned-settings-catalog | [Discover settings through domain-owned contracts](2026-09-08-domain-owned-settings-catalog.md) | accepted | backend, frontend, protocol | 2026-09-08 | +| 2026-09-09-task-completion-conversation-availability | [Separate task completion from conversation availability](2026-09-09-task-completion-conversation-availability.md) | accepted | workflow | 2026-09-09 | | 2026-09-09-count-application-files-for-pr-size | [Count Application Files for Pull Request Size](2026-09-09-count-application-files-for-pr-size.md) | accepted | workflow, infra | 2026-09-09 | diff --git a/docs/plans/task-completion/plan.md b/docs/plans/task-completion/plan.md new file mode 100644 index 0000000000..fe87b7a85d --- /dev/null +++ b/docs/plans/task-completion/plan.md @@ -0,0 +1,197 @@ +--- +created: 2026-09-09 +status: complete +requirements: + - REQ-TASKS-COMPLETION-001 + - REQ-TASKS-COMPLETION-002 +system_design: + - ../../specs/tasks/system-design/task-completion.md +legacy_specs: [] +--- + +# Implementation Plan: Task completion and conversation follow-ups + +## Overview + +Make workflow completion explicit and restore follow-ups in completed chats. +Deliver persistence, portable contracts, runtime completion, guarded resume, +workflow editing, then chat UI. Execute sequentially with `/tdd`; `/mobile-parity` +and focused `/e2e` apply to both UI work orders. No delegation is authorized. + +The task system owns this package because it owns durable workflow and session +lifecycle contracts. The user supplied the intended behavior; it supersedes the +old name-based completion and permanent completed-chat lock. + +UX refinement: show the completion checkbox only on the final step and put its +description behind an info icon. Completion requires final position plus the +enabled setting. Retain per-step values across reorder, with non-final values +inactive. Cover hover/focus on desktop and tap on mobile. + +## Confirmed cause and reproduction + +The reported Contributor PR Review child reached its final workflow step. +The remote task URL could not be opened in this environment. Local evidence: + +- `workflow/models/terminal.go` requires no next step and a trimmed, + case-insensitive Done/Complete/Completed/Approved name. +- `event_handlers_children_completed.go` writes task COMPLETED and uses + inferred step membership for parent rollups. +- `setSessionWaitingForInputIfRequestedWithHook` in + `event_handlers_streaming.go` collapses a terminal child without active + clarification to COMPLETED. Root sessions take the waiting branch. +- `ResumeTaskSessionWithOptions` rejects COMPLETED. Its missing-executor guard + also excludes completed sessions after resource reclamation. +- `SessionStoppedBanner` completed mode renders only New Agent. MCP target + resolution excludes completed sessions from live targets; turn-start rejects + them through the orchestrator's terminal guard. + +Smallest existing reproduction: seed a completed child task with a session and +executor, emit `agent.completed`, then inspect the session. The paired root +test settles to WAITING_FOR_INPUT. Existing tests pass because they pin the old +behavior; permanent regression tests for the new contract begin in implementation. + +## Scope + +### In scope + +- `complete_task_on_enter` across persistence, migration, APIs, portable formats, + sync, built-ins, bootstrap, duplication, state delivery, and the editor. +- Root/child completion consistency and parent/dependency notification accuracy. +- Explicit same-session Resume, preserved history/ownership, ordered follow-ups, + cleanup races, and desktop/mobile recovery UI. +- Owning specifications, compatibility amendments, ADR, and public docs at ship. + +### Out of scope + +- Changing FAILED/CANCELLED ordinary-message admission or their existing + dedicated manual recovery behavior. +- Office scheduler ownership, archive/delete policy, provider conversation + protocols, new queue types, or new workflow triggers. +- Mutating the remote reproduction task or deploying this change. + +## Technical approach + +Follow [the system design](../../specs/tasks/system-design/task-completion.md). +Add a non-null boolean column and a transactional, one-time legacy backfill. +Version-2 portable definitions carry explicit booleans; version-1 compatibility +conversion runs at import/sync only. Both schema constructors and every writer +must agree before runtime predicates change. + +Use persisted task state for downstream completion. Retain ordinary completed +task conversations in a promptable state and keep fail-closed idle reclamation. +Permit old completed chats through explicit recovery with expected-state and +execution ownership checks. Keep automatic terminal guards. Retired conversations +retain a durable follow-up marker so resuming cannot claim active workflow work. + +## Tests + +The work orders name full paths and exact commands. New regression names below +are proposed tests, not tests already run. + +| Acceptance criteria | Regression evidence | +| --- | --- | +| `001.5`, `001.6`, `001.7` | `completion_policy_test.go`: `TestCompletionPolicyMigration`, `TestCompletionPolicyReplayPreservesDisabled`, `TestCompletionPolicyMigrationRollback`; required-store fresh/replay/upgrade on both dialects | +| `001.4`, `001.7`, `001.8` | workflow `completion_portable_test.go`: `TestCompletionPortableVersions`, `TestCompletionSyncPreservesFalse`; controller, MCP, DTO and step-event round trips | +| `001.2`, `001.3`, `001.9`–`001.12` | orchestrator `workflow_completion_policy_test.go`: `TestCompletionPolicyEntryPaths`, `TestCompletionPolicyNotifications`; task-service move and dependency tests | +| `002.5`, `002.10`, `002.13` | updated `event_handlers_subtask_waiting_test.go` and `event_handlers_reclaim_wiring_test.go`, including active clarification and reclaim barriers | +| `002.2`–`002.4`, `002.6`–`002.13` | `completed_session_resume_test.go`: `TestCompletedSessionResumePreservesConversation`, `TestCompletedSessionResumeRaces`, `TestCompletedSessionFollowUpOwnership`; MCP dispatch and executor tests | +| `001.1`, `001.4`, `001.7`, `001.13` | workflow draft/create/duplicate/API/WS unit tests and workflow E2E | +| `002.1`–`002.4`, `002.7`, `002.9`, `002.11`, `002.12` | recovery action/input mode/resumption unit tests and completed-chat E2E | + +Criterion suffixes refer to `AC-TASKS-COMPLETION`. + +## E2E tests + +- `tests/workflow/workflow-task-completion.spec.ts`, project `chromium`: + toggle/save/reload, checked final step, unchecked final Done, rename/reorder, + hidden controls on non-final steps, info-icon disclosure, + and same-conversation follow-up. Covers `001.1`–`001.4`, `001.7`, `001.13`. +- `tests/workflow/mobile-workflow-task-completion.spec.ts`, `mobile-chrome`: + tap the setting, save/reload, perform the same completed/unchecked flows, + assert 44 px touch label, tap-to-open help, final-step-only visibility, and + no horizontal overflow. Same criteria. +- `tests/session/completed-session-resume.spec.ts`, `chromium`: + root and child histories, passive open/reload, Resume, second response in the + same session, failed resume feedback, and non-primary historical ownership. + Covers `002.1`–`002.5`, `002.9`, `002.12`. +- `tests/session/mobile-completed-session-resume.spec.ts`, `mobile-chrome`: + Resume and send by touch, preserved history and task state, no overflow, + visible New Agent, and safe-area/touch geometry. Same criteria. +- Run the neighbouring recovery, resume-queue, workflow-agent-switch, and + child-completion specs named in work orders to protect existing semantics. + +Seed with existing API fixtures, assert user outcomes through the UI, and use +causal waits. Rebuild production assets through the managed runner. Keep one +worker per shard, run desktop/mobile separately, and do not increase timeouts. + +## Work orders + +- [x] [Task 01: Persist completion settings](task-01-persistence.md) (done) +- [x] [Task 02: Carry portable completion settings](task-02-portable-contracts.md) (done) +- [x] [Task 03: Apply configured task completion](task-03-runtime-completion.md) (done) +- [x] [Task 04: Resume completed conversations safely](task-04-session-resume.md) (done) +- [x] [Task 05: Expose workflow completion settings](task-05-workflow-editor.md) (done) +- [x] [Task 06: Restore completed-chat interaction](task-06-chat-recovery.md) (done) + +## Verification results + +Design investigation on 2026-09-09, from `apps/backend`: + +```bash +rtk go test ./internal/workflow/models -run TestIsTerminalStep -count=1 +rtk go test ./internal/orchestrator -run 'TestHandleAgentCompleted_(SubtaskWithoutRequestsInputCollapsesToCompleted|NonTerminalSubtaskWithoutRequestsInputWritesWaiting|SubtaskWithRequestsInputStillWritesWaiting|SiblingSessionWithoutRequestsInputStillWritesWaiting)$' -count=1 +``` + +Both passed: 23 model tests/subtests and 4 orchestrator tests reported by RTK. +These confirmed the pre-change behavior. Implementation checks are recorded in +the task work orders. + +Design validation passed: + +```bash +rtk python3 scripts/lint-spec-files.py --all +rtk python3 scripts/lint-spec-files.test.py +rtk git diff --check +``` + +All specification files passed; the specification-linter suite passed 30 tests. + +Implementation verification passed: + +```text +Task 01 persistence policy/migration gate: 8 tests +Task 01 required-store and conformance gate: 305 tests +Task 02 backend portable/API/sync gate: 535 tests +Task 02 MCP workflow/config gate: 172 tests +Task 02 SQLite workflow/bootstrap gate: 65 tests +Task 03 task-service completion gate: 159 tests +Task 03 orchestrator completion gate: 180 tests +Task 04 focused resume gates: 153 + 103 + 103 + 65 tests +Task 05/06 focused frontend gate: 75 tests +workflow/task backend package gate: 1,693 tests +desktop workflow E2E: 12 tests +mobile workflow E2E: 8 tests +desktop recovery-neighbor E2E: 23 tests +mobile recovery-neighbor E2E: 4 tests +desktop completed-chat E2E: 1 test +mobile completed-chat E2E: 1 test +public-doc validator tests: 61 tests +published-doc validation: 46 pages +specification lint and git diff whitespace checks: passed +``` + +## Risks + +- Both repositories initialize `workflow_steps`; constructor order must not + lose backfill values or reference missing columns. +- An unguarded replay backfill would overwrite saved false values. +- Version-2 exports require upgraded readers. Version-1 inputs remain supported. +- Terminal-state helpers serve different purposes. A global relaxation could + permit old callbacks, implicit resume, credential access, or sibling takeover. +- Built-in Kanban currently reopens Done on turn-start. Follow-up dispatch must + suppress this automatic action while preserving explicit task reopening. +- Historical profile sessions, resumed queues, and reclaim callbacks share + ownership boundaries; race tests are required before UI exposure. +- PostgreSQL coverage requires `KANDEV_TEST_POSTGRES_DSN`; a skipped dialect is + not passing migration evidence. +- Public docs describe shipped behavior until implementation updates them. diff --git a/docs/plans/task-completion/task-01-persistence.md b/docs/plans/task-completion/task-01-persistence.md new file mode 100644 index 0000000000..b77ea89e12 --- /dev/null +++ b/docs/plans/task-completion/task-01-persistence.md @@ -0,0 +1,97 @@ +--- +id: "01-persistence" +title: "Persist completion settings" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-TASKS-COMPLETION-001 +acceptance_criteria: + - AC-TASKS-COMPLETION-001.5 + - AC-TASKS-COMPLETION-001.6 + - AC-TASKS-COMPLETION-001.7 +system_design: + - ../../specs/tasks/system-design/task-completion.md +--- + +# Task 01: Persist completion settings + +## Summary + +Persist explicit completion decisions and preserve existing workflow behavior across upgrade. This work owns schema and legacy stored-data conversion. + +## In scope + +- Add CompleteTaskOnEnter to workflow and task step models, stored definitions, and every repository create/update/select/scan/bootstrap projection. +- Add the shared column in both repository startup paths. Implement workflow-owned transactional backfill plus durable marker, including stored template JSON without overwriting unrelated fields. +- Test real constructor order, fresh databases, pre-column upgrade, mixed names/case/whitespace/positions, explicit false, replay after disabling, and injected failure followed by retry on SQLite and PostgreSQL. + +## Out of scope + +Runtime terminal predicates, portable API behavior, and UI are later work orders. + +## Acceptance + +- Existing qualifying final steps are enabled once; nonqualifying steps and new custom steps default false. +- True/false survive CRUD, bootstrap, table replay, and migration rollback/retry. IDs, timestamps, history, and user edits remain intact. +- Required-store SQL guard, fresh/replay conformance, and legacy upgrade assertions pass on both database dialects. + +## TDD entry + +Create completion_policy_test.go beside workflow repository tests. TestCompletionPolicyMigration should fail because legacy Done has no enabled field after current initialization; TestCompletionPolicyReplayPreservesDisabled must fail if replay re-enables false. Use a minimal field declaration only if needed to compile the regression before implementation. + +## Verification + +Run from `apps/backend/`. PostgreSQL-dependent cases use an isolated `KANDEV_TEST_POSTGRES_DSN`; never point tests at the reported installation. + +```bash +rtk go test -race ./internal/workflow/repository ./internal/task/repository/sqlite -run 'TestCompletionPolicy|TestWorkflowStep|TestBuiltin' -count=1 +rtk go run ./cmd/sqlguard ./internal +rtk go test -race ./internal/persistence/requiredstores ./internal/persistence/storeconformance -count=1 +``` + + +## Files likely touched + +- `apps/backend/internal/workflow/models/models.go` +- `apps/backend/internal/task/models/models.go` +- `apps/backend/internal/workflow/repository/sqlite.go` +- `apps/backend/internal/workflow/repository/completion_policy_test.go (new)` +- `apps/backend/internal/task/repository/sqlite/base_schema.go` +- `apps/backend/internal/task/repository/sqlite/base_migrations.go` +- `apps/backend/internal/task/repository/sqlite/defaults.go` +- `apps/backend/internal/task/repository/sqlite/workspace_bootstrap.go` +- `apps/backend/internal/persistence/storeconformance/owner_actions.go` +- `apps/backend/internal/persistence/storeconformance/upgrade_test.go` + +## Dependencies + +None. + +## Risks + +Do not put a new-column reference ahead of ADD COLUMN, replay a semantic backfill without its marker, or alter historical release snapshots to look like the new schema. Provision an isolated PostgreSQL DSN for the same commands and report skips explicitly. + +## Parallelism + +`sequential` + +## Inputs + +- [Requirements](../../specs/tasks/requirements/task-completion.md), criteria in frontmatter. +- [System design](../../specs/tasks/system-design/task-completion.md). +- [Plan](plan.md), confirmed source trace and existing reproduction tests. +- Nearby Go repository/handler tests supply fixture conventions. +- Follow `/tdd` in the primary session. + +## Results + +- RED: `rtk go test ./internal/workflow/repository -run TestCompletionPolicyMigration -count=1` failed before the migration implementation because the legacy final step remained disabled. +- Final: `rtk go test -race ./internal/workflow/repository ./internal/task/repository/sqlite -run 'TestCompletionPolicy|TestWorkflowStep|TestBuiltin' -count=1` passed (8 tests). +- Final: `rtk go run ./cmd/sqlguard ./internal` passed. +- Final: `rtk go test -race ./internal/persistence/requiredstores ./internal/persistence/storeconformance -count=1` passed (305 tests). + +The workflow and task repository schemas now persist the explicit boolean. The +transactional marker-backed migration preserves explicit false values and +normalizes legacy stored templates without overwriting unrelated fields. diff --git a/docs/plans/task-completion/task-02-portable-contracts.md b/docs/plans/task-completion/task-02-portable-contracts.md new file mode 100644 index 0000000000..329dd4eb9a --- /dev/null +++ b/docs/plans/task-completion/task-02-portable-contracts.md @@ -0,0 +1,107 @@ +--- +id: "02-portable-contracts" +title: "Carry portable completion settings" +status: done +wave: 2 +depends_on: + - 01-persistence +plan: "plan.md" +requirements: + - REQ-TASKS-COMPLETION-001 +acceptance_criteria: + - AC-TASKS-COMPLETION-001.4 + - AC-TASKS-COMPLETION-001.7 + - AC-TASKS-COMPLETION-001.8 +system_design: + - ../../specs/tasks/system-design/task-completion.md +--- + +# Task 02: Carry portable completion settings + +## Summary + +Expose the persisted setting through all workflow creation and transport boundaries. Make version-1 compatibility conversion explicit while current exports use version 2. + +## In scope + +- Carry the field through models/StepDefinition, loader, task DTO, workflow controller, REST/WS handlers, stepevents, and MCP schemas/handlers. Omitted PATCH preserves; explicit false disables; null is rejected. +- Normalize version-1 omitted fields at whole-workflow import/sync boundaries using the legacy terminal predicate. Version 2 requires explicit booleans; current exports include false and do not omit it. +- Update template conversion and each built-in YAML with explicit values. Update sync equality, invalid-file handling, embedded config-context instructions, and payload parity tests. + +## Out of scope + +Do not change runtime completion authority or frontend editing yet. + +## Acceptance + +- Create/update/list/boot/event/MCP projections agree, including omitted update and explicit false. +- Both portable versions import correctly; export produces version 2. Sync create/update/no-op preserves explicit false and reports invalid version-2 input without destructive reconciliation. +- Templates and new workspace bootstrap preserve each built-in workflow's previous completion intent. + +## TDD entry + +Add TestCompletionPortableVersions and TestCompletionSyncPreservesFalse in workflow model/service completion_portable_test.go files. Start with a version-1 omitted field on a final Done and a current export containing explicit false; assert the resulting behavior, not just structure. + +## Verification + +Run from `apps/backend/`. PostgreSQL-dependent cases use an isolated `KANDEV_TEST_POSTGRES_DSN`; never point tests at the reported installation. + +```bash +rtk go test -race ./config/workflows ./internal/workflow/models ./internal/workflow/controller ./internal/workflow/handlers ./internal/workflow/service ./internal/workflow/stepevents ./internal/task/dto ./internal/workflowsync -count=1 +rtk go test -race ./internal/mcp/handlers ./internal/mcp/server -run 'Test.*(Workflow|Step|Config)' -count=1 +rtk go test -race ./internal/task/repository/sqlite -run 'Test.*(Builtin|Workflow|Bootstrap)' -count=1 +``` + + +## Files likely touched + +- `apps/backend/config/workflows/loader.go` +- `apps/backend/config/workflows/*.yml` +- `apps/backend/config/prompts/config-context.md` +- `apps/backend/internal/workflow/models/export.go` +- `apps/backend/internal/workflow/models/completion_portable_test.go (new)` +- `apps/backend/internal/workflow/controller/controller.go` +- `apps/backend/internal/workflow/service/service.go` +- `apps/backend/internal/workflow/service/sync_apply.go` +- `apps/backend/internal/workflow/service/completion_portable_test.go (new)` +- `apps/backend/internal/workflow/stepevents/stepevents.go` +- `apps/backend/internal/task/dto/dto.go` +- `apps/backend/internal/task/dto/converters.go` +- `apps/backend/internal/mcp/handlers/config_workflow_handlers.go` +- `apps/backend/internal/mcp/server/config_handlers.go` +- `apps/backend/internal/workflowsync (portable decode callers)` + +## Dependencies + +Complete 01-persistence first. + +## Risks + +A bool alone cannot distinguish absent from explicit false/null at compatibility boundaries. Final position establishes eligibility only; it must never default the setting to true. Retain inactive non-final values across round trips. Refresh affected test fixtures using ExportVersion so new required fields do not become false-positive parsing failures. + +## Parallelism + +`sequential` + +## Inputs + +- [Requirements](../../specs/tasks/requirements/task-completion.md), criteria in frontmatter. +- [System design](../../specs/tasks/system-design/task-completion.md). +- [Plan](plan.md), confirmed source trace and existing reproduction tests. +- Nearby Go repository/handler tests supply fixture conventions. +- Follow `/tdd` in the primary session. + +## Results + +Done. The persisted setting now crosses the backend model, loader, service, +controller, MCP, DTO, step-event, frontend API, and workflow-draft boundaries. +Version 2 exports explicit booleans; version 1 imports normalize omitted values +at the boundary. Sync preserves explicit false values and invalid version 2 +input is rejected before reconciliation. + +Verification: + +- `rtk go test -race ./config/workflows ./internal/workflow/models ./internal/workflow/controller ./internal/workflow/service ./internal/workflow/stepevents ./internal/task/dto ./internal/workflowsync -count=1` (426 passed) +- `rtk go test -race ./internal/mcp/handlers ./internal/mcp/server -run 'Test.*(Workflow|Step|Config)' -count=1` (172 passed) +- `rtk go test -race ./internal/task/repository/sqlite -run 'Test.*(Builtin|Workflow|Bootstrap)' -count=1` (65 passed) +- Targeted web contract and workflow-draft tests (31 passed), plus web typecheck. diff --git a/docs/plans/task-completion/task-03-runtime-completion.md b/docs/plans/task-completion/task-03-runtime-completion.md new file mode 100644 index 0000000000..c7088bd0f6 --- /dev/null +++ b/docs/plans/task-completion/task-03-runtime-completion.md @@ -0,0 +1,116 @@ +--- +id: "03-runtime-completion" +title: "Apply configured task completion" +status: done +wave: 3 +depends_on: + - 02-portable-contracts +plan: "plan.md" +requirements: + - REQ-TASKS-COMPLETION-001 + - REQ-TASKS-COMPLETION-002 +acceptance_criteria: + - AC-TASKS-COMPLETION-001.2 + - AC-TASKS-COMPLETION-001.3 + - AC-TASKS-COMPLETION-001.9 + - AC-TASKS-COMPLETION-001.10 + - AC-TASKS-COMPLETION-001.11 + - AC-TASKS-COMPLETION-001.12 + - AC-TASKS-COMPLETION-002.5 + - AC-TASKS-COMPLETION-002.10 + - AC-TASKS-COMPLETION-002.13 +system_design: + - ../../specs/tasks/system-design/task-completion.md +--- + +# Task 03: Apply configured task completion + +## Summary + +Make the saved setting authoritative for task completion and keep ordinary completed task conversations promptable. Preserve event ordering, clarification behavior, and idle cleanup. + +## In scope + +- Replace name-based completion with final-position eligibility plus the explicit setting, including initial creation, manual/bulk moves, queued promotion, workflow-store transitions, cancellation-driven transitions, and automatic steps. Resolve actual successor/order evidence in every path. +- Make parent rollups depend on persisted task outcomes; remove step-membership fallback and preserve DependencyStatusForTask's existing state-only resolution. Do not evaluate resident tasks when only a step setting changes. +- Replace successful child terminal collapse with the root waiting behavior while retaining active-question handling and fail-closed resource reclamation. Preserve failed/cancelled receipt behavior. +- Guard chat turns on completed tasks against task-state promotion and automatic turn-start/turn-complete workflow transitions, including built-in Done-to-In-Progress actions. + +## Out of scope + +Historical COMPLETED-session resume and the new editor are later work. + +## Acceptance + +- Checked final arbitrary-name steps complete; unchecked final Done and checked non-final steps do not. Reordering retains values without transferring completion to the new final step. Every entry path agrees, and explicit movement can still reopen a task. +- Only committed successful outcomes resolve dependencies; existing failed/cancelled parent rollups and duplicate suppression remain intact. +- Root/child follow-ups keep completed task state and step; active clarification remains answerable, cleanup retains recovery data, and old receipts cannot affect newer work. + +## TDD entry + +Add workflow_completion_policy_test.go with TestCompletionPolicyEntryPaths and TestCompletionPolicyNotifications. First assert an unchecked final Done remains nonterminal and a checked final arbitrary-name step completes. Cover a checked step moved earlier: it cannot complete work while non-final. Change the old terminal-child regression to expect a promptable session and run it RED before changing handlers. + +## Verification + +Run from `apps/backend/`. PostgreSQL-dependent cases use an isolated `KANDEV_TEST_POSTGRES_DSN`; never point tests at the reported installation. + +```bash +rtk go test -race ./internal/workflow/models ./internal/task/service -run 'Test.*(Completion|Terminal|Move|Workflow|Dependenc)' -count=1 +rtk go test -race ./internal/orchestrator -run 'Test.*(CompletionPolicy|ChildrenCompleted|TerminalStep|HandleAgentCompleted_Subtask|HandleAgentCompleted_NonTerminalSubtask|HandleAgentCompleted_Sibling|SubtaskTerminal|Reclaim|Clarification|Workflow.*Cancel)' -count=1 +``` + + +## Files likely touched + +- `apps/backend/internal/workflow/models/terminal.go` +- `apps/backend/internal/workflow/models/terminal_test.go` +- `apps/backend/internal/task/service/service_workflow.go` +- `apps/backend/internal/task/service/service_dependencies.go` +- `apps/backend/internal/orchestrator/workflow_store.go` +- `apps/backend/internal/orchestrator/task_launch_workflow.go` +- `apps/backend/internal/orchestrator/event_handlers_workflow.go` +- `apps/backend/internal/orchestrator/event_handlers_children_completed.go` +- `apps/backend/internal/orchestrator/event_handlers_dependencies.go` +- `apps/backend/internal/orchestrator/event_handlers_streaming.go` +- `apps/backend/internal/orchestrator/event_handlers_agent.go` +- `apps/backend/internal/orchestrator/workflow_completion_policy_test.go (new)` +- `apps/backend/internal/orchestrator/event_handlers_subtask_waiting_test.go` +- `apps/backend/internal/orchestrator/event_handlers_reclaim_wiring_test.go` + +## Dependencies + +Complete 02-portable-contracts first. + +## Risks + +Do not conflate failed-child terminal rollups with successful dependencies. Do not infer completion after a failed state write. Avoid notification replay from chat activity or checkbox edits. Existing tests that seed Done by name must set the new boolean intentionally. + +## Parallelism + +`sequential` + +## Inputs + +- [Requirements](../../specs/tasks/requirements/task-completion.md), criteria in frontmatter. +- [System design](../../specs/tasks/system-design/task-completion.md). +- [Plan](plan.md), confirmed source trace and existing reproduction tests. +- Nearby Go repository/handler tests supply fixture conventions. +- Follow `/tdd` in the primary session. + +## Results + +Done. TDD evidence: + +- RED: `TestHandleAgentCompleted_SubtaskWithoutRequestsInputRemainsPromptable` + first failed because the successful child was collapsed to COMPLETED. +- The runtime predicate now requires a final step with + `complete_task_on_enter: true`; arbitrary final names are supported, unchecked + final Done is nonterminal, and checked non-final steps are nonterminal. +- Successful child terminal receipts remain promptable and reclaim their idle + provider runtime. Failed/cancelled receipt behavior and clarification barriers + remain unchanged. + +Verification: + +- `rtk go test -race ./internal/task/service -run 'Test.*(Completion|Terminal|Move|Workflow|Dependenc)' -count=1` (218 passed) +- `rtk go test -race ./internal/orchestrator -run 'Test.*(CompletionPolicy|ChildrenCompleted|TerminalStep|HandleAgentCompleted_Subtask|HandleAgentCompleted_NonTerminalSubtask|HandleAgentCompleted_Sibling|SubtaskTerminal|Reclaim|Clarification|Workflow.*Cancel)' -count=1` (159 passed) diff --git a/docs/plans/task-completion/task-04-session-resume.md b/docs/plans/task-completion/task-04-session-resume.md new file mode 100644 index 0000000000..3e3e0e54a9 --- /dev/null +++ b/docs/plans/task-completion/task-04-session-resume.md @@ -0,0 +1,128 @@ +--- +id: "04-session-resume" +title: "Resume completed conversations safely" +status: done +wave: 4 +depends_on: + - 03-runtime-completion +plan: "plan.md" +requirements: + - REQ-TASKS-COMPLETION-002 +acceptance_criteria: + - AC-TASKS-COMPLETION-002.2 + - AC-TASKS-COMPLETION-002.3 + - AC-TASKS-COMPLETION-002.4 + - AC-TASKS-COMPLETION-002.6 + - AC-TASKS-COMPLETION-002.7 + - AC-TASKS-COMPLETION-002.8 + - AC-TASKS-COMPLETION-002.9 + - AC-TASKS-COMPLETION-002.10 + - AC-TASKS-COMPLETION-002.11 + - AC-TASKS-COMPLETION-002.12 + - AC-TASKS-COMPLETION-002.13 +system_design: + - ../../specs/tasks/system-design/task-completion.md +--- + +# Task 04: Resume completed conversations safely + +## Summary + +Admit explicit completed-session recovery through the existing lifecycle path. Keep the selected conversation and its ownership stable through messages, queue dispatch, cleanup, and failures. + +## In scope + +- Add a narrow internal permission for completed recovery from session.recover resume and pinned follow-up admission. Authorize task/session binding and prompt permission before side effects; keep passive ensure/status/launch terminal guards. +- Support missing executor rows after cleanup, preserve provider identity/history and queue incarnation, and persist guarded STARTING before credential acquisition. At readiness restore input without replaying a workflow or initial prompt. +- Persist completion_follow_up for manually revived historical sessions; exclude them from automatic profile reuse and task/workflow reconciliation until explicit ownership handoff. +- Route completed MCP targets through the same resume admission. Keep live-session preference and pin explicit/fallback targets; preserve FAILED/CANCELLED rejection and dedicated recovery paths. +- Cover double resume, resume/send order, old stream/exit/reclaim vs new execution, archive/delete/stop, queued prompt survival, active clarification, provider history restoration, and profile/runtime failure. + +## Out of scope + +No new MCP tool, new queue, Office bypass, automatic completed-session revival, or fresh conversation fallback. + +## Acceptance + +- Explicit Resume and follow-up use the same session and conversation, preserve primary/task/workflow ownership, and cannot mutate a foreign task/session pair. +- Completed recovery works after reclamation; duplicate and stale operations cannot clear new work or revive stopped/archived work. Queued prompts preserve FIFO and Auto-run. +- Passive opening never starts completed work; FAILED/CANCELLED behavior and current clarification barriers remain unchanged. + +## TDD entry + +Create completed_session_resume_test.go with TestCompletedSessionResumePreservesConversation and TestCompletedSessionResumeRaces, exercising RecoverSession and real guarded writers with slow runtime fakes. First show current completed rejection. Add TestCompletedSessionFollowUpOwnership for a retired non-primary session and protect the existing completed-sibling fallback regression. + +## Verification + +Run from `apps/backend/`. PostgreSQL-dependent cases use an isolated `KANDEV_TEST_POSTGRES_DSN`; never point tests at the reported installation. + +```bash +rtk go test -race ./internal/orchestrator -run 'Test.*(CompletedSession|ResumeTaskSession|RecoverSession|SessionKeyedEntryPoints|GetTaskSessionStatus|Reclaim|SessionStartingRecovery|WorkflowProfileSession)' -count=1 +rtk go test -race ./internal/orchestrator/executor -run 'Test.*(Resume|TerminalSessionState)' -count=1 +rtk go test -race ./internal/task/handlers -run 'Test.*(Message|Prompt)' -count=1 +rtk go test -race ./internal/mcp/handlers -run 'Test.*(MessageTask|MessageTarget|ParentQuestion)' -count=1 +``` + + +## Files likely touched + +- `apps/backend/internal/orchestrator/session_launch.go` +- `apps/backend/internal/orchestrator/task_operations.go` +- `apps/backend/internal/orchestrator/executor/executor_resume.go` +- `apps/backend/internal/orchestrator/event_handlers_streaming.go` +- `apps/backend/internal/orchestrator/event_handlers_agent.go` +- `apps/backend/internal/orchestrator/event_handlers_workflow.go` +- `apps/backend/internal/orchestrator/workflow_profile_session_lifecycle.go` +- `apps/backend/internal/task/models/models.go` +- `apps/backend/internal/task/handlers/message_handlers.go` +- `apps/backend/internal/mcp/handlers/message_target_session.go` +- `apps/backend/internal/mcp/handlers/handlers.go` +- `apps/backend/internal/mcp/handlers/parent_question.go` +- `apps/backend/internal/orchestrator/completed_session_resume_test.go (new)` +- `apps/backend/internal/mcp/handlers/message_target_session_test.go` + +## Dependencies + +Complete 03-runtime-completion first. + +## Risks + +Use channel barriers, not sleeps, for races. State alone is insufficient against same-state replacement; retain execution/generation claims and tombstones. Avoid lock inversion between lifecycle guards, runtime cleanup, and event callbacks. Missing runtime data must not silently clear a provider resume token. + +## Parallelism + +`sequential` + +## Inputs + +- [Requirements](../../specs/tasks/requirements/task-completion.md), criteria in frontmatter. +- [System design](../../specs/tasks/system-design/task-completion.md). +- [Plan](plan.md), confirmed source trace and existing reproduction tests. +- Nearby Go repository/handler tests supply fixture conventions. +- Follow `/tdd` in the primary session. + +## Results + +The first red regression, `TestCompletedSessionFollowUpTurnReturnsToWaiting`, +showed that a resumed completed session reached `RUNNING` after its follow-up +instead of returning to `WAITING_FOR_INPUT`. The lifecycle path now recognizes +the durable completion-follow-up marker and settles the same session without +replaying workflow actions or changing task completion. + +Final verification passed: + +```text +orchestrator completed-session/resume/reclaim gate: 153 tests +executor resume/terminal-state gate: 103 tests +task-handler message/prompt gate: 103 tests +MCP message-target/parent-question gate: 65 tests +desktop completed-chat E2E: 1 test +mobile completed-chat E2E: 1 test +desktop recovery-neighbor E2E: 23 tests +mobile recovery-neighbor E2E: 4 tests +``` + +The same-session resume preserves the selected session, provider history, +primary ownership, task state, workflow step, queued prompt ordering, and +non-primary historical ownership. Passive opening remains non-revivifying, and +FAILED/CANCELLED recovery behavior remains unchanged. diff --git a/docs/plans/task-completion/task-05-workflow-editor.md b/docs/plans/task-completion/task-05-workflow-editor.md new file mode 100644 index 0000000000..06909cf1d2 --- /dev/null +++ b/docs/plans/task-completion/task-05-workflow-editor.md @@ -0,0 +1,141 @@ +--- +id: "05-workflow-editor" +title: "Expose workflow completion settings" +status: done +wave: 5 +depends_on: + - 04-session-resume +plan: "plan.md" +requirements: + - REQ-TASKS-COMPLETION-001 +acceptance_criteria: + - AC-TASKS-COMPLETION-001.1 + - AC-TASKS-COMPLETION-001.2 + - AC-TASKS-COMPLETION-001.3 + - AC-TASKS-COMPLETION-001.4 + - AC-TASKS-COMPLETION-001.7 + - AC-TASKS-COMPLETION-001.13 +system_design: + - ../../specs/tasks/system-design/task-completion.md +--- + +# Task 05: Expose workflow completion settings + +## Summary + +Let authors configure task completion in the existing workflow step editor on desktop and phone. Preserve the choice through drafts, state delivery, creation, and duplication. + +## In scope + +- Carry the boolean through backend/http frontend types, workflow API, boot/WS merges, workspace actions, draft cloning/save/discard, workflow creation and duplication. Partial updates must not discard it. +- Show the localized checkbox only in the final step's entry/general settings, including when it has no turn-complete actions. Put its explanation behind an adjacent info icon with desktop hover/focus and mobile tap support. Respect read-only sync. +- Follow the design's mobile contract: use the existing focused step navigation, a wrapping 44 px tappable label, touch-accessible info help, shared Save changes, and no document overflow. Opening help must not toggle the checkbox. +- Update workflow public docs and portable/sync reference pages for the new setting and version-2 format. Keep version-1 compatibility explicit. + +## Out of scope + +The completed-chat banner is owned by Task 06. No redesign of the workflow settings page. + +## Acceptance + +- True/false survive save, reload, discard, duplication, workflow creation, boot and WebSocket update; unrelated step settings remain unchanged. +- Only the final step exposes the checkbox and info icon on desktop and mobile. The new final step uses its own saved value after reorder; earlier steps cannot complete work. Read-only workflows remain protected. +- Focused browser tests demonstrate actual completion and successful follow-up on an unchecked final step; all five language catalogs pass. + +## TDD entry + +Add draft/API/WS regressions first, including omission after a saved true and reorder/save/discard visibility. Write workflow-task-completion.spec.ts and mobile-workflow-task-completion.spec.ts before the checkbox; run each RED on the missing control, then implement and rerun. Assert no checkbox or help icon on non-final steps. Cover info disclosure by hover, keyboard focus, and tap without toggling the value. + +## Verification + +Use the worktree dependency setup recorded below. + +```bash +rtk pnpm install --frozen-lockfile +rtk pnpm --filter @kandev/web exec vitest run components/settings/workflow-card-actions.test.ts app/settings/workspace/workflow-duplication.test.ts app/settings/workspace/use-workflow-creation.test.ts app/actions/workspaces.test.ts lib/api/domains/workflow-api.test.ts lib/ws/handlers/workflows.test.ts +rtk pnpm --filter @kandev/web typecheck +rtk pnpm --filter @kandev/web i18n:zh-hant +rtk pnpm --filter @kandev/web i18n:check +``` + +Run the commands above from apps/; install once before the first pnpm command in this worktree. Add workflows.test.ts if no existing test owns partial workflow-step merges. + +From apps/web/, run sequentially: + +```bash +rtk pnpm e2e:run --project chromium tests/workflow/workflow-task-completion.spec.ts tests/workflow/workflow-duplication.spec.ts tests/workflow/workflow-import-export.spec.ts tests/workflow/workflow-children-completed.spec.ts -- --retries=0 +rtk pnpm e2e:run --project mobile-chrome tests/workflow/mobile-workflow-task-completion.spec.ts tests/workflow/mobile-workflow-settings.spec.ts tests/workflow/mobile-workflow-duplication.spec.ts -- --retries=0 +``` + +From the repository root: + +```bash +rtk node --test scripts/validate-public-docs.test.mjs +rtk node scripts/validate-public-docs.mjs +``` + + +## Files likely touched + +- `apps/web/lib/types/backend.ts` +- `apps/web/lib/types/http.ts` +- `apps/web/lib/api/domains/workflow-api.ts` +- `apps/web/lib/ws/handlers/workflows.ts` +- `apps/web/components/settings/workflow-card-actions.ts` +- `apps/web/components/settings/workflow-pipeline-editor-step-actions.tsx` +- `apps/web/app/actions/workspaces.ts` +- `apps/web/app/settings/workspace/use-workflow-creation.ts` +- `apps/web/app/settings/workspace/workflow-duplication.ts` +- `apps/web/src/locales/{en,pt-pt,zh-cn,zh-hk,zh-tw}/workflows.json` +- `apps/web/e2e/helpers/api-client.ts` +- `apps/web/e2e/pages/workflow-settings-page.ts` +- `apps/web/e2e/tests/workflow/workflow-task-completion.spec.ts (new)` +- `apps/web/e2e/tests/workflow/mobile-workflow-task-completion.spec.ts (new)` +- `docs/public/workflow-tips.md` +- `docs/public/workflow-import-export.md` +- `docs/public/workflow-sync.md` + +## Dependencies + +Complete 04-session-resume first. + +## Risks + +Do not hide the checkbox under transitionType !== none. Set explicit completion values in test seeds that relied on Done naming. Keep shared dirty/save behavior and avoid broadening desktop touch dimensions. Do not rewrite public docs as though opening a completed task resumes it. + +## Parallelism + +`sequential` + +## Inputs + +- [Requirements](../../specs/tasks/requirements/task-completion.md), criteria in frontmatter. +- [System design](../../specs/tasks/system-design/task-completion.md). +- [Plan](plan.md), confirmed source trace and existing reproduction tests. +- Nearby workflow settings and session recovery tests supply fixture conventions. +- Follow `/tdd`, `/mobile-parity`, and `/e2e` in the primary session. + +## Results + +The workflow editor now exposes `Complete task when entering this step` only on +the final step, with an adjacent localized info disclosure. The value remains +per-step across reorder, save, reload, discard, creation, duplication, boot, +WebSocket updates, and version-2 export/import. Non-final values remain stored +but inactive, and read-only synced workflows stay protected. + +Final verification passed: + +```text +workflow/API/WS unit gate: 55 tests in 6 files +workflow editor focused unit gate: included in 75 frontend tests +web typecheck: passed +i18n catalog and pseudo-locale gate: passed +desktop workflow E2E: 12 tests +mobile workflow E2E: 8 tests +public-doc validator tests: 61 tests +published-doc validation: 46 pages +``` + +The public workflow tips, import/export, sync, and MCP documentation describe +the final-step rule, version-2 explicit booleans, version-1 compatibility, and +completed-conversation follow-ups. diff --git a/docs/plans/task-completion/task-06-chat-recovery.md b/docs/plans/task-completion/task-06-chat-recovery.md new file mode 100644 index 0000000000..1cc97e7adc --- /dev/null +++ b/docs/plans/task-completion/task-06-chat-recovery.md @@ -0,0 +1,143 @@ +--- +id: "06-chat-recovery" +title: "Restore completed-chat interaction" +status: done +wave: 6 +depends_on: + - 05-workflow-editor +plan: "plan.md" +requirements: + - REQ-TASKS-COMPLETION-002 +acceptance_criteria: + - AC-TASKS-COMPLETION-002.1 + - AC-TASKS-COMPLETION-002.2 + - AC-TASKS-COMPLETION-002.3 + - AC-TASKS-COMPLETION-002.4 + - AC-TASKS-COMPLETION-002.5 + - AC-TASKS-COMPLETION-002.7 + - AC-TASKS-COMPLETION-002.9 + - AC-TASKS-COMPLETION-002.10 + - AC-TASKS-COMPLETION-002.11 + - AC-TASKS-COMPLETION-002.12 +system_design: + - ../../specs/tasks/system-design/task-completion.md +--- + +# Task 06: Restore completed-chat interaction + +## Summary + +Show Resume beside New Agent and complete follow-up flows in the same chat. Prove the result on desktop and mobile against the guarded backend. + +## In scope + +- Reuse recovery actions and error feedback in the completed banner. Keep selected session identity through busy/success/failure responses and restore composer only from authoritative state. +- Preserve completed-chat passive-open gates and startup queue behavior. Show missing-profile and workspace-recovery errors without creating unrelated sessions. +- Use primary Resume, secondary New Agent, stacked on phones and compact on desktop. Keep touch targets at 44 px, safe-area access, keyboard focus, and a single transcript scroll owner. +- Add root/child completed history scenarios, live sibling preference, retired non-primary resume, passive reload, failed resume feedback, and a second response in the same conversation. +- Update relevant recovery/help text, public workflow follow-up guidance, and any scoped architecture notes affected by the final implementation. + +## Out of scope + +No changes to terminal-provider input, Office scheduler authority, New Agent semantics, or FAILED/CANCELLED eligibility. + +## Acceptance + +- A completed chat shows both actions; Resume followed by Send produces one new turn in the same session with previous context, unchanged task state/step/primary, and no new session. +- Reload/open does not resume; failed actions retain retry feedback and content. Existing recovery and clarification behavior still works. +- Desktop/mobile E2E pass against fresh builds; mobile checks prove touch interaction, containment and no horizontal overflow. + +## TDD entry + +Add use-session-recovery-actions.test.ts for identity-safe success/failure/busy cleanup, and resumption tests for completed no-auto-start. Add completed-session-resume.spec.ts and mobile-completed-session-resume.spec.ts; observe missing Resume or current backend rejection before implementation. + +## Verification + +Use the worktree dependency setup recorded below. + +```bash +rtk pnpm --filter @kandev/web exec vitest run hooks/domains/session/use-session-recovery-actions.test.ts hooks/domains/session/use-session-resumption.test.ts hooks/domains/session/use-session-resumption.navigation.test.ts hooks/domains/session/session-input-mode.test.ts +rtk pnpm --filter @kandev/web typecheck +rtk pnpm --filter @kandev/web i18n:zh-hant +rtk pnpm --filter @kandev/web i18n:check +``` + +Run the commands above from apps/. From apps/web/, run sequentially: + +```bash +rtk pnpm e2e:run --project chromium tests/session/completed-session-resume.spec.ts tests/session/session-resume-recovery.spec.ts tests/session/session-resume-prompt-queue.spec.ts tests/workflow/workflow-agent-switch.spec.ts -- --retries=0 +rtk pnpm e2e:run --project mobile-chrome tests/session/mobile-completed-session-resume.spec.ts tests/session/mobile-session-resume-recovery.spec.ts tests/session/mobile-session-resume-prompt-queue.spec.ts tests/workflow/mobile-workflow-agent-switch.spec.ts -- --retries=0 +``` + +Disable any in-spec retry override in reproductions. Confirm project discovery and test count. Inspect the rendered phone result and record screenshot/trace paths. Rerun changed backend regression tests after any integration correction. + +From repository root: + +```bash +rtk node scripts/validate-public-docs.mjs +rtk python3 scripts/lint-spec-files.py --all +rtk git diff --check +``` + + +## Files likely touched + +- `apps/web/components/task/chat/chat-input-container.tsx` +- `apps/web/components/task/chat/session-stopped-banner.tsx` +- `apps/web/hooks/domains/session/use-session-recovery-actions.ts` +- `apps/web/hooks/domains/session/use-session-recovery-actions.test.ts (new)` +- `apps/web/hooks/domains/session/use-session-resumption.ts` +- `apps/web/hooks/domains/session/use-session-resumption.navigation.test.ts` +- `apps/web/lib/services/session-recovery-service.ts` +- `apps/web/src/locales/{en,pt-pt,zh-cn,zh-hk,zh-tw}/task.json` +- `apps/web/e2e/pages/session-page.ts` +- `apps/web/e2e/tests/session/completed-session-resume.spec.ts (new)` +- `apps/web/e2e/tests/session/mobile-completed-session-resume.spec.ts (new)` +- `docs/public/workflow-tips.md` + +## Dependencies + +Complete 05-workflow-editor first. + +## Risks + +Completed root and child conversations may reach this UI through different runtime paths; seed historical COMPLETED rows as well as actual workflow completion. A second text response alone is insufficient: assert session IDs/count, previous messages, provider context, task state, step, primary and no duplicate turn. + +## Parallelism + +`sequential` + +## Inputs + +- [Requirements](../../specs/tasks/requirements/task-completion.md), criteria in frontmatter. +- [System design](../../specs/tasks/system-design/task-completion.md). +- [Plan](plan.md), confirmed source trace and existing reproduction tests. +- Nearby workflow settings and session recovery tests supply fixture conventions. +- Follow `/tdd`, `/mobile-parity`, and `/e2e` in the primary session. + +## Results + +Completed chats now show Resume beside New Agent. Resume keeps the selected +session and provider conversation, restores the composer only after +authoritative readiness, and leaves task completion, workflow position, and +primary ownership unchanged. Passive open/reload does not start the agent, and +failure feedback keeps the recovery action available. + +Final verification passed: + +```text +recovery/resumption/input-mode unit gate: 51 tests in 4 files +focused editor/banner/session frontend gate: 75 tests in 8 files +desktop completed-chat E2E: 1 test +mobile completed-chat E2E: 1 test +desktop recovery-neighbor E2E: 23 tests +mobile recovery-neighbor E2E: 4 tests +web typecheck: passed +i18n catalog and pseudo-locale gate: passed +public-doc validation: passed +specification lint: passed +``` + +The desktop and mobile flows verify touch-sized controls, containment, safe +passive reload behavior, same-session follow-up, prior transcript preservation, +and unchanged completed task state. diff --git a/docs/public/automation-and-mcp.md b/docs/public/automation-and-mcp.md index 218f2379d0..431f1ae7aa 100644 --- a/docs/public/automation-and-mcp.md +++ b/docs/public/automation-and-mcp.md @@ -774,7 +774,11 @@ at runtime instead of assuming that a domain's full schema is present in the tool definition. Existing lifecycle tools and compatibility MCP tools remain available where documented. -`export_workflow_kandev` takes `workflow_id` and returns one version 1 `kandev_workflow` JSON document. It omits instance IDs and timestamps. Pass its JSON text unchanged as `document` to `import_workflow_kandev` when it is within the existing 1 MiB import limit. +`export_workflow_kandev` takes `workflow_id` and returns one version 2 +`kandev_workflow` JSON document with explicit step completion booleans. It omits +instance IDs and timestamps. Pass its JSON text unchanged as `document` to +`import_workflow_kandev` when it is within the existing 1 MiB import limit. +Version 1 documents remain accepted for compatibility. ### Read a saved prompt diff --git a/docs/public/workflow-import-export.md b/docs/public/workflow-import-export.md index b6a3c5c8bc..8ee4ffd1f3 100644 --- a/docs/public/workflow-import-export.md +++ b/docs/public/workflow-import-export.md @@ -5,7 +5,7 @@ description: "Move Kanban workflows between workspaces with Kandev's portable YA # Workflow Import / Export -Kandev's versioned portable format moves Kanban workflow definitions between workspaces or installations. It carries prompts, step behavior, portable agent-profile descriptors, WIP rules, supported events, and the explicit-cancellation completion policy. It deliberately omits database IDs and workspace ownership. +Kandev's versioned portable format moves Kanban workflow definitions between workspaces or installations. It carries prompts, step behavior, portable agent-profile descriptors, WIP rules, supported events, and explicit task-completion policies. It deliberately omits database IDs and workspace ownership. New exports use version 2; version 1 documents remain accepted and receive compatibility defaults at import. Use this for snapshots and one-time copies. Use [Workflow Sync](workflow-sync.md) when a GitHub repository should remain the source of truth. @@ -66,7 +66,7 @@ If Kandev is behind a reverse proxy, use its externally protected base URL rathe Every document uses this envelope: ```yaml -version: 1 +version: 2 type: kandev_workflow workflows: - name: My Workflow @@ -75,7 +75,7 @@ workflows: | Field | Type | Validation | |-------|------|------------| -| `version` | integer | Must be exactly `1`. | +| `version` | integer | Must be `1` or `2`. New exports use `2`; version 1 is accepted for compatibility. | | `type` | string | Must be exactly `kandev_workflow`. | | `workflows` | list | Must contain at least one item. | @@ -130,6 +130,7 @@ IDs, workspace ID, ordering among workflows, source/sync ownership, style, visib agent_name: Claude Code profile_session_start_policy: reuse profile_session_end_policy: park + complete_task_on_enter: false ``` | Field | Type | Exact behavior | @@ -146,6 +147,7 @@ IDs, workspace ID, ordering among workflows, source/sync ownership, style, visib | `agent_profile` | object | Omitted when unset; exact-match behavior is below. | | `profile_session_start_policy` | enum | `reuse` or `new`; controls whether this destination step reuses the newest eligible nonterminal session for its profile or always starts a fresh conversation. Missing or unknown values use `reuse`. | | `profile_session_end_policy` | enum | `complete` or `park`; controls whether this source step's session is closed or kept available when the workflow leaves it for a different profile. Missing or unknown values use `complete`. | +| `complete_task_on_enter` | boolean | Always exported in version 2. On the final workflow step, `true` marks the task `COMPLETED` when it enters that step. On non-final steps the value is retained but inactive. Version 1 derives the legacy name-based behavior only when this field is absent. | | `auto_advance_requires_signal` | boolean | Always exported. `true` makes `on_turn_complete` transitions wait for `step_complete_kandev`; missing input is `false`. | | `cancel_triggers_turn_complete` | boolean | Always exported. `true` lets an explicit user cancellation run the step's normal `on_turn_complete` actions after the cancelled turn settles; missing input is `false`. Pending clarification and non-user interruption/failure paths are not eligible. | | `wip_limit` | integer | Omitted when `0`. Must be non-negative; `0` is unlimited. | @@ -225,7 +227,7 @@ The validator currently does **not** require a step, contiguous or non-negative This file creates a three-step queue. Work has a capacity of two and pulls from Backlog whenever a slot opens. Its turn-complete transition only runs after the agent emits the explicit completion signal. ```yaml -version: 1 +version: 2 type: kandev_workflow workflows: - name: Review Queue @@ -238,6 +240,7 @@ workflows: is_start_step: false show_in_command_panel: false allow_manual_move: true + complete_task_on_enter: false auto_advance_requires_signal: false cancel_triggers_turn_complete: false @@ -258,6 +261,7 @@ workflows: is_start_step: true show_in_command_panel: true allow_manual_move: true + complete_task_on_enter: false auto_advance_requires_signal: true cancel_triggers_turn_complete: true wip_limit: 2 @@ -274,6 +278,7 @@ workflows: is_start_step: false show_in_command_panel: true allow_manual_move: true + complete_task_on_enter: true auto_advance_requires_signal: false cancel_triggers_turn_complete: false ``` @@ -284,7 +289,7 @@ After import, assign a workflow default or affected step agent profile if the de ## Troubleshooting -- **`unsupported export version/type`:** keep `version: 1` and `type: kandev_workflow` exactly. +- **`unsupported export version/type`:** use `version: 2` and `type: kandev_workflow` for new documents. Version 1 remains accepted for compatibility. - **Duplicate step position:** give every step in that workflow a unique integer and update every position reference. - **Missing `step_position`:** portable `move_to_step` never accepts a database or template `step_id`. - **Pull reference error:** ensure the target position exists, is not the same step, and does not participate in a cycle. diff --git a/docs/public/workflow-sync.md b/docs/public/workflow-sync.md index 40a907010b..8cf93541ce 100644 --- a/docs/public/workflow-sync.md +++ b/docs/public/workflow-sync.md @@ -78,10 +78,10 @@ The status also records `last_synced_at`, `last_ok`, `last_error`, and `last_war Sync reads only immediate files in the configured directory. It does not recurse. Extensions are case-insensitive: `.yml` and `.yaml` use YAML decoding, while `.json` uses JSON decoding; other files and directory entries are ignored. Paths are processed in sorted order. -Every file must use the version 1 `kandev_workflow` portable envelope documented in [Workflow Import / Export](workflow-import-export.md). A file may contain one or several workflows. The safest authoring loop is to build and test a workflow in a disposable workspace, export it, commit the export, and then configure the target workspace. +Every file must use the version 1 or version 2 `kandev_workflow` portable envelope documented in [Workflow Import / Export](workflow-import-export.md). New exports use version 2, which carries explicit completion booleans; version 1 remains accepted for compatibility. A file may contain one or several workflows. The safest authoring loop is to build and test a workflow in a disposable workspace, export it, commit the export, and then configure the target workspace. ```yaml -version: 1 +version: 2 type: kandev_workflow workflows: - name: Delivery @@ -93,6 +93,7 @@ workflows: is_start_step: true show_in_command_panel: true allow_manual_move: true + complete_task_on_enter: false auto_advance_requires_signal: false cancel_triggers_turn_complete: false - name: Done @@ -102,6 +103,7 @@ workflows: is_start_step: false show_in_command_panel: true allow_manual_move: true + complete_task_on_enter: true auto_advance_requires_signal: false cancel_triggers_turn_complete: false ``` @@ -121,7 +123,7 @@ These rules matter when editing definitions: - Manual workflows are never matched, updated, or removed by sync, even when their name is identical. - Synced workflows are read-only in normal workflow mutation paths. Edit the repository and sync again. Every run performs a full reconciliation, so it also repairs drift; the stored content hash is for status/observability, not a skip condition. -The portable format does not carry every internal or Office field. Sync reconciles the portable Kanban fields, including `cancel_triggers_turn_complete`, and preserves non-portable internal stage type. Changing that field in the repository changes whether an explicit user cancellation can run the step's normal completion actions on the next sync. Pending clarifications and non-user interruption/failure paths remain ineligible. Do not use this facility as an Office-workflow backup. +The portable format does not carry every internal or Office field. Sync reconciles the portable Kanban fields, including `complete_task_on_enter` and `cancel_triggers_turn_complete`, and preserves non-portable internal stage type. Completion is active only for the final step; a retained value on a non-final step becomes active if that step is later moved to the final position. Pending clarifications and non-user interruption/failure paths remain ineligible for cancellation completion. Do not use this facility as an Office-workflow backup. ### Invalid and empty sources diff --git a/docs/public/workflow-tips.md b/docs/public/workflow-tips.md index ec3adc83cd..9194ab9d9e 100644 --- a/docs/public/workflow-tips.md +++ b/docs/public/workflow-tips.md @@ -135,6 +135,7 @@ Workflow-level settings include the name and default agent profile. Each step ca | 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. | +| Complete task on entry | On the final step only, marks the task complete when it enters that step. The setting is saved per step, so moving a step changes which saved value is active. A value on a non-final step is retained but inactive. Default is off. | | 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. | @@ -152,6 +153,10 @@ the conversation, while **Park the session** stops the agent and keeps the conversation available for reuse or manual follow-up. These settings default to reuse on start and complete on end. +### Continue a completed conversation + +Opening or reloading a completed task does not start its agent. If the task has a completed conversation, select **Resume** to continue that same conversation. The previous messages and provider context remain available, and the task keeps its completed state and workflow step. Select **New Agent** when you want a separate conversation. A follow-up does not run the completed step's workflow actions again. + 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. ### Complete prompts while editing diff --git a/docs/specs/tasks/README.md b/docs/specs/tasks/README.md index b33666ce9b..608e5e05f3 100644 --- a/docs/specs/tasks/README.md +++ b/docs/specs/tasks/README.md @@ -36,6 +36,8 @@ signals, and task-scoped scheduling contracts. ### Requirements +- [Task completion and conversation follow-ups](requirements/task-completion.md) + - [Agent-Generated Task Titles](requirements/agent-generated-titles.md) - [Additional Session Workspace Reuse](requirements/additional-session-workspace-reuse.md) - [Task Archive Confirmation](requirements/archive-confirmation.md) @@ -128,6 +130,8 @@ signals, and task-scoped scheduling contracts. ### System design +- [Task completion and conversation follow-ups](system-design/task-completion.md) + - [Additional Session Workspace Reuse](system-design/additional-session-workspace-reuse.md) - [Environment-Owned Git Status](system-design/environment-owned-git-status.md) - [Attach Workspace Sources](system-design/attach-workspace-sources.md) diff --git a/docs/specs/tasks/requirements/subtask-completion-trigger.md b/docs/specs/tasks/requirements/subtask-completion-trigger.md index 6700d806d0..d63e12051e 100644 --- a/docs/specs/tasks/requirements/subtask-completion-trigger.md +++ b/docs/specs/tasks/requirements/subtask-completion-trigger.md @@ -36,9 +36,10 @@ orchestration logic into the parent prompt instead of the workflow system. - The event fires on a parent task's current workflow step when every direct, active child task has reached terminal completion. - Terminal task states are `COMPLETED`, `FAILED`, and `CANCELLED`. -- A child also counts as terminal when it is in a final workflow step named - `Done`, `Complete`, `Completed`, or `Approved`; entering that step persists - `tasks.state = COMPLETED`. +- Entering the final step with **Complete task when entering this step** enabled + persists `tasks.state = COMPLETED`. Step name or final position alone does not establish + completion. [Task completion](task-completion.md) owns this setting and its + legacy compatibility rules. - The event is driven by task state changes and workflow-step moves into a terminal step, including subtasks created through `create_task_kandev` with `parent_id`. @@ -47,7 +48,7 @@ orchestration logic into the parent prompt instead of the workflow system. - Workflow authors can configure the event from the workflow step editor with the "When Child Tasks Complete" transition selector. The editor explains that the trigger belongs on the parent step, waits for direct active children only, - treats terminal task states and terminal workflow-step membership as terminal, + treats persisted terminal task states as terminal, and ignores archived or ephemeral child tasks. - The event fires at most once for the same completed child set. If a child is reopened and later returns to a terminal state, the parent can receive a new @@ -78,10 +79,9 @@ FAILED CANCELLED ``` -`tasks.workflow_step_id` is also considered. A child task is complete when its -current step is the final step in that workflow and the step name is `Done`, -`Complete`, `Completed`, or `Approved`. This keeps visual board completion and -API state consistent without adding a new persistent terminal-step flag. +Workflow entry can persist a terminal task state through the explicit +completion setting. Membership in a step alone is not evidence of completion. +The parent observes the committed task outcome. ### Workflow operation idempotency @@ -152,8 +152,8 @@ does not block the trigger. ## State machine - A child enters terminal completion when its task state changes from a - non-terminal state to `COMPLETED`, `FAILED`, or `CANCELLED`, or when it moves - into a terminal workflow step. + non-terminal state to `COMPLETED`, `FAILED`, or `CANCELLED`. A move to a + completion-enabled step first persists that outcome. - On that transition or move, the system checks the child's parent. - If the child has no parent, no parent event is considered. - If any active direct child of the parent is still non-terminal, no parent event @@ -185,7 +185,8 @@ This feature adds no new permission boundary. is logged. - If sibling lookup fails, no parent trigger fires for that event and a warning is logged. A later child state transition can retry the check. -- If terminal step lookup fails, the child is evaluated by task state only. +- If step completion persistence fails, that move does not supply a successful + child outcome. The parent continues to evaluate committed task state. - If the parent has no active or primary session, no new parent session is created. The skip is logged at debug/info level. - If the workflow engine rejects or fails a configured action, the error is @@ -213,7 +214,7 @@ This feature adds no new permission boundary. `COMPLETED`, **THEN** the parent receives one `on_children_completed` trigger. - **GIVEN** a child task still has `state = REVIEW`, **WHEN** it moves into a - final workflow step named `Done`, **THEN** the child persists + final workflow step with completion enabled, **THEN** the child persists `state = COMPLETED` and the parent completion check treats that child as terminal. @@ -252,4 +253,4 @@ This feature adds no new permission boundary. - Recursive descendant aggregation. - Creating parent sessions automatically when no parent session exists. - Adding a new MCP wait primitive or long-poll API. -- Requiring every child agent to know whether it is the last sibling. \ No newline at end of file +- Requiring every child agent to know whether it is the last sibling. diff --git a/docs/specs/tasks/requirements/task-completion.md b/docs/specs/tasks/requirements/task-completion.md new file mode 100644 index 0000000000..91be1d6403 --- /dev/null +++ b/docs/specs/tasks/requirements/task-completion.md @@ -0,0 +1,126 @@ +--- +status: draft +system: tasks +created: 2026-09-09 +owners: + - kandev +--- + +# Task Completion Requirements + +## Overview + +Workflow authors control when work is complete. Users can continue a completed +task's conversation to ask follow-up questions. The task system owns both +contracts because it owns task state, workflow entry, and session identity. + +Task completion and conversation availability are independent. A completed task +does not need to reopen merely because its agent answers a question. + +## Requirements + +### REQ-TASKS-COMPLETION-001: Explicit completion on step entry + +**Intent:** Make task completion a visible, portable workflow choice. + +#### Acceptance criteria + +- **AC-TASKS-COMPLETION-001.1:** Only the workflow's final step shall expose + **Complete task when entering this step** on desktop and mobile. An adjacent + info icon shall disclose the helper description on hover, keyboard focus, + or touch. The description shall not appear as permanent text below the checkbox. +- **AC-TASKS-COMPLETION-001.2:** When a nonterminal task enters the final step + with completion enabled, the task shall become `COMPLETED`, regardless of + the step's name. Final position alone shall not complete a task. +- **AC-TASKS-COMPLETION-001.3:** Entering an unchecked step shall not complete + the task. An unchecked final step shall permit further conversation. A + non-final step shall not complete a task, even if its stored setting is enabled. +- **AC-TASKS-COMPLETION-001.4:** Renaming or reordering a step shall not change + its stored completion setting. Only the current final step's setting shall + apply. Reordering shall not transfer or enable another step's setting. New + custom steps shall default to unchecked. +- **AC-TASKS-COMPLETION-001.5:** Upgrade shall enable the setting on existing + final steps whose trimmed, case-insensitive names are Done, Complete, + Completed, or Approved. Other existing steps shall remain unchecked. +- **AC-TASKS-COMPLETION-001.6:** After upgrade, disabling that setting shall + survive restart and schema replay. Upgrade shall preserve task and session + history and shall not synthesize completion notifications. +- **AC-TASKS-COMPLETION-001.7:** Create, update, reload, duplication, templates, + workspace bootstrap, import/export, and workflow synchronization shall retain + explicit true and false values. Omitted update fields shall preserve values. +- **AC-TASKS-COMPLETION-001.8:** Legacy portable workflows shall retain their + previous completion behavior when imported or synchronized. Current exports + shall state each step's completion setting explicitly. +- **AC-TASKS-COMPLETION-001.9:** Parent completion and successful dependency + processing shall observe actual task completion. An unchecked final step + shall not trigger either process through its name or position. +- **AC-TASKS-COMPLETION-001.10:** Repeated delivery of one completion transition + shall not repeat parent actions or dependency launches. A chat follow-up shall + not create another task-completion cycle. +- **AC-TASKS-COMPLETION-001.11:** Saving a step setting shall affect future + entry. It shall not complete or reopen tasks already in that step. Explicit + movement from a completed step to an unchecked step shall retain existing + task-reopening behavior. +- **AC-TASKS-COMPLETION-001.12:** Manual, bulk, queued, automated, and initial + task entry shall apply the same completion setting. Existing clarification, + admission, cancellation, and transition-ledger rules shall remain effective. +- **AC-TASKS-COMPLETION-001.13:** The editor shall show saved and unsaved state, + use coordinated Save changes and discard, and disable editing for read-only + synchronized workflows. The phone control shall be reachable by touch. + +### REQ-TASKS-COMPLETION-002: Follow-ups in completed conversations + +**Intent:** Preserve conversation continuity after task or session completion. + +#### Acceptance criteria + +- **AC-TASKS-COMPLETION-002.1:** A `COMPLETED` session shall show **Resume** + alongside **New Agent** in its existing chat on desktop and mobile. +- **AC-TASKS-COMPLETION-002.2:** Successful Resume shall restore the composer + in the selected session. A follow-up shall reach the agent with that session's + existing conversation context and appear in the same transcript. +- **AC-TASKS-COMPLETION-002.3:** Opening, reloading, or reconnecting to a + completed chat shall not resume it or change its lifecycle state, regardless + of the open-time auto-start preference. +- **AC-TASKS-COMPLETION-002.4:** Resume and a follow-up shall preserve task and + session identity, history, workflow position, task state, agent ownership, + primary-session selection, and valid provider conversation identity. +- **AC-TASKS-COMPLETION-002.5:** Root and child tasks shall both permit user + follow-ups. Successful child completion without active clarification shall + not permanently close its interactive conversation. +- **AC-TASKS-COMPLETION-002.6:** Explicit delivery to a completed session shall + resume that exact session before accepting its turn. Task-addressed agent + messages shall prefer an eligible live session over a completed sibling. +- **AC-TASKS-COMPLETION-002.7:** Resume shall preserve message order, queue + identity, attachments, and Auto-run policy. Concurrent resume and send shall + not launch duplicate runtimes or dispatch the same accepted prompt twice. +- **AC-TASKS-COMPLETION-002.8:** Late callbacks or cleanup from the completed + execution shall not stop, complete, or clear the resumed execution's work. +- **AC-TASKS-COMPLETION-002.9:** A failed resume shall show actionable feedback + without discarding history or unsent content. A missing profile or + unrecoverable workspace shall not silently create a different conversation. +- **AC-TASKS-COMPLETION-002.10:** Active clarification ownership, answer + delivery, and question barriers shall remain unchanged. Resume shall not + reactivate historical questions or discard a current question. +- **AC-TASKS-COMPLETION-002.11:** FAILED and CANCELLED shall retain their + existing recovery and message-delivery rules. The completed-session change + shall not make ordinary sends revive those states. +- **AC-TASKS-COMPLETION-002.12:** A historical session retired by a profile + switch shall remain excluded from automatic workflow reuse after manual + Resume. Its follow-ups shall not advance the active workflow session. +- **AC-TASKS-COMPLETION-002.13:** Runtime cleanup shall retain recoverable + conversation and workspace identity. Concurrent archive, delete, or explicit + stop shall prevent a stale resume from resurrecting work. + +## Compatibility and exclusions + +This contract replaces name-based workflow completion and the permanent +completed-session lock. It does not change manual task-state APIs, failed-child +rollups, successful-dependency semantics, Office scheduler ownership, archive +policy, or intentional New Agent behavior. Existing manual FAILED/CANCELLED +recovery is preserved; these states are not newly eligible for ordinary sends. + +Automatic profile reuse remains governed by +[workflow profile sessions](workflow-profile-session-lifecycle.md). Prompt +admission during startup follows [resume prompt queue](resume-prompt-queue.md). +Provider restoration uses [agent recovery](../../agents/requirements/agent-resume-runtime-recovery.md). diff --git a/docs/specs/tasks/requirements/workflow-profile-session-lifecycle.md b/docs/specs/tasks/requirements/workflow-profile-session-lifecycle.md index 4aaf6c3afe..2f029d5f4d 100644 --- a/docs/specs/tasks/requirements/workflow-profile-session-lifecycle.md +++ b/docs/specs/tasks/requirements/workflow-profile-session-lifecycle.md @@ -54,7 +54,9 @@ starts and ends, so repeated stages use the intended context. reuse another session for that profile. - **AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.3:** When a source step selects **Complete the session**, Kandev shall complete its session during a profile - switch. Kandev shall not reuse that completed session later. + switch. Kandev shall not automatically reuse that completed session later. + Explicit conversation follow-ups follow + [task completion](task-completion.md), without changing workflow ownership. - **AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.4:** When a source step selects **Park the session**, Kandev shall stop its runtime and keep the session nonterminal. The conversation shall remain available for reuse or manual @@ -96,7 +98,9 @@ starts and ends, so repeated stages use the intended context. - Lifecycle settings on transition edges. - Reusing a session from a different agent profile. - Keeping an inactive agent process or executor backend running. -- Reviving a terminal `COMPLETED`, `FAILED`, or `CANCELLED` session. +- Automatic revival of a terminal session. Explicit completed-chat follow-ups + belong to [task completion](task-completion.md); existing FAILED/CANCELLED + recovery keeps its own rules. - Automatically removing parked or historical sessions. - Changing Office agent-session ownership or automation thread policies. - Moving conditional original-session model settings into this selector. diff --git a/docs/specs/tasks/system-design/task-completion.md b/docs/specs/tasks/system-design/task-completion.md new file mode 100644 index 0000000000..bf78c181bb --- /dev/null +++ b/docs/specs/tasks/system-design/task-completion.md @@ -0,0 +1,262 @@ +--- +status: draft +system: tasks +requirements: + - REQ-TASKS-COMPLETION-001 + - REQ-TASKS-COMPLETION-002 +--- + +# Task Completion System Design + +## Purpose and boundaries + +The task system owns workflow completion and conversation admission. Runtime +shutdown releases execution resources; it does not establish permanent loss of +conversation access. Agent runtime restoration and workspace recovery retain +their current owners and authorization checks. + +| Requirement | Design sections | +| --- | --- | +| `REQ-TASKS-COMPLETION-001` | Step contract, Migration, Entry and notification | +| `REQ-TASKS-COMPLETION-002` | Explicit resume, Follow-up ownership, Cleanup and races, User interface | + +## Step contract + +Use `CompleteTaskOnEnter bool` and `complete_task_on_enter` in JSON, YAML, and +SQL. The naming follows `cancel_triggers_turn_complete`. Responses and current +exports include false explicitly. PATCH uses presence-aware decoding: omission +preserves the value, false disables it, and explicit null is rejected. +Create defaults an omitted value to false and rejects null or non-booleans. + +Completion requires both final-step eligibility and an enabled setting. +Persist the setting on its step across reorder operations, but ignore it while +that step is non-final. The new final step uses its own saved value; reordering +does not transfer or automatically enable completion. The editor derives +visibility from the current draft order, and the backend evaluates the committed +order. Saving or discarding the order and settings uses existing coordination. + +Carry the field through both task and workflow step models, `StepDefinition`, +the embedded workflow loader, task DTO conversion, workflow controller request +types, `stepevents`, MCP schemas and handlers, and all repository projections. +`workflow_steps.complete_task_on_enter` uses the repository's integer boolean +convention with a non-null zero default. Both repositories initialize this +shared table; neither constructor may erase or bypass migration decisions. + +Frontend types, workflow API, boot hydration, WebSocket merges, step drafts, +save/rollback, workflow creation, and duplication carry the same boolean. +Partial WebSocket updates that omit it retain the saved value. + +Portable export advances to version 2. Each version-2 step requires a boolean +completion field. Import and sync accept versions 1 and 2. At the version-1 +boundary only, omitted values are materialized using the old name-and-final +predicate; explicit false always wins. Reject null in either version. +Normalize the whole workflow before converting individual steps, because legacy +final-position detection needs all steps. Export always emits version 2. +Thus current authored definitions never depend on names at runtime. + +Sync applies normalized values and includes the field in equality checks. +Version-1 sync files remain authoritative legacy input until their authors +upgrade them; local editing of synced rows stays disabled. An invalid version-2 +file follows existing freeze-and-report sync behavior. + +Built-in YAML declares the field explicitly for every step. Preserve each +template's old behavior: qualifying final steps are true; other steps are false. +Do not make the final PR step in Improve Kandev terminal merely because it is +last. Update stored template definitions and workspace bootstrap paths too. + +## Migration + +The workflow repository owns the semantic backfill, after required additive +schema changes and before serving requests or starting sync. Task repository +projection/bootstrap initialization adds the same column before its own writers +need it. It does not independently infer or backfill completion. + +Use one transaction and a durable `kandev_meta` key, +`workflow_complete_task_on_enter_backfill_v1`. Inside the transaction, read the +marker, backfill legacy steps and stored template JSON, then record completion. +Use the existing portable Go normalization for trimmed case-insensitive names +and final position. A final step has no sibling with a greater position, +matching `GetNextStepByPosition`; tied maximum positions retain that definition. +Preserve row timestamps and IDs. The transaction rolls back the marker and all +backfill writes on failure. Startup fails if this required migration fails. + +Replay skips the semantic backfill after the marker. It must not use +`WHERE complete_task_on_enter = 0` as a recurring migration. Fresh startup writes +explicit built-in values and records the marker before ordinary user writes. +Template backfill preserves unrelated JSON fields and respects explicit values. +Required-store conformance covers actual constructor order, fresh SQLite and +PostgreSQL, old-schema upgrade, replay after disabling, and failure/retry. +Historical release fixtures remain truthful snapshots; add assertions or a +dedicated legacy fixture rather than editing old releases to contain new columns. + +## Entry and notification + +Replace the name test in `IsTerminalStep` with the explicit boolean while +retaining final-position eligibility. A step completes work only when it has +no successor and its setting is true. +Keep the old name predicate private to legacy conversion/migration. Audit all +callers, including task creation, `task/service.service_workflow`, +`orchestrator.workflow_store`, queued moves, automated transitions, and +`task_launch_workflow`. Every caller must resolve the actual successor or +equivalent final-position evidence; passing nil without a lookup is not proof. + +Persist step and task-state changes through existing guarded transition and +ledger operations. A checked final entry completes a nonterminal task; an +unchecked or non-final entry follows existing nonterminal/reopen behavior. +Do not convert FAILED or +CANCELLED to success. Saving the checkbox does not reevaluate resident tasks. +Do not add transition actions or replay on-enter during Resume. + +Parent rollups and dependencies use committed task state. Remove name- or +step-membership fallback from `annotateTerminalChildSteps` and terminal receipt +handling. Preserve `DependencyStatusForTask`'s existing state-only resolution. +Completion events follow the +successful state write, never a speculative target lookup. Preserve existing +operation deduplication, failure/cancellation rollups, archived-child filtering, +and success-only dependency launches. Upgrade does not rewrite resident tasks +or fire old completion events; pre-existing persisted completion stays intact. + +## Explicit resume + +Reuse `session.recover` with `action: resume`. Admit completed recovery only +through this explicit operation or a session-pinned follow-up send. Carry a +narrow internal completed-resume permission through `LaunchSession` and +`executor.ResumeOptions`; automatic `session.ensure`, status reads, generic +open-time launch, and startup recovery never infer that permission. + +Authorize the task/session pair and prompt access before side effects. Under +the existing lifecycle/cancel guards, reload the exact session and task, verify +archive and runtime ownership, and claim the current completed state/version. +Require the expected execution generation as well as state where a callback +can replace the session during recovery. Do not weaken global terminal guards. + +Extend `allowsSessionStartingRecovery` only for an explicitly claimed completed +resume. Permit absent `executors_running` after cleanup; retain recoverable task +environment and profile checks. Reuse stale-execution cleanup, provider token +restoration, and native/CLI/history fallback. Persist `STARTING` and clear +`CompletedAt` through the guarded writer before credentials are issued. Retain +prior execution and turn history; do not clear ACP/provider identity. + +At readiness, settle into the existing input-capable state without sending a +synthetic workflow or task-description prompt. Preserve current initial-message +backfill only for genuinely absent launch history. The next user send starts +one new conversational turn after the old one. No new session ID or queue +incarnation is allocated by Resume. + +`message_target_session.go` continues to prefer live primary, then live +fallback. Completed siblings must not shadow a live session. With no live +session, a completed primary, or the newest completed session if no primary +exists, can be selected and pinned. An explicit completed `session_id` always +targets that session. Authorization and validation precede the same resume +admission, which runs before `ProcessOnTurnStart` and message persistence. +FAILED/CANCELLED dispatch continues to reject with its existing error code. + +## Follow-up ownership + +A message on a completed task is conversation work. Runtime running, waiting, +failed, and finished events do not reopen that task or replay workflow entry, +turn-start, turn-complete, completion-signal, or parent notification effects. +Existing explicit task moves/state APIs remain the way to reopen workflow work. + +For an explicitly resumed completed session on a still-active task, persist a +session metadata marker `completion_follow_up` in the guarded resume claim. +It records conversational-only ownership independently of the mutable session +state. Such a session stays excluded from automatic profile reuse and cannot +become primary merely because it resumes. Automatic workflow triggers and +runtime task-state reconciliation skip it. An explicit workflow handoff may +clear the marker only while establishing that session's workflow ownership. +An ordinary send, page activation, or automatic reuse lookup cannot clear it. + +Pinned user and MCP sends, queued dispatch, streaming completion, agent exit, +and failure publication consume the same ownership predicate. This protects a +retired profile conversation even after its state becomes `WAITING_FOR_INPUT`. +Preserve primary IDs, profile routing, step history, and current queue policy. + +## Cleanup and races + +For successful task completion, stop collapsing an ordinary child conversation +to `COMPLETED` in `setSessionWaitingForInputIfRequestedWithHook`. Settle it with +the existing root-task waiting behavior. Keep task status completed and retain +the fail-closed idle resource reclamation. Do not change failed/cancelled task +receipt rules through this branch. + +Use current-turn clarification lookup; any active clarification retains its +existing barrier and late-answer path. Historical answered or expired bundles +remain history. A lookup error cannot justify closing the conversation. + +Cleanup and resumed execution must use the same lifecycle ownership boundary. +An old receipt, stop callback, stream frame, or reclaim operation carries its +execution identity; after a newer resume claim it cannot clear queues or stop +the replacement runtime. Preserve retired-execution tombstones. Release guards +before waiting for callbacks that need those guards. + +Queue admission and readiness use the existing queue-incarnation and dispatch +reservation contract. Never clear pending messages solely because the old +execution completed. Duplicate Resume joins or returns the existing attempt. +A later stop/archive/delete wins over stale recovery writes. A failed launch +uses existing recoverable error state without changing completed task state; +retain the follow-up marker for retry and preserve queued/unsent content. + +## User interface + +`SessionStoppedBanner` completed mode has primary Resume and secondary New +Agent. Reuse `useSessionRecoveryActions` and existing recovery error feedback. +Show busy state, disable duplicate actions, and restore the composer from the +authoritative session update. Missing profile feedback stays visible. New +Agent retains its dialog and intentional new-conversation behavior. + +Keep the completed-chat open-time gate in session-resumption hooks. Extend +recovery hook tests for response success/failure, session switching while a +request is pending, and busy cleanup. Do not let a late response modify another +chat's recovery feedback or selected session. + +Show the completion checkbox only in the final step's entry/general settings, +outside the conditional turn-complete controls. It remains available when the +final step has no actions. Put the description inside an adjacent info icon +using the existing HelpTip interaction: hover or focus on desktop, tap on touch. +Do not render the description permanently below the checkbox. Suggested text: +"Marks the task complete when it enters this final step. You can still continue +the conversation." Give the icon a localized accessible name. Use coordinated +dirty/save/discard state and read-only sync treatment. Localize all copy. + +### Mobile composition + +The existing `task-layout.tsx` phone layout and inline stopped-session banner +are the chat exemplar. Keep the transcript's single scroll owner and the +composer area's safe-area spacing. Stack Resume then New Agent on phones; +retain compact side-by-side buttons on fine-pointer desktop. Phone/coarse +pointer actions have at least 44 px active height; no unconditional desktop +`min-h-11` sizing is introduced. + +The step editor's `CancelCompletionToggle` supplies the checkbox/label pattern; +the existing mobile workflow step selector supplies focused navigation. Use a +44 px tappable label and a touch-accessible info icon. Disclose wrapping helper +text through the existing touch help surface; do not require hover or add a +new standalone modal. Opening help must not toggle the checkbox. +An inline setting fits this short choice. Both viewports share draft and save +logic. Preserve keyboard focus, one settings scroll region, no horizontal page +overflow, and access to Save changes above safe-area insets. + +## Verification and observability + +Use barrier-based Go tests for resume/stop, reclaim/resume, stale receipt/new +execution, double Resume, and readiness/queue admission. Assert the preserved +task, primary session, provider token, history, queue order, and emitted events. +Test both database dialects at migration boundaries. Use existing structured +logs with task/session/execution identity and resume outcome; do not log tokens. + +Desktop and mobile Playwright tests save/reload the final-step checkbox, complete +checked final steps, send from unchecked final steps, and Resume a persisted +completed chat. Reorder tests prove the control moves to the current final +step and a previously checked non-final step cannot complete work. Help tests +prove hover/focus/tap disclosure and that opening help does not toggle the value. +Assert a second agent response, unchanged session count and history, and no +task reopen or duplicate completion. Include a retired non-primary session and +active clarification regression. Run against freshly built production assets. + +## Related decisions + +- [Separate task completion from conversation availability](../../../decisions/2026-09-09-task-completion-conversation-availability.md) +- [Replayable migrations](../../../decisions/0027-replayable-schema-migrations.md) +- [Profile-switch session policy](../../../decisions/2026-08-31-workflow-profile-session-switch-policy.md) +- [Current-turn clarification ownership](../../../decisions/2026-08-14-current-turn-clarification-ownership.md) diff --git a/docs/specs/tasks/system-design/task-dependencies.md b/docs/specs/tasks/system-design/task-dependencies.md index fa62c037d6..b6ff97763a 100644 --- a/docs/specs/tasks/system-design/task-dependencies.md +++ b/docs/specs/tasks/system-design/task-dependencies.md @@ -155,10 +155,11 @@ model can ask — the answer is always "the last one to resolve". denormalized `is_blocked` column: a stale copy of it would gate launches incorrectly, which is the one failure this feature must not have. -A predecessor is **resolved** when it satisfies the existing successful-completion -convention: `state = COMPLETED`, or resident in a final workflow step whose name -is `Done`, `Complete`, `Completed`, or `Approved` (`wfmodels.IsTerminalStepName`, -which also persists `state = COMPLETED`). +A predecessor is **resolved** when its persisted state is `COMPLETED`. +[Task completion](task-completion.md) owns the explicit step-entry setting +and compatibility backfill. Step name, position, or membership alone does not +resolve a dependency. A conversation follow-up does not create another +dependency-completion cycle. A predecessor is **failed** when `state` is `FAILED` or `CANCELLED`. diff --git a/docs/specs/tasks/system-design/workflow-profile-session-lifecycle.md b/docs/specs/tasks/system-design/workflow-profile-session-lifecycle.md index 7092d8c5e5..5370caecc5 100644 --- a/docs/specs/tasks/system-design/workflow-profile-session-lifecycle.md +++ b/docs/specs/tasks/system-design/workflow-profile-session-lifecycle.md @@ -137,7 +137,9 @@ callback cannot advance the destination step. A reuse candidate that becomes terminal before promotion is not revived. The orchestrator creates a new destination when the start setting is `reuse`. -Terminal sessions remain historical endpoints. +Completed sessions remain excluded from automatic workflow reuse. Explicit +conversation follow-ups use [task completion](task-completion.md) and do not +promote the historical session or advance the current workflow. Changing a step setting affects later transitions only.