diff --git a/apps/backend/internal/backendapp/adapters.go b/apps/backend/internal/backendapp/adapters.go index 301fe66ac4..3f1a7ea7e3 100644 --- a/apps/backend/internal/backendapp/adapters.go +++ b/apps/backend/internal/backendapp/adapters.go @@ -1044,6 +1044,47 @@ func (a *messageCreatorAdapter) UpdateClarificationMessage(ctx context.Context, return a.svc.UpdateClarificationMessageForQuestion(ctx, sessionID, pendingID, questionID, status, answer) } +func (a *messageCreatorAdapter) CompleteActiveClarificationBundle( + ctx context.Context, + pendingID, status string, + responses map[string]interface{}, +) ([]*models.Message, bool, error) { + return a.svc.CompleteActiveClarificationBundle(ctx, pendingID, status, responses) +} + +func (a *messageCreatorAdapter) FinalizeClarificationResponseDelivery( + ctx context.Context, + pendingID, terminalStatus string, + claimedMessages []*models.Message, +) ([]*models.Message, bool, error) { + return a.svc.FinalizeClarificationResponseDelivery( + ctx, + pendingID, + terminalStatus, + claimedMessages, + ) +} + +func (a *messageCreatorAdapter) RestoreActiveClarificationBundle( + ctx context.Context, + pendingID, terminalStatus string, + claimedMessages []*models.Message, +) ([]*models.Message, bool, error) { + return a.svc.RestoreActiveClarificationBundle( + ctx, + pendingID, + terminalStatus, + claimedMessages, + ) +} + +func (a *messageCreatorAdapter) PublishClarificationBundleUpdates( + ctx context.Context, + messages []*models.Message, +) error { + return a.svc.PublishClarificationBundleUpdates(ctx, messages) +} + // CreateAgentMessageStreaming creates a new agent message with a pre-generated ID. // This is used for real-time streaming where content arrives incrementally. func (a *messageCreatorAdapter) CreateAgentMessageStreaming(ctx context.Context, messageID, taskID, content, agentSessionID, turnID string) error { diff --git a/apps/backend/internal/backendapp/boot_state.go b/apps/backend/internal/backendapp/boot_state.go index 46cb033521..c8bf5dc6f0 100644 --- a/apps/backend/internal/backendapp/boot_state.go +++ b/apps/backend/internal/backendapp/boot_state.go @@ -708,11 +708,12 @@ func (b bootStateBuilder) taskDTOsWithSessionInfo(ctx context.Context, tasks []* pendingActionsBySession = map[string]taskmodels.TaskPendingAction{} } if summaryErr == nil && pendingErr == nil { - statusSummaries, err = b.p.taskSvc.HydrateMissingTaskStatusSummaries( + reconciledSummaries, reconcileErr := b.p.taskSvc.ReconcileTaskStatusSummaries( ctx, tasks, sessionsByTask, pendingActionsBySession, statusSummaries, ) - if err != nil { - b.logBootError("repair missing task status summaries", err) + statusSummaries = reconciledSummaries + if reconcileErr != nil { + b.logBootError("reconcile task status summaries", reconcileErr) } } // Stamp the authoritative per-task queued prompt count onto every summary so @@ -768,7 +769,7 @@ func (b bootStateBuilder) taskDTOsWithSessionInfo(ctx context.Context, tasks []* taskdto.EnrichTaskForegroundActivity(&dto, sessions, b.p.orchestratorSvc) } taskdto.EnrichTaskDependencies(&dto, bootDependencyProjection(dependencyViews[task.ID]), task) - dto.StatusSummary = statusSummaries[task.ID] + taskdto.EnrichTaskStatusSummary(&dto, task.ID, statusSummaries) if dto.StatusSummary != nil { switch { case queuedErr != nil: diff --git a/apps/backend/internal/backendapp/gateway.go b/apps/backend/internal/backendapp/gateway.go index 4f7bd7926e..0cbf5a5210 100644 --- a/apps/backend/internal/backendapp/gateway.go +++ b/apps/backend/internal/backendapp/gateway.go @@ -32,6 +32,7 @@ import ( terminalrepo "github.com/kandev/kandev/internal/terminal/repository" terminalservice "github.com/kandev/kandev/internal/terminal/service" userservice "github.com/kandev/kandev/internal/user/service" + v1 "github.com/kandev/kandev/pkg/api/v1" ws "github.com/kandev/kandev/pkg/websocket" ) @@ -279,12 +280,31 @@ func provideGateway( go gateway.Hub.Run(ctx) gateways.RegisterTaskNotifications(ctx, eventBus, gateway.Hub, log) if taskRepo != nil && eventBus != nil { + var loadPullRequests statussummary.PullRequestLoader + if githubSvc != nil { + reader := &githubTaskStatusSummaryPRReader{gh: githubSvc} + loadPullRequests = func(ctx context.Context, taskID string) ([]statussummary.PullRequestInput, error) { + byTask, err := reader.ListTaskStatusSummaryPullRequests(ctx, []string{taskID}) + if err != nil { + return nil, err + } + return byTask[taskID], nil + } + } + activityProvider, countQueuedPrompts := taskStatusRuntimeProviders(orchestratorSvc) projector := statussummary.NewProjector(statussummary.ProjectorConfig{ Store: taskRepo, EventBus: eventBus, + LoadSessionObservations: func(ctx context.Context, taskID string) (statussummary.SessionObservationSnapshot, error) { + return loadTaskSessionObservations(ctx, taskRepo, activityProvider, taskID) + }, + LoadPendingActions: func(ctx context.Context, taskID string) (map[string]string, error) { + return loadTaskPendingActions(ctx, taskRepo, taskID) + }, LoadGitObservations: func(ctx context.Context, taskID string) ([]statussummary.GitObservation, error) { return loadTaskGitObservations(ctx, taskRepo, taskID) }, + LoadPullRequests: loadPullRequests, ResolveWorkspace: func(ctx context.Context, taskID string) (string, error) { task, err := taskRepo.GetTask(ctx, taskID) if err != nil { @@ -295,11 +315,10 @@ func provideGateway( } return task.WorkspaceID, nil }, - CountQueuedPrompts: func(ctx context.Context, taskID string) (int, error) { - return orchestratorSvc.GetMessageQueue().CountPendingByTask(ctx, taskID) - }, - Logger: log, + CountQueuedPrompts: countQueuedPrompts, + Logger: log, }) + taskSvc.SetTaskStatusSummaryEventProjector(projector) if err := projector.Start(ctx); err != nil { log.Error("failed to start task status summary projector", zap.Error(err)) } @@ -401,6 +420,102 @@ func provideGateway( return gateway, notificationSvc, notificationCtrl, terminalSvc, nil } +type taskStatusActivityProvider interface { + ForegroundActivity(sessionID string) v1.ForegroundActivity + ActiveSubagentCount(sessionID string) int +} + +func taskStatusRuntimeProviders( + orchestratorSvc *orchestrator.Service, +) (taskStatusActivityProvider, func(context.Context, string) (int, error)) { + if orchestratorSvc == nil { + return nil, nil + } + queue := orchestratorSvc.GetMessageQueue() + if queue == nil { + return orchestratorSvc, nil + } + return orchestratorSvc, queue.CountPendingByTask +} + +func loadTaskSessionObservations( + ctx context.Context, + taskRepo *sqliterepo.Repository, + activityProvider taskStatusActivityProvider, + taskID string, +) (statussummary.SessionObservationSnapshot, error) { + sessions, err := taskRepo.ListTaskSessions(ctx, taskID) + if err != nil { + return statussummary.SessionObservationSnapshot{}, err + } + snapshot := statussummary.SessionObservationSnapshot{ + Sessions: make([]statussummary.RebuildSession, 0, len(sessions)), + ActivityObserved: activityProvider != nil, + ErrorsObserved: true, + } + for _, session := range sessions { + if session == nil || session.ID == "" { + continue + } + input := statussummary.RebuildSession{ + ID: session.ID, + State: string(session.State), + IsPrimary: session.IsPrimary, + } + if activityProvider != nil { + activity := activityProvider.ForegroundActivity(session.ID) + if session.State == models.TaskSessionStateRunning || activity == v1.ForegroundActivityBackground { + input.ForegroundActivity = string(activity) + } + input.ActiveSubagentCount = maxNonNegative(activityProvider.ActiveSubagentCount(session.ID)) + } + if lastError, ok := models.LoadLastAgentError(session.Metadata); ok && !lastError.IsDismissed() { + input.ActiveError = &statussummary.ActiveErrorSummary{ + SessionID: session.ID, + Stamp: lastError.Stamp(), + OccurredAt: lastError.OccurredAt, + Preview: lastError.Message, + } + } + snapshot.Sessions = append(snapshot.Sessions, input) + } + return snapshot, nil +} + +func loadTaskPendingActions( + ctx context.Context, + taskRepo *sqliterepo.Repository, + taskID string, +) (map[string]string, error) { + sessions, err := taskRepo.ListTaskSessions(ctx, taskID) + if err != nil { + return nil, err + } + // Session state and pending actions are separate snapshots. A concurrent + // transition can briefly omit an owner; the next lifecycle/message event + // rebuilds the summary from fresh snapshots. + sessionIDs := make([]string, 0, len(sessions)) + for _, session := range sessions { + if session == nil || (session.State != models.TaskSessionStateRunning && + session.State != models.TaskSessionStateWaitingForInput) { + continue + } + sessionIDs = append(sessionIDs, session.ID) + } + if len(sessionIDs) == 0 { + return map[string]string{}, nil + } + actions, err := taskRepo.GetPendingActionsBySessionIDs(ctx, sessionIDs) + if err != nil { + return nil, err + } + result := make(map[string]string, len(actions)) + for sessionID, action := range actions { + result[sessionID] = string(action) + } + return result, nil +} + func loadTaskGitObservations( ctx context.Context, taskRepo *sqliterepo.Repository, diff --git a/apps/backend/internal/backendapp/helpers.go b/apps/backend/internal/backendapp/helpers.go index 2e94b237ce..18c22e6db8 100644 --- a/apps/backend/internal/backendapp/helpers.go +++ b/apps/backend/internal/backendapp/helpers.go @@ -577,8 +577,9 @@ func registerRoutes(p routeParams) { // Per-user task scoping for plan reads/writes (opt-in auth). planService.SetTaskAuthorizer(p.taskSvc.AuthorizeTaskAccess) clarificationStore := clarification.NewStore(2 * time.Hour) - clarificationCanceller := clarification.NewCanceller(clarificationStore, p.taskRepo, p.eventBus, p.log) + clarificationCanceller := clarification.NewCanceller(clarificationStore, p.taskRepo, p.taskSvc, p.log) p.orchestratorSvc.SetClarificationCanceller(clarificationCanceller) + p.taskSvc.SetClarificationCanceller(clarificationCanceller) // Wire pending clarification requests into the office inbox. if p.services.OfficeSvcs != nil && p.services.OfficeSvcs.Dashboard != nil { @@ -1132,7 +1133,16 @@ func registerSecondaryRoutes( agentcapabilities.RegisterRoutes(p.router, p.hostUtilityMgr, p.log) p.log.Debug("Registered Agent Capabilities handlers (HTTP)") - clarification.RegisterRoutes(p.router, clarificationStore, p.gateway.Hub, p.msgCreator, p.taskRepo, p.eventBus, p.log) + clarification.RegisterRoutes( + p.router, + clarificationStore, + p.gateway.Hub, + p.msgCreator, + p.taskRepo, + p.eventBus, + p.orchestratorSvc, + p.log, + ) p.log.Debug("Registered Clarification handlers (HTTP)") if p.secretsSvc != nil { diff --git a/apps/backend/internal/backendapp/services.go b/apps/backend/internal/backendapp/services.go index ffad7d46cc..09a35b202d 100644 --- a/apps/backend/internal/backendapp/services.go +++ b/apps/backend/internal/backendapp/services.go @@ -85,6 +85,10 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories return ok })) workflowSvc := workflowservice.NewService(repos.Workflow, log) + pendingActionProjectionEpoch, err := repos.Task.NextPendingActionProjectionEpoch(context.Background()) + if err != nil { + return nil, nil, fmt.Errorf("allocate pending-action projection epoch: %w", err) + } taskSvc := taskservice.NewService( taskservice.Repos{ Workspaces: repos.Task, @@ -115,6 +119,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories TaskWorktreeRoots: []string{filepath.Join(cfg.ResolvedHomeDir(), "tasks")}, }, ) + taskSvc.SetPendingActionProjectionEpoch(pendingActionProjectionEpoch) taskSvc.SetSecretStore(userSecretStore) if deleter, ok := userSecretStore.(taskservice.WorkspaceSecretDeleter); ok { taskSvc.SetWorkspaceSecretDeleter(deleter) diff --git a/apps/backend/internal/backendapp/status_summary_adapter.go b/apps/backend/internal/backendapp/status_summary_adapter.go index 1f7fcfd084..beae5f3140 100644 --- a/apps/backend/internal/backendapp/status_summary_adapter.go +++ b/apps/backend/internal/backendapp/status_summary_adapter.go @@ -2,7 +2,7 @@ package backendapp import ( "context" - "strconv" + "fmt" "github.com/kandev/kandev/internal/github" "github.com/kandev/kandev/internal/task/statussummary" @@ -31,9 +31,9 @@ func (r *githubTaskStatusSummaryPRReader) ListTaskStatusSummaryPullRequests( if pr == nil { continue } - key := pr.ID + key := taskStatusSummaryPRKey(pr) if key == "" { - key = pr.RepositoryID + ":" + strconv.Itoa(pr.PRNumber) + continue } requiredReviews := 0 if pr.RequiredReviews != nil { @@ -57,3 +57,24 @@ func (r *githubTaskStatusSummaryPRReader) ListTaskStatusSummaryPullRequests( } return result, nil } + +// taskStatusSummaryPRKey mirrors the live projector's event identity so an +// update replaces its rehydrated observation instead of creating a duplicate. +func taskStatusSummaryPRKey(pr *github.TaskPR) string { + if pr == nil { + return "" + } + if pr.RepositoryID != "" && pr.PRNumber > 0 { + return fmt.Sprintf("%s#%d", pr.RepositoryID, pr.PRNumber) + } + if pr.PRURL != "" { + return pr.PRURL + } + if pr.RepositoryID != "" { + return pr.RepositoryID + } + if pr.PRNumber > 0 { + return fmt.Sprintf("#%d", pr.PRNumber) + } + return "" +} diff --git a/apps/backend/internal/backendapp/status_summary_adapter_test.go b/apps/backend/internal/backendapp/status_summary_adapter_test.go new file mode 100644 index 0000000000..0f1aff82a0 --- /dev/null +++ b/apps/backend/internal/backendapp/status_summary_adapter_test.go @@ -0,0 +1,62 @@ +package backendapp + +import ( + "testing" + + "github.com/kandev/kandev/internal/github" +) + +func TestTaskStatusSummaryPRKeyMatchesLiveEventIdentity(t *testing.T) { + tests := []struct { + name string + pr *github.TaskPR + want string + }{ + { + name: "repository and number", + pr: &github.TaskPR{ID: "association-1", RepositoryID: "repo-a", PRNumber: 42, PRURL: "https://example.test/42"}, + want: "repo-a#42", + }, + { + name: "legacy URL", + pr: &github.TaskPR{ID: "association-2", PRNumber: 42, PRURL: "https://example.test/42"}, + want: "https://example.test/42", + }, + { + name: "repository only", + pr: &github.TaskPR{ID: "association-3", RepositoryID: "repo-a"}, + want: "repo-a", + }, + { + name: "number only", + pr: &github.TaskPR{ID: "association-4", PRNumber: 42}, + want: "#42", + }, + { + name: "association without source identity", + pr: &github.TaskPR{ID: "association-5"}, + want: "", + }, + { + name: "nil pull request", + want: "", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := taskStatusSummaryPRKey(test.pr); got != test.want { + t.Fatalf("taskStatusSummaryPRKey() = %q, want %q", got, test.want) + } + }) + } +} + +func TestTaskStatusRuntimeProvidersTreatNilOrchestratorAsAbsent(t *testing.T) { + activityProvider, countQueuedPrompts := taskStatusRuntimeProviders(nil) + if activityProvider != nil { + t.Fatalf("activity provider = %#v, want nil", activityProvider) + } + if countQueuedPrompts != nil { + t.Fatal("queued prompt counter should be nil without an orchestrator") + } +} diff --git a/apps/backend/internal/backendapp/status_summary_boot_test.go b/apps/backend/internal/backendapp/status_summary_boot_test.go index e4a44c4106..62e3ae490f 100644 --- a/apps/backend/internal/backendapp/status_summary_boot_test.go +++ b/apps/backend/internal/backendapp/status_summary_boot_test.go @@ -10,7 +10,7 @@ import ( "github.com/kandev/kandev/internal/webapp" ) -func TestBootKanbanSnapshotHydratesTaskStatusSummaryInBatch(t *testing.T) { +func TestBootKanbanSnapshotReconcilesExistingPendingSummary(t *testing.T) { harness := newBootStateTestHarness(t) ctx := context.Background() workspaces, err := harness.taskSvc.ListWorkspaces(ctx) @@ -74,9 +74,15 @@ func TestBootKanbanSnapshotHydratesTaskStatusSummaryInBatch(t *testing.T) { if item.ID != task.ID { continue } - if item.StatusSummary == nil || item.StatusSummary.Revision != 7 || item.StatusSummary.PendingAction != "clarification" { + if item.StatusSummary == nil || item.StatusSummary.Revision != 8 || item.StatusSummary.PendingAction != "" || + item.StatusSummary.ForegroundActivity != "background" { t.Fatalf("boot status summary = %#v", item.StatusSummary) } + persisted, loadErr := harness.taskRepo.LoadTaskStatusSummaries(ctx, []string{task.ID}) + if loadErr != nil || persisted[task.ID] == nil || persisted[task.ID].Revision != 8 || + persisted[task.ID].PendingAction != "" { + t.Fatalf("persisted reconciled summary = %#v, err=%v", persisted[task.ID], loadErr) + } return } t.Fatalf("task %q missing from boot snapshot: %s", task.ID, raw) diff --git a/apps/backend/internal/backendapp/turn_adapters.go b/apps/backend/internal/backendapp/turn_adapters.go index 8bcdc07686..4646b165fe 100644 --- a/apps/backend/internal/backendapp/turn_adapters.go +++ b/apps/backend/internal/backendapp/turn_adapters.go @@ -28,6 +28,33 @@ func (a *turnServiceAdapter) StartTurn(ctx context.Context, sessionID string) (* return a.svc.StartTurn(ctx, sessionID) } +func (a *turnServiceAdapter) ReserveTurn( + ctx context.Context, + sessionID string, + recovery *models.PromptDispatchRecovery, +) (*models.Turn, error) { + return a.svc.ReserveTurn(ctx, sessionID, recovery) +} + +func (a *turnServiceAdapter) PublishReservedTurn(ctx context.Context, turn *models.Turn) error { + return a.svc.PublishReservedTurn(ctx, turn) +} + +func (a *turnServiceAdapter) MarkReservedTurnDispatchAttempted(ctx context.Context, turn *models.Turn) error { + return a.svc.MarkReservedTurnDispatchAttempted(ctx, turn) +} + +func (a *turnServiceAdapter) RollbackReservedTurn( + ctx context.Context, + sessionID, turnID string, +) (bool, error) { + return a.svc.RollbackReservedTurn(ctx, sessionID, turnID) +} + +func (a *turnServiceAdapter) ReconcileUnpublishedPromptTurns(ctx context.Context) (int, error) { + return a.svc.ReconcileUnpublishedPromptTurns(ctx) +} + func (a *turnServiceAdapter) CompleteTurn(ctx context.Context, turnID string) error { return a.svc.CompleteTurn(ctx, turnID) } @@ -44,6 +71,14 @@ func (a *turnServiceAdapter) UpdateTurn(ctx context.Context, turn *models.Turn) return a.svc.UpdateTurn(ctx, turn) } +func (a *turnServiceAdapter) PatchTurnMetadata( + ctx context.Context, + sessionID, turnID string, + updates map[string]interface{}, +) error { + return a.svc.PatchTurnMetadata(ctx, sessionID, turnID, updates) +} + func (a *turnServiceAdapter) AbandonOpenTurns(ctx context.Context, sessionID string) error { return a.svc.AbandonOpenTurns(ctx, sessionID) } diff --git a/apps/backend/internal/clarification/canceller.go b/apps/backend/internal/clarification/canceller.go index 9dc07891f5..b805b3773a 100644 --- a/apps/backend/internal/clarification/canceller.go +++ b/apps/backend/internal/clarification/canceller.go @@ -3,201 +3,140 @@ package clarification import ( "context" - "time" + "errors" + "fmt" "github.com/kandev/kandev/internal/common/logger" - "github.com/kandev/kandev/internal/events" - "github.com/kandev/kandev/internal/events/bus" taskmodels "github.com/kandev/kandev/internal/task/models" "go.uber.org/zap" ) +type clarificationBundlePublisher interface { + PublishClarificationBundleUpdates(context.Context, []*taskmodels.Message) error +} + // Canceller wraps Store with message-update side effects. // When the agent's turn completes, it cancels pending clarifications // and marks the database messages with agent_disconnected metadata. type Canceller struct { - store *Store - repo messageStore - eventBus EventBus - logger *logger.Logger + store *Store + repo cancellationMessageStore + publisher clarificationBundlePublisher + logger *logger.Logger } // NewCanceller creates a Canceller. -func NewCanceller(store *Store, repo messageStore, eventBus EventBus, log *logger.Logger) *Canceller { +func NewCanceller( + store *Store, + repo cancellationMessageStore, + publisher clarificationBundlePublisher, + log *logger.Logger, +) *Canceller { return &Canceller{ - store: store, - repo: repo, - eventBus: eventBus, - logger: log.WithFields(zap.String("component", "clarification-canceller")), + store: store, + repo: repo, + publisher: publisher, + logger: log.WithFields(zap.String("component", "clarification-canceller")), } } -// isTerminalStatus returns true for statuses that should not be overwritten. -func isTerminalStatus(status string) bool { - switch status { - case string(StatusAnswered), string(StatusExpired), string(StatusRejected), string(StatusCancelled): - return true +func (c *Canceller) detachSessionBundles(ctx context.Context, sessionID string) (int, error) { + // Draining live waiters and durable detachment are separate concerns. The + // repository owns the atomic current-turn/status claim for persisted rows. + c.store.CancelSession(sessionID) + writeCtx, cancel := clarificationPersistenceContext(ctx) + defer cancel() + messages, err := c.repo.DetachActiveClarificationMessagesBySessionID(writeCtx, sessionID) + if err != nil { + c.logger.Error("failed to detach current clarification bundles", + zap.String("session_id", sessionID), + zap.Error(err)) + return 0, fmt.Errorf("detach current clarification bundles: %w", err) } - return false + return c.publishChangedBundles(writeCtx, messages), nil } -// markMessagesDetached unblocks WaitForResponse and keeps the clarification -// interactive: status stays pending and agent_disconnected is set so the UI -// can route a late answer through the event fallback path. -func (c *Canceller) markMessagesDetached(ctx context.Context, msgs []*taskmodels.Message, pendingID string) { - writeCtx := context.WithoutCancel(ctx) - for _, msg := range msgs { - if msg.Metadata == nil { - msg.Metadata = map[string]any{} +func (c *Canceller) expireSessionBundles(ctx context.Context, sessionID string) (int, error) { + // The initial read discovers bundle identities only. Each transition below + // rechecks that exact pending ID, current-turn ownership, and pending status + // inside one UPDATE serialized with answers and successor turns. + c.store.CancelSession(sessionID) + writeCtx, cancel := clarificationPersistenceContextPreservingDeadline(ctx) + defer cancel() + messages, err := c.repo.FindActiveClarificationMessagesBySessionID(writeCtx, sessionID) + if err != nil { + c.logger.Warn("failed to load current clarification bundles for expiry", + zap.String("session_id", sessionID), + zap.Error(err)) + return 0, fmt.Errorf("load current clarification bundles for expiry: %w", err) + } + pendingIDs := make(map[string]struct{}) + for _, message := range messages { + if pendingID := stringFromMetadata(message.Metadata, "pending_id"); pendingID != "" { + pendingIDs[pendingID] = struct{}{} } - if current, _ := msg.Metadata["status"].(string); isTerminalStatus(current) { - continue - } - msg.Metadata["agent_disconnected"] = true - if err := c.repo.UpdateMessage(writeCtx, msg); err != nil { - c.logger.Warn("failed to update message with detached status", + } + changedBundles := 0 + var expiryErr error + // Bundle expiry is intentionally best effort, not all-or-nothing. A bundle + // that commits stays terminal even if a sibling write fails; the joined + // error tells the caller to retry, and that retry only sees bundles still + // pending under the repository's guarded transition. + for pendingID := range pendingIDs { + changed, expireErr := c.repo.ExpireActiveClarificationBundle(writeCtx, sessionID, pendingID) + if expireErr != nil { + c.logger.Warn("failed to expire current clarification bundle", + zap.String("session_id", sessionID), zap.String("pending_id", pendingID), - zap.String("message_id", msg.ID), - zap.Error(err)) + zap.Error(expireErr)) + expiryErr = errors.Join( + expiryErr, + fmt.Errorf("expire clarification bundle %s: %w", pendingID, expireErr), + ) continue } - c.publishMessageUpdated(writeCtx, msg) + if len(changed) == 0 { + continue + } + changedBundles++ + c.publishBundleUpdates(writeCtx, changed) } + return changedBundles, expiryErr } -// markMessagesExpired updates the given messages to status=expired and publishes -// a message.updated event for each one. It is idempotent: already-terminal -// messages are skipped. -func (c *Canceller) markMessagesExpired(ctx context.Context, msgs []*taskmodels.Message, pendingID string) { - writeCtx := context.WithoutCancel(ctx) - for _, msg := range msgs { - if msg.Metadata == nil { - msg.Metadata = map[string]any{} +func (c *Canceller) publishChangedBundles(ctx context.Context, messages []*taskmodels.Message) int { + bundles := make(map[string]struct{}) + for _, message := range messages { + if pendingID := stringFromMetadata(message.Metadata, "pending_id"); pendingID != "" { + bundles[pendingID] = struct{}{} } - if current, _ := msg.Metadata["status"].(string); isTerminalStatus(current) { - continue - } - msg.Metadata["agent_disconnected"] = true - msg.Metadata["status"] = string(StatusExpired) - if err := c.repo.UpdateMessage(writeCtx, msg); err != nil { - c.logger.Warn("failed to update message with expired status", - zap.String("pending_id", pendingID), - zap.String("message_id", msg.ID), - zap.Error(err)) - continue - } - c.publishMessageUpdated(writeCtx, msg) } -} - -func (c *Canceller) detachSessionBundles(ctx context.Context, sessionID string) int { - pendingIDs := c.store.CancelSession(sessionID) - - handled := make(map[string]bool, len(pendingIDs)) - for _, id := range pendingIDs { - msgs, err := c.repo.FindMessagesByPendingID(ctx, id) - if err != nil || len(msgs) == 0 { - c.logger.Debug("messages not found for detached clarification", - zap.String("pending_id", id), - zap.Error(err)) - continue - } - c.markMessagesDetached(ctx, msgs, id) - handled[id] = true - } - - msgs, err := c.repo.FindPendingClarificationMessagesBySessionID(ctx, sessionID) - if err != nil || len(msgs) == 0 { - return len(pendingIDs) - } - - byPendingID := make(map[string][]*taskmodels.Message) - for _, msg := range msgs { - pid := stringFromMetadata(msg.Metadata, "pending_id") - if pid == "" || handled[pid] { - continue - } - byPendingID[pid] = append(byPendingID[pid], msg) - } - for pid, bundle := range byPendingID { - c.markMessagesDetached(ctx, bundle, pid) - } - return len(pendingIDs) + len(byPendingID) + c.publishBundleUpdates(ctx, messages) + return len(bundles) } // DetachSessionAndNotify cancels in-memory WaitForResponse waiters for a session // and marks DB clarification messages as pending with agent_disconnected=true. -// The overlay stays interactive; a late answer uses the event fallback path. -func (c *Canceller) DetachSessionAndNotify(ctx context.Context, sessionID string) int { +// The overlay stays interactive; a late answer uses the acknowledged resume fallback path. +func (c *Canceller) DetachSessionAndNotify(ctx context.Context, sessionID string) (int, error) { return c.detachSessionBundles(ctx, sessionID) } // ExpireSessionAndNotify cancels in-memory waiters and marks clarification // messages expired so the overlay closes and history shows a timed-out entry. -// TODO: wire this into terminal teardown paths that should close the overlay -// instead of preserving the deferred-answer UX. -func (c *Canceller) ExpireSessionAndNotify(ctx context.Context, sessionID string) int { - pendingIDs := c.store.CancelSession(sessionID) - - handled := make(map[string]bool, len(pendingIDs)) - for _, id := range pendingIDs { - msgs, err := c.repo.FindMessagesByPendingID(ctx, id) - if err != nil || len(msgs) == 0 { - continue - } - c.markMessagesExpired(ctx, msgs, id) - handled[id] = true - } - - msgs, err := c.repo.FindPendingClarificationMessagesBySessionID(ctx, sessionID) - if err != nil || len(msgs) == 0 { - return len(pendingIDs) - } - - byPendingID := make(map[string][]*taskmodels.Message) - for _, msg := range msgs { - pid := stringFromMetadata(msg.Metadata, "pending_id") - if pid == "" || handled[pid] { - continue - } - byPendingID[pid] = append(byPendingID[pid], msg) - } - for pid, bundle := range byPendingID { - c.markMessagesExpired(ctx, bundle, pid) - } - return len(pendingIDs) + len(byPendingID) +func (c *Canceller) ExpireSessionAndNotify(ctx context.Context, sessionID string) (int, error) { + return c.expireSessionBundles(ctx, sessionID) } -// publishMessageUpdated publishes a message.updated event to the event bus. -func (c *Canceller) publishMessageUpdated(ctx context.Context, msg *taskmodels.Message) { - if c.eventBus == nil { +// publishBundleUpdates routes committed rows through the task service so every +// message event carries the authoritative pending-action projection. +func (c *Canceller) publishBundleUpdates(ctx context.Context, messages []*taskmodels.Message) { + if c.publisher == nil || len(messages) == 0 { return } - - msgType := string(msg.Type) - if msgType == "" { - msgType = "message" - } - - data := map[string]any{ - "message_id": msg.ID, - "session_id": msg.TaskSessionID, - "task_id": msg.TaskID, - "turn_id": msg.TurnID, - "author_type": string(msg.AuthorType), - "author_id": msg.AuthorID, - "content": msg.Content, - "type": msgType, - "requests_input": msg.RequestsInput, - "created_at": msg.CreatedAt.Format(time.RFC3339), - "updated_at": msg.UpdatedAt.Format(time.RFC3339Nano), - "metadata": msg.Metadata, - } - - event := bus.NewEvent(events.MessageUpdated, "clarification-canceller", data) - if err := c.eventBus.Publish(ctx, events.MessageUpdated, event); err != nil { - c.logger.Warn("failed to publish message.updated event", - zap.String("message_id", msg.ID), + if err := c.publisher.PublishClarificationBundleUpdates(ctx, messages); err != nil { + c.logger.Warn("failed to publish clarification bundle updates", + zap.Int("message_count", len(messages)), zap.Error(err)) } } diff --git a/apps/backend/internal/clarification/canceller_test.go b/apps/backend/internal/clarification/canceller_test.go index 3112360074..757e559802 100644 --- a/apps/backend/internal/clarification/canceller_test.go +++ b/apps/backend/internal/clarification/canceller_test.go @@ -7,13 +7,27 @@ import ( "time" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" taskmodels "github.com/kandev/kandev/internal/task/models" ) type stubMessageStore struct { - messages map[string][]*taskmodels.Message - updated []*taskmodels.Message + messages map[string][]*taskmodels.Message + activeBySession map[string][]*taskmodels.Message + activeErr error + updated []*taskmodels.Message + activeHasDeadline bool + activeDeadline time.Time + activeContextErr error + detachHasDeadline bool + detachContextErr error + expireHasDeadline bool + expireDeadline time.Time + expireContextErr error + expireErr error + findHasDeadline bool + findContextErr error } func (s *stubMessageStore) GetTaskSession(context.Context, string) (*taskmodels.TaskSession, error) { @@ -28,7 +42,9 @@ func (s *stubMessageStore) FindMessageByPendingID(_ context.Context, pendingID s return msgs[0], nil } -func (s *stubMessageStore) FindMessagesByPendingID(_ context.Context, pendingID string) ([]*taskmodels.Message, error) { +func (s *stubMessageStore) FindMessagesByPendingID(ctx context.Context, pendingID string) ([]*taskmodels.Message, error) { + _, s.findHasDeadline = ctx.Deadline() + s.findContextErr = ctx.Err() msgs, ok := s.messages[pendingID] if !ok { return nil, nil @@ -36,12 +52,21 @@ func (s *stubMessageStore) FindMessagesByPendingID(_ context.Context, pendingID return msgs, nil } -func (s *stubMessageStore) FindPendingClarificationMessagesBySessionID(_ context.Context, sessionID string) ([]*taskmodels.Message, error) { +func (s *stubMessageStore) FindActiveClarificationMessagesBySessionID(ctx context.Context, sessionID string) ([]*taskmodels.Message, error) { + s.activeDeadline, s.activeHasDeadline = ctx.Deadline() + s.activeContextErr = ctx.Err() + if s.activeErr != nil { + return nil, s.activeErr + } + if s.activeBySession != nil { + return s.activeBySession[sessionID], nil + } var out []*taskmodels.Message for _, msgs := range s.messages { for _, m := range msgs { if m.TaskSessionID == sessionID { - if status, _ := m.Metadata["status"].(string); status == "pending" { + status, _ := m.Metadata["status"].(string) + if status == "" || status == "pending" { out = append(out, m) } } @@ -50,20 +75,118 @@ func (s *stubMessageStore) FindPendingClarificationMessagesBySessionID(_ context return out, nil } +func (s *stubMessageStore) DetachActiveClarificationMessagesBySessionID( + ctx context.Context, + sessionID string, +) ([]*taskmodels.Message, error) { + _, s.detachHasDeadline = ctx.Deadline() + s.detachContextErr = ctx.Err() + active, err := s.FindActiveClarificationMessagesBySessionID(ctx, sessionID) + if err != nil { + return nil, err + } + var changed []*taskmodels.Message + for _, message := range active { + if detached, _ := message.Metadata["agent_disconnected"].(bool); detached { + continue + } + message.Metadata["agent_disconnected"] = true + s.updated = append(s.updated, message) + changed = append(changed, message) + } + return changed, nil +} + +func (s *stubMessageStore) ExpireActiveClarificationBundle( + ctx context.Context, + sessionID, pendingID string, +) ([]*taskmodels.Message, error) { + s.expireDeadline, s.expireHasDeadline = ctx.Deadline() + s.expireContextErr = ctx.Err() + if s.expireErr != nil { + return nil, s.expireErr + } + active, err := s.FindActiveClarificationMessagesBySessionID(ctx, sessionID) + if err != nil { + return nil, err + } + changed := make([]*taskmodels.Message, 0, len(active)) + for _, message := range active { + if stringFromMetadata(message.Metadata, "pending_id") != pendingID { + continue + } + message.Metadata["agent_disconnected"] = true + message.Metadata["status"] = "expired" + s.updated = append(s.updated, message) + changed = append(changed, message) + } + return changed, nil +} + func (s *stubMessageStore) UpdateMessage(_ context.Context, m *taskmodels.Message) error { s.updated = append(s.updated, m) return nil } type stubEventBus struct { - events []*bus.Event + events []*bus.Event + publishedBundles int + publishErr error + contextErrs []error + publishHasDeadline []bool + resumeRequests []DetachedClarificationResume + resumeErr error + resumeContextErrs []error + resumeHasDeadline []bool + beforeResume func() } -func (s *stubEventBus) Publish(_ context.Context, _ string, ev *bus.Event) error { +func (s *stubEventBus) PublishClarificationBundleUpdates( + ctx context.Context, + messages []*taskmodels.Message, +) error { + s.publishedBundles++ + for _, message := range messages { + data := map[string]any{ + "message_id": message.ID, + "updated_at": message.UpdatedAt.Format(time.RFC3339Nano), + } + if err := s.Publish( + ctx, + events.MessageUpdated, + bus.NewEvent(events.MessageUpdated, "clarification-canceller-test", data), + ); err != nil { + return err + } + } + return nil +} + +func (s *stubEventBus) Publish(ctx context.Context, _ string, ev *bus.Event) error { + _, hasDeadline := ctx.Deadline() + s.publishHasDeadline = append(s.publishHasDeadline, hasDeadline) + s.contextErrs = append(s.contextErrs, ctx.Err()) + if s.publishErr != nil { + return s.publishErr + } s.events = append(s.events, ev) return nil } +func (s *stubEventBus) ResumeDetachedClarification( + ctx context.Context, + request DetachedClarificationResume, +) error { + if s.beforeResume != nil { + s.beforeResume() + } + _, hasDeadline := ctx.Deadline() + s.resumeHasDeadline = append(s.resumeHasDeadline, hasDeadline) + s.resumeContextErrs = append(s.resumeContextErrs, ctx.Err()) + s.resumeRequests = append(s.resumeRequests, request) + return s.resumeErr +} + func newTestCanceller(t *testing.T, msgs map[string][]*taskmodels.Message) (*Canceller, *stubMessageStore, *stubEventBus) { t.Helper() store := NewStore(time.Minute) @@ -84,11 +207,14 @@ func TestCanceller_MarksDetachedOnDisconnect(t *testing.T) { ID: "m1", TaskSessionID: "s1", Metadata: map[string]any{ - "status": "pending", + "pending_id": pendingID, }, }} - cancelled := c.DetachSessionAndNotify(context.Background(), "s1") + cancelled, err := c.DetachSessionAndNotify(context.Background(), "s1") + if err != nil { + t.Fatalf("detach clarification: %v", err) + } if cancelled != 1 { t.Fatalf("expected 1 cancelled, got %d", cancelled) } @@ -100,8 +226,8 @@ func TestCanceller_MarksDetachedOnDisconnect(t *testing.T) { if got, _ := updated.Metadata["agent_disconnected"].(bool); !got { t.Errorf("expected agent_disconnected=true, got %v", updated.Metadata["agent_disconnected"]) } - if got, _ := updated.Metadata["status"].(string); got != "pending" { - t.Errorf("expected status=pending, got %q", got) + if _, exists := updated.Metadata["status"]; exists { + t.Errorf("expected missing status to remain missing, got %#v", updated.Metadata["status"]) } } @@ -116,11 +242,14 @@ func TestCanceller_ExpireSessionAndNotify_MarksExpired(t *testing.T) { ID: "m1", TaskSessionID: "s1", Metadata: map[string]any{ - "status": "pending", + "pending_id": pendingID, }, }} - cancelled := c.ExpireSessionAndNotify(context.Background(), "s1") + cancelled, err := c.ExpireSessionAndNotify(context.Background(), "s1") + if err != nil { + t.Fatalf("expire clarification: %v", err) + } if cancelled != 1 { t.Fatalf("expected 1 cancelled, got %d", cancelled) } @@ -133,13 +262,107 @@ func TestCanceller_ExpireSessionAndNotify_MarksExpired(t *testing.T) { } } +func TestCanceller_PersistenceUsesFreshBoundedContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + t.Run("detach", func(t *testing.T) { + c, repo, _ := newTestCanceller(t, map[string][]*taskmodels.Message{}) + if _, err := c.DetachSessionAndNotify(ctx, "s1"); err != nil { + t.Fatalf("detach clarification: %v", err) + } + if !repo.detachHasDeadline || repo.detachContextErr != nil { + t.Fatalf("detach context deadline=%v err=%v, want fresh bounded context", + repo.detachHasDeadline, repo.detachContextErr) + } + }) + + t.Run("expiry", func(t *testing.T) { + message := &taskmodels.Message{ + ID: "m1", TaskSessionID: "s1", + Metadata: map[string]any{"status": "pending", "pending_id": "pending-1"}, + } + c, repo, _ := newTestCanceller(t, map[string][]*taskmodels.Message{ + "pending-1": {message}, + }) + if _, err := c.ExpireSessionAndNotify(ctx, "s1"); err != nil { + t.Fatalf("expire clarification: %v", err) + } + if !repo.activeHasDeadline || repo.activeContextErr != nil { + t.Fatalf("expiry lookup context deadline=%v err=%v, want fresh bounded context", + repo.activeHasDeadline, repo.activeContextErr) + } + if !repo.expireHasDeadline || repo.expireContextErr != nil { + t.Fatalf("expiry update context deadline=%v err=%v, want fresh bounded context", + repo.expireHasDeadline, repo.expireContextErr) + } + }) +} + +func TestCanceller_ExpiryPreservesShorterCallerDeadline(t *testing.T) { + message := &taskmodels.Message{ + ID: "m1", TaskSessionID: "s1", + Metadata: map[string]any{"status": "pending", "pending_id": "pending-1"}, + } + c, repo, _ := newTestCanceller(t, map[string][]*taskmodels.Message{ + "pending-1": {message}, + }) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + t.Cleanup(cancel) + wantDeadline, _ := ctx.Deadline() + + if _, err := c.ExpireSessionAndNotify(ctx, "s1"); err != nil { + t.Fatalf("expire clarification: %v", err) + } + if !repo.activeDeadline.Equal(wantDeadline) || !repo.expireDeadline.Equal(wantDeadline) { + t.Fatalf("expiry deadlines = lookup %v update %v, want caller %v", + repo.activeDeadline, repo.expireDeadline, wantDeadline) + } +} + +func TestCanceller_ExpireSessionAndNotifyReturnsPersistenceError(t *testing.T) { + message := &taskmodels.Message{ + ID: "m1", TaskSessionID: "s1", + Metadata: map[string]any{"status": "pending", "pending_id": "pending-1"}, + } + c, repo, _ := newTestCanceller(t, map[string][]*taskmodels.Message{ + "pending-1": {message}, + }) + wantErr := errors.New("expiry write failed") + repo.expireErr = wantErr + + count, err := c.ExpireSessionAndNotify(context.Background(), "s1") + + if count != 0 { + t.Fatalf("expired bundles = %d, want 0", count) + } + if !errors.Is(err, wantErr) { + t.Fatalf("expiry error = %v, want %v", err, wantErr) + } +} + +func TestCanceller_DetachSessionAndNotifyReturnsPersistenceError(t *testing.T) { + c, repo, _ := newTestCanceller(t, map[string][]*taskmodels.Message{}) + wantErr := errors.New("detach lookup failed") + repo.activeErr = wantErr + + count, err := c.DetachSessionAndNotify(context.Background(), "s1") + + if count != 0 { + t.Fatalf("detached bundles = %d, want 0", count) + } + if !errors.Is(err, wantErr) { + t.Fatalf("detach error = %v, want %v", err, wantErr) + } +} + // TestCanceller_NoMessagesToUpdate confirms that a cancel with no pending // clarifications returns 0 and does not touch the repo. func TestCanceller_NoMessagesToUpdate(t *testing.T) { c, repo, _ := newTestCanceller(t, map[string][]*taskmodels.Message{}) - if got := c.DetachSessionAndNotify(context.Background(), "nonexistent"); got != 0 { - t.Errorf("expected 0 cancelled, got %d", got) + if got, err := c.DetachSessionAndNotify(context.Background(), "nonexistent"); err != nil || got != 0 { + t.Errorf("detach nonexistent = %d, %v; want 0, nil", got, err) } if len(repo.updated) != 0 { t.Errorf("expected no message updates, got %d", len(repo.updated)) @@ -157,15 +380,20 @@ func TestCanceller_PublishesMessageUpdatedEvent(t *testing.T) { msgs[pendingID] = []*taskmodels.Message{{ ID: "m1", TaskSessionID: "s1", - Metadata: map[string]any{"status": "pending"}, + Metadata: map[string]any{"status": "pending", "pending_id": pendingID}, UpdatedAt: updatedAt, }} - c.DetachSessionAndNotify(context.Background(), "s1") + if _, err := c.DetachSessionAndNotify(context.Background(), "s1"); err != nil { + t.Fatalf("detach clarification: %v", err) + } if len(eventBus.events) != 1 { t.Fatalf("expected 1 event, got %d", len(eventBus.events)) } + if eventBus.publishedBundles != 1 { + t.Fatalf("projection-aware bundle publications = %d, want 1", eventBus.publishedBundles) + } data, ok := eventBus.events[0].Data.(map[string]any) if !ok { t.Fatalf("expected map event data, got %T", eventBus.events[0].Data) @@ -183,12 +411,15 @@ func TestCanceller_MultiQuestion_MarksAllMessagesDetached(t *testing.T) { pendingID, _ := c.store.CreateRequest(&Request{SessionID: "s1"}) msgs[pendingID] = []*taskmodels.Message{ - {ID: "m1", TaskSessionID: "s1", Metadata: map[string]any{"status": "pending", "question_id": "q1"}}, - {ID: "m2", TaskSessionID: "s1", Metadata: map[string]any{"status": "pending", "question_id": "q2"}}, - {ID: "m3", TaskSessionID: "s1", Metadata: map[string]any{"status": "pending", "question_id": "q3"}}, + {ID: "m1", TaskSessionID: "s1", Metadata: map[string]any{"status": "pending", "pending_id": pendingID, "question_id": "q1"}}, + {ID: "m2", TaskSessionID: "s1", Metadata: map[string]any{"status": "pending", "pending_id": pendingID, "question_id": "q2"}}, + {ID: "m3", TaskSessionID: "s1", Metadata: map[string]any{"status": "pending", "pending_id": pendingID, "question_id": "q3"}}, } - cancelled := c.DetachSessionAndNotify(context.Background(), "s1") + cancelled, err := c.DetachSessionAndNotify(context.Background(), "s1") + if err != nil { + t.Fatalf("detach clarification: %v", err) + } if cancelled != 1 { t.Fatalf("expected 1 cancelled bundle, got %d", cancelled) } @@ -209,12 +440,14 @@ func TestCanceller_MarksDetachedWhenStoreAlreadyDrained(t *testing.T) { ID: "m1", TaskSessionID: "s1", Metadata: map[string]any{ - "status": "pending", "pending_id": pendingID, }, }} - cancelled := c.DetachSessionAndNotify(context.Background(), "s1") + cancelled, err := c.DetachSessionAndNotify(context.Background(), "s1") + if err != nil { + t.Fatalf("detach clarification: %v", err) + } if cancelled != 1 { t.Fatalf("expected 1 cancelled bundle from DB fallback, got %d", cancelled) } @@ -222,14 +455,109 @@ func TestCanceller_MarksDetachedWhenStoreAlreadyDrained(t *testing.T) { t.Fatalf("expected 1 message update, got %d", len(repo.updated)) } updated := repo.updated[0] - if got, _ := updated.Metadata["status"].(string); got != "pending" { - t.Errorf("expected status=pending, got %q", got) + if _, exists := updated.Metadata["status"]; exists { + t.Errorf("expected missing status to remain missing, got %#v", updated.Metadata["status"]) } if got, _ := updated.Metadata["agent_disconnected"].(bool); !got { t.Errorf("expected agent_disconnected=true") } } +func TestCanceller_SupersededStoreBundleDoesNotCount(t *testing.T) { + pendingID := "superseded-store-bundle" + msgs := map[string][]*taskmodels.Message{ + pendingID: {{ + ID: "message-superseded-store", + TaskSessionID: "s1", + Metadata: map[string]any{ + "status": "pending", + "pending_id": pendingID, + }, + }}, + } + c, repo, eventBus := newTestCanceller(t, msgs) + repo.activeBySession = map[string][]*taskmodels.Message{"s1": {}} + if _, created := c.store.CreateRequest(&Request{ + PendingID: pendingID, + SessionID: "s1", + }); !created { + t.Fatal("expected superseded in-memory request to be created") + } + + if got, err := c.DetachSessionAndNotify(context.Background(), "s1"); err != nil || got != 0 { + t.Fatalf("superseded store bundle = %d, %v; want 0, nil", got, err) + } + if _, exists := c.store.GetRequest(pendingID); exists { + t.Fatal("superseded in-memory waiter was not drained") + } + if len(repo.updated) != 0 || len(eventBus.events) != 0 { + t.Fatalf("superseded store bundle produced writes=%d events=%d", len(repo.updated), len(eventBus.events)) + } +} + +func TestCanceller_RepeatedDetachIsNoOp(t *testing.T) { + pendingID := "already-detached" + msgs := map[string][]*taskmodels.Message{ + pendingID: {{ + ID: "m-detached", + TaskSessionID: "s1", + Metadata: map[string]any{ + "status": "pending", + "pending_id": pendingID, + "agent_disconnected": true, + }, + }}, + } + c, repo, eventBus := newTestCanceller(t, msgs) + if _, created := c.store.CreateRequest(&Request{ + PendingID: pendingID, + SessionID: "s1", + }); !created { + t.Fatal("expected in-memory request to be created") + } + + if got, err := c.DetachSessionAndNotify(context.Background(), "s1"); err != nil || got != 0 { + t.Fatalf("repeated detach = %d, %v; want 0, nil", got, err) + } + if len(repo.updated) != 0 { + t.Fatalf("repeated detach wrote %d messages", len(repo.updated)) + } + if len(eventBus.events) != 0 { + t.Fatalf("repeated detach published %d events", len(eventBus.events)) + } +} + +func TestCanceller_RepeatedExpiryIsNoOp(t *testing.T) { + pendingID := "already-expired" + msgs := map[string][]*taskmodels.Message{ + pendingID: {{ + ID: "m-expired", + TaskSessionID: "s1", + Metadata: map[string]any{ + "status": "expired", + "pending_id": pendingID, + }, + }}, + } + c, repo, eventBus := newTestCanceller(t, msgs) + if _, created := c.store.CreateRequest(&Request{ + PendingID: pendingID, + SessionID: "s1", + }); !created { + t.Fatal("expected in-memory request to be created") + } + + if got, err := c.ExpireSessionAndNotify(context.Background(), "s1"); err != nil || got != 0 { + t.Fatalf("repeated expiry = %d, %v; want 0, nil", got, err) + } + if len(repo.updated) != 0 { + t.Fatalf("repeated expiry wrote %d messages", len(repo.updated)) + } + if len(eventBus.events) != 0 { + t.Fatalf("repeated expiry published %d events", len(eventBus.events)) + } +} + // TestCanceller_Idempotent_SkipsAnsweredMessages verifies that an already-answered // message is not clobbered back to expired. func TestCanceller_Idempotent_SkipsAnsweredMessages(t *testing.T) { @@ -238,11 +566,13 @@ func TestCanceller_Idempotent_SkipsAnsweredMessages(t *testing.T) { pendingID, _ := c.store.CreateRequest(&Request{SessionID: "s1"}) msgs[pendingID] = []*taskmodels.Message{ - {ID: "m1", TaskSessionID: "s1", Metadata: map[string]any{"status": "answered"}}, - {ID: "m2", TaskSessionID: "s1", Metadata: map[string]any{"status": "pending"}}, + {ID: "m1", TaskSessionID: "s1", Metadata: map[string]any{"status": "answered", "pending_id": pendingID}}, + {ID: "m2", TaskSessionID: "s1", Metadata: map[string]any{"status": "pending", "pending_id": pendingID}}, } - c.DetachSessionAndNotify(context.Background(), "s1") + if _, err := c.DetachSessionAndNotify(context.Background(), "s1"); err != nil { + t.Fatalf("detach clarification: %v", err) + } if len(repo.updated) != 1 { t.Fatalf("expected 1 message update (only the pending one), got %d", len(repo.updated)) diff --git a/apps/backend/internal/clarification/handlers.go b/apps/backend/internal/clarification/handlers.go index cdabb1f14b..ebdd58a037 100644 --- a/apps/backend/internal/clarification/handlers.go +++ b/apps/backend/internal/clarification/handlers.go @@ -8,6 +8,7 @@ import ( "net/http" "sort" "strings" + "time" "github.com/gin-gonic/gin" "github.com/kandev/kandev/internal/common/logger" @@ -24,15 +25,34 @@ import ( const ( metaQuestionKey = "question" metaQuestionIDKey = "question_id" + metaStatusKey = "status" + metaSessionIDKey = "session_id" + metaTaskIDKey = "task_id" + metaPendingIDKey = "pending_id" + metaRejectedKey = "rejected" + + clarificationPersistenceTimeout = 30 * time.Second ) -// messageStore is the minimal task repository interface required by clarification handlers. -type messageStore interface { +// handlerMessageStore is the task repository surface used by HTTP handlers. +type handlerMessageStore interface { GetTaskSession(ctx context.Context, id string) (*taskmodels.TaskSession, error) - FindMessageByPendingID(ctx context.Context, pendingID string) (*taskmodels.Message, error) FindMessagesByPendingID(ctx context.Context, pendingID string) ([]*taskmodels.Message, error) - FindPendingClarificationMessagesBySessionID(ctx context.Context, sessionID string) ([]*taskmodels.Message, error) - UpdateMessage(ctx context.Context, message *taskmodels.Message) error +} + +// cancellationMessageStore is the task repository surface used by session cancellation. +type cancellationMessageStore interface { + FindActiveClarificationMessagesBySessionID(ctx context.Context, sessionID string) ([]*taskmodels.Message, error) + DetachActiveClarificationMessagesBySessionID(ctx context.Context, sessionID string) ([]*taskmodels.Message, error) + ExpireActiveClarificationBundle(ctx context.Context, sessionID, pendingID string) ([]*taskmodels.Message, error) +} + +type clarificationStore interface { + CreateRequest(req *Request) (string, bool) + GetRequest(pendingID string) (*Request, bool) + WaitForResponse(ctx context.Context, pendingID string) (*Response, error) + RespondWithDeliveryConfirmation(ctx context.Context, pendingID string, response *Response, confirm func() error) error + CancelRequest(pendingID string) bool } // Broadcaster interface for sending WebSocket notifications @@ -52,6 +72,26 @@ type MessageCreator interface { // status (and stores the matching answer if any) for a (pending_id, question_id) // pair within the session. UpdateClarificationMessage(ctx context.Context, sessionID, pendingID, questionID, status string, answer *Answer) error + // CompleteActiveClarificationBundle atomically transitions a bundle only + // when it still belongs to the session's current durable turn. + CompleteActiveClarificationBundle(ctx context.Context, pendingID, status string, responses map[string]interface{}) ([]*taskmodels.Message, bool, error) + // FinalizeClarificationResponseDelivery clears the durable recovery intent + // after the response reaches a live waiter or detached-resume boundary. + FinalizeClarificationResponseDelivery( + ctx context.Context, + pendingID, terminalStatus string, + claimedMessages []*taskmodels.Message, + ) ([]*taskmodels.Message, bool, error) + // RestoreActiveClarificationBundle reopens a claimed bundle when detached + // resume acceptance fails and returns the committed pending rows for publication. + RestoreActiveClarificationBundle( + ctx context.Context, + pendingID, terminalStatus string, + claimedMessages []*taskmodels.Message, + ) ([]*taskmodels.Message, bool, error) + // PublishClarificationBundleUpdates exposes committed terminal or restored-pending rows. + // Restored rows synchronously converge the durable task summary before publication. + PublishClarificationBundleUpdates(ctx context.Context, messages []*taskmodels.Message) error } // EventBus interface for publishing events. @@ -59,31 +99,70 @@ type EventBus interface { Publish(ctx context.Context, topic string, event *bus.Event) error } +// DetachedClarificationResume contains the durable context required to resume +// a session after its original clarification waiter has gone away. +type DetachedClarificationResume struct { + TaskID string + SessionID string + PendingID string + ClarificationTurnID string + ClaimedMessageIDs []string + Question string + AnswerText string + Rejected bool + RejectReason string +} + +// DetachedClarificationResumer acknowledges whether the orchestrator accepted +// a detached answer before the handler exposes the bundle as terminal. +type DetachedClarificationResumer interface { + ResumeDetachedClarification(ctx context.Context, request DetachedClarificationResume) error +} + // Handlers provides HTTP handlers for clarification requests. type Handlers struct { - store *Store - hub Broadcaster - messageCreator MessageCreator - repo messageStore - eventBus EventBus - logger *logger.Logger + store clarificationStore + hub Broadcaster + messageCreator MessageCreator + repo handlerMessageStore + eventBus EventBus + detachedResumer DetachedClarificationResumer + logger *logger.Logger } // NewHandlers creates new clarification handlers. -func NewHandlers(store *Store, hub Broadcaster, messageCreator MessageCreator, repo messageStore, eventBus EventBus, log *logger.Logger) *Handlers { +func NewHandlers( + store *Store, + hub Broadcaster, + messageCreator MessageCreator, + repo handlerMessageStore, + eventBus EventBus, + detachedResumer DetachedClarificationResumer, + log *logger.Logger, +) *Handlers { return &Handlers{ - store: store, - hub: hub, - messageCreator: messageCreator, - repo: repo, - eventBus: eventBus, - logger: log.WithFields(zap.String("component", "clarification-handlers")), + store: store, + hub: hub, + messageCreator: messageCreator, + repo: repo, + eventBus: eventBus, + detachedResumer: detachedResumer, + logger: log.WithFields(zap.String("component", "clarification-handlers")), } } // RegisterRoutes registers clarification HTTP routes. -func RegisterRoutes(router *gin.Engine, store *Store, hub Broadcaster, messageCreator MessageCreator, repo messageStore, eventBus EventBus, log *logger.Logger) { - h := NewHandlers(store, hub, messageCreator, repo, eventBus, log) +func RegisterRoutes( + router *gin.Engine, + store *Store, + hub Broadcaster, + messageCreator MessageCreator, + repo handlerMessageStore, + eventBus EventBus, + detachedResumer DetachedClarificationResumer, + log *logger.Logger, +) { + h := NewHandlers(store, hub, messageCreator, repo, eventBus, detachedResumer, log) api := router.Group("/api/v1/clarification") api.POST("/request", h.httpCreateRequest) api.GET("/:id", h.httpGetRequest) @@ -254,57 +333,165 @@ type RespondBody struct { func (h *Handlers) httpRespond(c *gin.Context) { pendingID := c.Param("id") - var body RespondBody if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload: " + err.Error()}) return } - // Gate: when not rejecting, the user must have answered every question and - // each answer must reference a question id from the original bundle (no - // duplicates, no fabricated ids). We compare against the in-store request - // first; if the entry is gone (agent already moved on), fall back to the - // persisted messages so we still validate even after the in-memory cleanup. + writeCtx := context.WithoutCancel(c.Request.Context()) + claim, statusCode, errorMessage := h.claimClarificationResponse(writeCtx, pendingID, body) + if statusCode != 0 { + c.JSON(statusCode, gin.H{"error": errorMessage}) + return + } + statusCode, errorMessage = h.deliverClaimedClarificationResponse(writeCtx, pendingID, body, claim) + if statusCode != 0 { + c.JSON(statusCode, gin.H{"error": errorMessage}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +type clarificationResponseClaim struct { + response *Response + terminalStatus string + // messages is immutable after claim construction. A delivery callback can + // outlive the responder's bounded wait, so callback results stay local. + messages []*taskmodels.Message +} + +func (h *Handlers) claimClarificationResponse( + ctx context.Context, + pendingID string, + body RespondBody, +) (*clarificationResponseClaim, int, string) { + persistenceCtx, cancel := clarificationPersistenceContext(ctx) + defer cancel() if !body.Rejected { - if errMsg := h.validateRespondAnswers(c.Request.Context(), pendingID, body.Answers); errMsg != "" { - c.JSON(http.StatusBadRequest, gin.H{"error": errMsg}) - return + if errMsg := h.validateRespondAnswers(persistenceCtx, pendingID, body.Answers); errMsg != "" { + return nil, http.StatusBadRequest, errMsg } } - - resp := &Response{ - PendingID: pendingID, - Answers: body.Answers, - Rejected: body.Rejected, - RejectReason: body.RejectReason, + terminalStatus := string(StatusAnswered) + var responses map[string]interface{} + if body.Rejected { + terminalStatus = string(StatusRejected) + } else { + responses = make(map[string]interface{}, len(body.Answers)) + for i := range body.Answers { + answer := body.Answers[i] + responses[answer.QuestionID] = answer + } } + if h.messageCreator == nil { + return nil, http.StatusInternalServerError, "clarification message service unavailable" + } + completedMessages, claimed, claimErr := h.messageCreator.CompleteActiveClarificationBundle( + persistenceCtx, + pendingID, + terminalStatus, + responses, + ) + if claimErr != nil { + h.logger.Error("failed to claim clarification response", + zap.String("pending_id", pendingID), + zap.Error(claimErr)) + return nil, http.StatusInternalServerError, "failed to update clarification state" + } + if !claimed { + return nil, http.StatusConflict, "clarification request is no longer active" + } + return &clarificationResponseClaim{ + response: &Response{ + PendingID: pendingID, + Answers: body.Answers, + Rejected: body.Rejected, + RejectReason: body.RejectReason, + }, + terminalStatus: terminalStatus, + messages: completedMessages, + }, 0, "" +} - // Try the primary path: deliver via channel to blocking WaitForResponse. - // If the agent is still waiting, this unblocks the MCP handler and the - // answer is returned within the same agent turn (no extra cost). - err := h.store.Respond(pendingID, resp) - - if err == nil { - // Primary path succeeded — agent will receive the answers directly. - h.applyAnswersToMessages(c, pendingID, body.Rejected, body.Answers) - h.publishPrimaryAnsweredEvent(c, pendingID, body.Answers, body.Rejected, body.RejectReason) +func (h *Handlers) deliverClaimedClarificationResponse( + ctx context.Context, + pendingID string, + body RespondBody, + claim *clarificationResponseClaim, +) (int, string) { + // Durable current-turn ownership is claimed before touching the live waiter. + // This closes the window where a newer turn exists but its canceller has not + // yet drained the superseded in-memory request. + deliveryCtx, cancelDelivery := context.WithTimeout( + context.WithoutCancel(ctx), + clarificationPersistenceTimeout+5*time.Second, + ) + defer cancelDelivery() + // The callback may outlive this handler's bounded wait. Keep its result in + // callback-owned storage so the immutable claim remains safe for recovery. + finalizedMessages := make(chan []*taskmodels.Message, 1) + deliveryErr := h.store.RespondWithDeliveryConfirmation( + deliveryCtx, + pendingID, + claim.response, + func() error { + finalized, confirmErr := h.confirmLiveClarificationResponseDelivery(ctx, pendingID, claim) + if confirmErr == nil { + finalizedMessages <- finalized + } + return confirmErr + }, + ) + if deliveryErr == nil { + finalized := <-finalizedMessages + h.publishClarificationBundleUpdates(ctx, pendingID, finalized) + h.publishPrimaryAnsweredEvent( + ctx, + pendingID, + body.Answers, + body.Rejected, + body.RejectReason, + h.clarificationClaimTurnID(pendingID, finalized), + ) h.logger.Info("clarification answered via primary path (same turn)", zap.String("pending_id", pendingID), zap.Int("answers", len(body.Answers)), zap.Bool("rejected", body.Rejected)) - c.JSON(http.StatusOK, gin.H{"success": true}) - return + return 0, "" } - - // Duplicate response — someone clicked twice quickly. - if errors.Is(err, ErrAlreadyResponded) { - h.logger.Warn("duplicate response attempt", - zap.String("pending_id", pendingID)) - c.JSON(http.StatusConflict, gin.H{"error": "response already submitted"}) - return + if errors.Is(deliveryErr, ErrAlreadyResponded) { + // Defensive only: the durable claim above is exclusive. Retain this as a + // safety net if the in-memory waiter ever diverges from durable state. + // Re-publishing these already-terminal rows is idempotent for connected + // clients and converges any client that missed the winner's publication. + if finalized, ok := h.finalizeClarificationResponseDelivery(ctx, pendingID, claim); ok { + h.publishClarificationBundleUpdates(ctx, pendingID, finalized) + } + h.logger.Warn("duplicate response attempt", zap.String("pending_id", pendingID)) + return http.StatusConflict, "response already submitted" } + if !errors.Is(deliveryErr, ErrNotFound) { + restored := h.restoreFailedClarificationClaim(ctx, pendingID, claim.terminalStatus, claim.messages) + h.logger.Error("failed to deliver clarification response", + zap.String("pending_id", pendingID), + zap.Bool("restored", restored), + zap.Error(deliveryErr)) + if restored { + return http.StatusInternalServerError, "failed to deliver clarification response; response can be retried" + } + return http.StatusInternalServerError, "failed to deliver clarification response and recover pending clarification state" + } + return h.deliverDetachedClarificationResponse(ctx, pendingID, body, claim, deliveryErr) +} +func (h *Handlers) deliverDetachedClarificationResponse( + ctx context.Context, + pendingID string, + body RespondBody, + claim *clarificationResponseClaim, + deliveryErr error, +) (int, string) { // Fallback path: entry not found (agent timed out, entry was cleaned up). // If the user rejected (clicked X to dismiss), they're discarding a stale // overlay — not continuing the conversation. Treat as a no-op so we don't @@ -314,26 +501,127 @@ func (h *Handlers) httpRespond(c *gin.Context) { // We still need to mark the bundle rejected in the DB; otherwise the durable // pending-clarification guard would keep blocking future workflow transitions. if body.Rejected { - writeCtx := context.WithoutCancel(c.Request.Context()) - h.applyAnswersToMessages(c, pendingID, true, nil) - h.publishStaleDismissedEvent(writeCtx, pendingID) + finalized, ok := h.finalizeClarificationResponseDelivery(ctx, pendingID, claim) + if !ok { + return http.StatusInternalServerError, + "clarification rejection was recorded, but delivery state could not be finalized" + } + h.publishStaleDismissedEvent(ctx, pendingID) + h.publishClarificationBundleUpdates(ctx, pendingID, finalized) h.logger.Info("clarification rejected after agent moved on; no-op", zap.String("pending_id", pendingID)) - c.JSON(http.StatusOK, gin.H{"success": true}) - return + return 0, "" } - // User is providing an affirmative answer after the agent moved on. Update - // the clarification record and publish an event so the orchestrator resumes - // the agent with a new turn containing the answer. - h.logger.Info("clarification entry not found, using event fallback", + // User is providing an affirmative answer after the agent moved on. Ask the + // orchestrator to accept a new turn containing the answer before publishing + // the terminal clarification update. + h.logger.Info("clarification entry not found, using acknowledged resume fallback", zap.String("pending_id", pendingID), - zap.String("error", err.Error())) + zap.String("error", deliveryErr.Error())) - h.applyAnswersToMessages(c, pendingID, body.Rejected, body.Answers) - h.respondViaEventFallback(c, pendingID, body.Answers, body.Rejected, body.RejectReason) + if err := h.resumeDetachedClarification( + ctx, pendingID, body.Answers, body.Rejected, body.RejectReason, claim.messages, + ); err != nil { + if detachedResumeWasAccepted(err) { + // The prompt reached agentctl. Keep the durable answer terminal so an + // HTTP retry cannot dispatch it again, even though turn publication + // failed and the caller must receive a server error. + if finalized, ok := h.finalizeClarificationResponseDelivery(ctx, pendingID, claim); ok { + h.publishClarificationBundleUpdates(ctx, pendingID, finalized) + } + h.logger.Error("accepted clarification resume was not durably published", + zap.String("pending_id", pendingID), + zap.Error(err)) + return http.StatusInternalServerError, + "clarification response was accepted, but dispatch state could not be finalized" + } + restored := h.restoreFailedClarificationClaim(ctx, pendingID, claim.terminalStatus, claim.messages) + h.logger.Error("failed to resume detached clarification", + zap.String("pending_id", pendingID), + zap.Error(err)) + if restored { + return http.StatusInternalServerError, "failed to resume clarification; response can be retried" + } + return http.StatusInternalServerError, "failed to resume clarification and recover pending clarification state" + } + finalized, ok := h.finalizeClarificationResponseDelivery(ctx, pendingID, claim) + if !ok { + return http.StatusInternalServerError, + "clarification response was accepted, but delivery state could not be finalized" + } + h.publishClarificationBundleUpdates(ctx, pendingID, finalized) + return 0, "" +} - c.JSON(http.StatusOK, gin.H{"success": true}) +func (h *Handlers) publishClarificationBundleUpdates( + ctx context.Context, + pendingID string, + messages []*taskmodels.Message, +) { + publishCtx, cancel := clarificationPersistenceContext(ctx) + defer cancel() + if err := h.messageCreator.PublishClarificationBundleUpdates(publishCtx, messages); err != nil { + h.logger.Error("failed to publish clarification bundle updates", + zap.String("pending_id", pendingID), + zap.Error(err)) + } +} + +func detachedResumeWasAccepted(err error) bool { + var accepted interface{ DetachedResumeAccepted() bool } + return errors.As(err, &accepted) && accepted.DetachedResumeAccepted() +} + +func (h *Handlers) restoreFailedClarificationClaim( + ctx context.Context, + pendingID, terminalStatus string, + claimedMessages []*taskmodels.Message, +) bool { + persistenceCtx, cancel := clarificationPersistenceContext(ctx) + defer cancel() + restoredMessages, restored, err := h.messageCreator.RestoreActiveClarificationBundle( + persistenceCtx, + pendingID, + terminalStatus, + claimedMessages, + ) + if err != nil { + h.logger.Error("failed to restore clarification after delivery failure", + zap.String("pending_id", pendingID), + zap.Error(err)) + return false + } + if !restored { + h.logger.Warn("clarification claim was not restorable after delivery failure", + zap.String("pending_id", pendingID)) + return false + } + if err := h.messageCreator.PublishClarificationBundleUpdates(persistenceCtx, restoredMessages); err != nil { + h.logger.Error("failed to converge restored clarification state", + zap.String("pending_id", pendingID), + zap.Error(err)) + // The durable bundle is pending again, so retry remains safe even when + // its best-effort live projection could not be acknowledged. + return true + } + return true +} + +func clarificationPersistenceContext(ctx context.Context) (context.Context, context.CancelFunc) { + // Each durability phase gets an independent bounded window so caller + // cancellation cannot interrupt an accepted write or its compensation. A + // failed detached response can sequence claim, resume, and restore phases, + // so its worst-case response latency may span three of these windows. + return context.WithTimeout(context.WithoutCancel(ctx), clarificationPersistenceTimeout) +} + +func clarificationPersistenceContextPreservingDeadline(ctx context.Context) (context.Context, context.CancelFunc) { + detached := context.WithoutCancel(ctx) + if deadline, ok := ctx.Deadline(); ok && time.Until(deadline) < clarificationPersistenceTimeout { + return context.WithDeadline(detached, deadline) + } + return context.WithTimeout(detached, clarificationPersistenceTimeout) } // validateRespondAnswers enforces the all-required gate **and** the question-id @@ -378,8 +666,8 @@ func (h *Handlers) validateRespondAnswers(ctx context.Context, pendingID string, } // expectedQuestionIDs returns the ordered question ids the user is expected to -// answer for the given pending bundle. Falls back to the persisted messages if -// the in-store request has been cleaned up. +// answer for the given pending bundle. Persisted recovery includes only pending +// siblings because an earlier partial write may have made other rows terminal. func (h *Handlers) expectedQuestionIDs(ctx context.Context, pendingID string) []string { if req, ok := h.store.GetRequest(pendingID); ok && req != nil { ids := make([]string, 0, len(req.Questions)) @@ -397,6 +685,10 @@ func (h *Handlers) expectedQuestionIDs(ctx context.Context, pendingID string) [] } ids := make([]string, 0, len(msgs)) for _, m := range msgs { + status := stringFromMetadata(m.Metadata, metaStatusKey) + if status != "" && status != string(StatusPending) { + continue + } if id := stringFromMetadata(m.Metadata, metaQuestionIDKey); id != "" { ids = append(ids, id) } @@ -441,60 +733,6 @@ func (h *Handlers) httpCancelRequest(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"success": true}) } -// applyAnswersToMessages flips per-question status (answered/rejected) on every -// message that belongs to the bundle. When rejected, every question is marked -// rejected; when answered, each answer updates the matching question's row. -func (h *Handlers) applyAnswersToMessages(c *gin.Context, pendingID string, rejected bool, answers []Answer) { - if h.messageCreator == nil { - return - } - // Durable status writes must complete even if the HTTP request context is canceled. - writeCtx := context.WithoutCancel(c.Request.Context()) - sessionID := h.lookupSessionForPendingCtx(writeCtx, pendingID) - - if rejected { - // Mark every question in the bundle as rejected. Guard h.repo for - // parity with the sibling expectedQuestionIDs path; production wires - // both, but unit tests sometimes pass a nil repo. - if h.repo == nil { - return - } - msgs, err := h.repo.FindMessagesByPendingID(writeCtx, pendingID) - if err != nil || len(msgs) == 0 { - h.logger.Debug("rejected clarification: no messages to update", - zap.String("pending_id", pendingID), - zap.Error(err)) - return - } - for _, msg := range msgs { - questionID := stringFromMetadata(msg.Metadata, "question_id") - if questionID == "" { - continue - } - if err := h.messageCreator.UpdateClarificationMessage(writeCtx, sessionID, pendingID, questionID, "rejected", nil); err != nil { - h.logger.Warn("failed to mark clarification question rejected", - zap.String("pending_id", pendingID), - zap.String("question_id", questionID), - zap.Error(err)) - } - } - return - } - - for i := range answers { - ans := answers[i] - if ans.QuestionID == "" { - continue - } - if err := h.messageCreator.UpdateClarificationMessage(writeCtx, sessionID, pendingID, ans.QuestionID, "answered", &ans); err != nil { - h.logger.Warn("failed to update clarification question", - zap.String("pending_id", pendingID), - zap.String("question_id", ans.QuestionID), - zap.Error(err)) - } - } -} - func stringFromMetadata(meta map[string]any, key string) string { if meta == nil { return "" @@ -505,54 +743,75 @@ func stringFromMetadata(meta map[string]any, key string) string { return "" } -// respondViaEventFallback publishes a ClarificationAnswered event for the orchestrator -// to resume the agent with a new turn. Used when the agent timed out. -func (h *Handlers) respondViaEventFallback(c *gin.Context, pendingID string, answers []Answer, rejected bool, rejectReason string) { - if h.eventBus == nil { - return +// resumeDetachedClarification synchronously asks the orchestrator to accept a new +// turn. Used when the original clarification waiter has gone away. +func (h *Handlers) resumeDetachedClarification( + ctx context.Context, + pendingID string, + answers []Answer, + rejected bool, + rejectReason string, + claimedMessages []*taskmodels.Message, +) error { + if h.detachedResumer == nil { + return errors.New("detached clarification resumer unavailable") } + persistenceCtx, cancel := clarificationPersistenceContext(ctx) + defer cancel() - clarificationCtx, err := h.resolveClarificationEventContext(c.Request.Context(), pendingID) + clarificationCtx, err := h.resolveClarificationEventContext(persistenceCtx, pendingID) if err != nil { - h.logger.Error("failed to resolve context for clarification fallback event", - zap.String("pending_id", pendingID), - zap.Error(err)) - return + return fmt.Errorf("resolve clarification fallback context: %w", err) } if clarificationCtx.SessionID == "" || clarificationCtx.TaskID == "" { - h.logger.Error("missing session/task for clarification fallback event", - zap.String("pending_id", pendingID), - zap.String("session_id", clarificationCtx.SessionID), - zap.String("task_id", clarificationCtx.TaskID)) - return + return fmt.Errorf( + "missing session/task for clarification fallback event: session=%q task=%q", + clarificationCtx.SessionID, + clarificationCtx.TaskID, + ) } answerText := buildAnswerSummary(clarificationCtx.Questions, answers, rejected, rejectReason) - - eventData := map[string]any{ - "session_id": clarificationCtx.SessionID, - "task_id": clarificationCtx.TaskID, - "pending_id": pendingID, - metaQuestionKey: clarificationCtx.QuestionSummary, - "answer_text": answerText, - "rejected": rejected, - "reject_reason": rejectReason, - } - if err := h.eventBus.Publish(c.Request.Context(), events.ClarificationAnswered, bus.NewEvent( - events.ClarificationAnswered, - "clarification-handlers", - eventData, - )); err != nil { - h.logger.Error("failed to publish clarification answered event", - zap.String("pending_id", pendingID), - zap.String("session_id", clarificationCtx.SessionID), - zap.Error(err)) + clarificationTurnID, claimedMessageIDs, err := clarificationClaimRecovery(claimedMessages) + if err != nil { + return fmt.Errorf("build clarification claim recovery: %w", err) } - h.logger.Info("clarification answered via event fallback (new turn)", + request := DetachedClarificationResume{ + SessionID: clarificationCtx.SessionID, + TaskID: clarificationCtx.TaskID, + PendingID: pendingID, + ClarificationTurnID: clarificationTurnID, + ClaimedMessageIDs: claimedMessageIDs, + Question: clarificationCtx.QuestionSummary, + AnswerText: answerText, + Rejected: rejected, + RejectReason: rejectReason, + } + if err := h.detachedResumer.ResumeDetachedClarification(persistenceCtx, request); err != nil { + return fmt.Errorf("resume detached clarification: %w", err) + } + + h.logger.Info("clarification answered via acknowledged fallback (new turn)", zap.String("pending_id", pendingID), zap.String("session_id", clarificationCtx.SessionID), zap.String("task_id", clarificationCtx.TaskID)) + return nil +} + +func clarificationClaimRecovery(messages []*taskmodels.Message) (string, []string, error) { + turnID, err := clarificationClaimTurnIdentity(messages) + if err != nil { + return "", nil, err + } + messageIDs := make([]string, 0, len(messages)) + for _, message := range messages { + if message == nil || message.ID == "" { + continue + } + messageIDs = append(messageIDs, message.ID) + } + return turnID, messageIDs, nil } func (h *Handlers) publishCancelledEvent(c *gin.Context, pendingID string, req *Request) { @@ -584,27 +843,13 @@ func (h *Handlers) publishCancelledEvent(c *gin.Context, pendingID string, req * } } -// lookupSessionForPendingCtx returns the session ID for a pending clarification. -// Falls back to finding it from the database message. -func (h *Handlers) lookupSessionForPendingCtx(ctx context.Context, pendingID string) string { - if req, ok := h.store.GetRequest(pendingID); ok { - return req.SessionID - } - if h.repo == nil { - return "" - } - msg, err := h.repo.FindMessageByPendingID(ctx, pendingID) - if err != nil { - return "" - } - return msg.TaskSessionID -} - func (h *Handlers) publishStaleDismissedEvent(ctx context.Context, pendingID string) { if h.eventBus == nil { return } - clarificationCtx, err := h.resolveClarificationEventContext(ctx, pendingID) + persistenceCtx, cancel := clarificationPersistenceContext(ctx) + defer cancel() + clarificationCtx, err := h.resolveClarificationEventContext(persistenceCtx, pendingID) if err != nil || clarificationCtx.SessionID == "" || clarificationCtx.TaskID == "" { h.logger.Warn("failed to resolve context for stale-dismissed clarification event", zap.String("pending_id", pendingID), @@ -616,7 +861,7 @@ func (h *Handlers) publishStaleDismissedEvent(ctx context.Context, pendingID str "task_id": clarificationCtx.TaskID, "pending_id": pendingID, } - if err := h.eventBus.Publish(ctx, events.ClarificationStaleDismissed, bus.NewEvent( + if err := h.eventBus.Publish(persistenceCtx, events.ClarificationStaleDismissed, bus.NewEvent( events.ClarificationStaleDismissed, "clarification-handlers", eventData, @@ -628,47 +873,6 @@ func (h *Handlers) publishStaleDismissedEvent(ctx context.Context, pendingID str } } -func (h *Handlers) publishPrimaryAnsweredEvent(c *gin.Context, pendingID string, answers []Answer, rejected bool, rejectReason string) { - if h.eventBus == nil { - return - } - clarificationCtx, err := h.resolveClarificationEventContext(c.Request.Context(), pendingID) - if err != nil { - h.logger.Warn("failed to resolve context for primary clarification event", - zap.String("pending_id", pendingID), - zap.Error(err)) - return - } - if clarificationCtx.SessionID == "" || clarificationCtx.TaskID == "" { - h.logger.Warn("missing session/task for primary clarification event", - zap.String("pending_id", pendingID), - zap.String("session_id", clarificationCtx.SessionID), - zap.String("task_id", clarificationCtx.TaskID)) - return - } - - answerText := buildAnswerSummary(clarificationCtx.Questions, answers, rejected, rejectReason) - eventData := map[string]any{ - "session_id": clarificationCtx.SessionID, - "task_id": clarificationCtx.TaskID, - "pending_id": pendingID, - metaQuestionKey: clarificationCtx.QuestionSummary, - "answer_text": answerText, - "rejected": rejected, - "reject_reason": rejectReason, - } - if err := h.eventBus.Publish(c.Request.Context(), events.ClarificationPrimaryAnswered, bus.NewEvent( - events.ClarificationPrimaryAnswered, - "clarification-handlers", - eventData, - )); err != nil { - h.logger.Warn("failed to publish primary clarification event", - zap.String("pending_id", pendingID), - zap.String("session_id", clarificationCtx.SessionID), - zap.Error(err)) - } -} - type clarificationEventContext struct { SessionID string TaskID string diff --git a/apps/backend/internal/clarification/handlers_primary_restore_test.go b/apps/backend/internal/clarification/handlers_primary_restore_test.go new file mode 100644 index 0000000000..dc132bf064 --- /dev/null +++ b/apps/backend/internal/clarification/handlers_primary_restore_test.go @@ -0,0 +1,82 @@ +package clarification + +import ( + "context" + "errors" + "net/http" + "strings" + "testing" + "time" + + taskmodels "github.com/kandev/kandev/internal/task/models" +) + +type failingRespondStore struct { + *Store + err error + confirmBeforeError bool +} + +func (s *failingRespondStore) RespondWithDeliveryConfirmation( + _ context.Context, + _ string, + _ *Response, + confirm func() error, +) error { + if s.confirmBeforeError { + if err := confirm(); err != nil { + return err + } + } + return s.err +} + +func TestHttpRespond_PrimaryDeliveryFailureReportsRestoreOutcome(t *testing.T) { + for _, test := range []struct { + name string + refuseRestore bool + finalizeFirst bool + wantBody string + }{ + {name: "restored", wantBody: "response can be retried"}, + {name: "restore refused", refuseRestore: true, wantBody: "recover pending clarification state"}, + {name: "finalized delivery is not restored", finalizeFirst: true, wantBody: "recover pending clarification state"}, + } { + t.Run(test.name, func(t *testing.T) { + store := NewStore(time.Minute) + pendingID, _ := store.CreateRequest(&Request{ + PendingID: "pending-primary-restore", + TaskID: "task-primary-restore", + SessionID: "session-primary-restore", + Questions: []Question{{ID: "q1", Prompt: "Continue?"}}, + }) + message := &taskmodels.Message{ + ID: "message-primary-restore", TaskID: "task-primary-restore", + TaskSessionID: "session-primary-restore", + Metadata: map[string]any{ + "status": "pending", "pending_id": pendingID, "question_id": "q1", + "question": map[string]any{"id": "q1", "prompt": "Continue?"}, + }, + } + h, _, _, messageCreator := setupTestHandler(t, map[string][]*taskmodels.Message{ + pendingID: {message}, + }) + h.store = &failingRespondStore{ + Store: store, + err: errors.New("delivery failed"), + confirmBeforeError: test.finalizeFirst, + } + messageCreator.refuseRestore = test.refuseRestore + + recorder := runRespond(t, h, pendingID, RespondBody{ + Answers: []Answer{{QuestionID: "q1", SelectedOptions: []string{"yes"}}}, + }) + if recorder.Code != http.StatusInternalServerError { + t.Fatalf("response status = %d, want 500", recorder.Code) + } + if !strings.Contains(recorder.Body.String(), test.wantBody) { + t.Fatalf("response body = %q, want %q", recorder.Body.String(), test.wantBody) + } + }) + } +} diff --git a/apps/backend/internal/clarification/handlers_response_delivery.go b/apps/backend/internal/clarification/handlers_response_delivery.go new file mode 100644 index 0000000000..8888fe06d1 --- /dev/null +++ b/apps/backend/internal/clarification/handlers_response_delivery.go @@ -0,0 +1,137 @@ +package clarification + +import ( + "context" + "errors" + "fmt" + + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + taskmodels "github.com/kandev/kandev/internal/task/models" + "go.uber.org/zap" +) + +func (h *Handlers) confirmLiveClarificationResponseDelivery( + ctx context.Context, + pendingID string, + claim *clarificationResponseClaim, +) ([]*taskmodels.Message, error) { + finalizedMessages, finalized := h.finalizeClarificationResponseDelivery(ctx, pendingID, claim) + if !finalized { + return nil, errors.New("clarification delivery confirmation failed") + } + return finalizedMessages, nil +} + +func (h *Handlers) finalizeClarificationResponseDelivery( + ctx context.Context, + pendingID string, + claim *clarificationResponseClaim, +) ([]*taskmodels.Message, bool) { + persistenceCtx, cancel := clarificationPersistenceContext(ctx) + defer cancel() + finalizedMessages, finalized, err := h.messageCreator.FinalizeClarificationResponseDelivery( + persistenceCtx, + pendingID, + claim.terminalStatus, + claim.messages, + ) + if err != nil { + h.logger.Error("failed to finalize clarification response delivery", + zap.String("pending_id", pendingID), + zap.Error(err)) + return nil, false + } + if !finalized { + h.logger.Warn("clarification response delivery was not finalizable", + zap.String("pending_id", pendingID)) + return nil, false + } + return finalizedMessages, true +} + +func (h *Handlers) clarificationClaimTurnID(pendingID string, messages []*taskmodels.Message) string { + turnID, err := clarificationClaimTurnIdentity(messages) + if err != nil { + h.logger.Warn("clarification bundle has inconsistent turn IDs", + zap.String("pending_id", pendingID), + zap.Error(err)) + return "" + } + return turnID +} + +func clarificationClaimTurnIdentity(messages []*taskmodels.Message) (string, error) { + turnID := "" + turnMessageID := "" + for _, message := range messages { + if message == nil || message.ID == "" || message.TurnID == "" { + continue + } + if turnID == "" { + turnID = message.TurnID + turnMessageID = message.ID + continue + } + if message.TurnID != turnID { + return "", fmt.Errorf( + "clarification bundle mixes turn %q from message %s with turn %q from message %s", + turnID, + turnMessageID, + message.TurnID, + message.ID, + ) + } + } + return turnID, nil +} + +func (h *Handlers) publishPrimaryAnsweredEvent( + ctx context.Context, + pendingID string, + answers []Answer, + rejected bool, + rejectReason, clarificationTurnID string, +) { + if h.eventBus == nil { + return + } + persistenceCtx, cancel := clarificationPersistenceContext(ctx) + defer cancel() + clarificationCtx, err := h.resolveClarificationEventContext(persistenceCtx, pendingID) + if err != nil { + h.logger.Warn("failed to resolve context for primary clarification event", + zap.String("pending_id", pendingID), + zap.Error(err)) + return + } + if clarificationCtx.SessionID == "" || clarificationCtx.TaskID == "" { + h.logger.Warn("missing session/task for primary clarification event", + zap.String("pending_id", pendingID), + zap.String("session_id", clarificationCtx.SessionID), + zap.String("task_id", clarificationCtx.TaskID)) + return + } + + answerText := buildAnswerSummary(clarificationCtx.Questions, answers, rejected, rejectReason) + eventData := map[string]any{ + metaSessionIDKey: clarificationCtx.SessionID, + metaTaskIDKey: clarificationCtx.TaskID, + metaPendingIDKey: pendingID, + "clarification_turn_id": clarificationTurnID, + metaQuestionKey: clarificationCtx.QuestionSummary, + "answer_text": answerText, + metaRejectedKey: rejected, + "reject_reason": rejectReason, + } + if err := h.eventBus.Publish(persistenceCtx, events.ClarificationPrimaryAnswered, bus.NewEvent( + events.ClarificationPrimaryAnswered, + "clarification-handlers", + eventData, + )); err != nil { + h.logger.Warn("failed to publish primary clarification event", + zap.String("pending_id", pendingID), + zap.String("session_id", clarificationCtx.SessionID), + zap.Error(err)) + } +} diff --git a/apps/backend/internal/clarification/handlers_response_delivery_test.go b/apps/backend/internal/clarification/handlers_response_delivery_test.go new file mode 100644 index 0000000000..f2628b0343 --- /dev/null +++ b/apps/backend/internal/clarification/handlers_response_delivery_test.go @@ -0,0 +1,114 @@ +package clarification + +import ( + "context" + "errors" + "net/http" + "testing" + + taskmodels "github.com/kandev/kandev/internal/task/models" +) + +func TestHttpRespond_LiveWaiterRejectsUnconfirmedDelivery(t *testing.T) { + h, repo, _, messageCreator := setupTestHandler(t, map[string][]*taskmodels.Message{}) + pendingID, _ := h.store.CreateRequest(&Request{ + PendingID: "pending-finalize-failure", + SessionID: "session-finalize-failure", + TaskID: "task-finalize-failure", + Questions: []Question{{ID: "q1", Prompt: "Continue?"}}, + }) + repo.messages[pendingID] = []*taskmodels.Message{{ + ID: "message-finalize-failure", TaskID: "task-finalize-failure", + TaskSessionID: "session-finalize-failure", + Metadata: map[string]any{ + "status": "pending", "pending_id": pendingID, "question_id": "q1", + }, + }} + messageCreator.finalizeErr = errors.New("database unavailable") + waitDone := startTestClarificationWaiter(t, h, pendingID) + + recorder := runRespond(t, h, pendingID, RespondBody{ + Answers: []Answer{{QuestionID: "q1", CustomText: "continue"}}, + }) + if recorder.Code != http.StatusInternalServerError { + t.Fatalf("response status = %d, want 500; body=%s", recorder.Code, recorder.Body.String()) + } + if err := <-waitDone; err == nil { + t.Fatal("live waiter returned a response whose delivery was not durably confirmed") + } + if status := repo.messages[pendingID][0].Metadata["status"]; status != "pending" { + t.Fatalf("restored status = %v, want pending", status) + } + if marker := repo.messages[pendingID][0].Metadata["response_delivery_pending"]; marker != nil { + t.Fatalf("restored delivery marker = %v, want absent", marker) + } +} + +func TestFinalizeClarificationResponseDeliveryKeepsClaimSnapshotImmutable(t *testing.T) { + pendingID := "pending-immutable-claim" + h, _, _, _ := setupTestHandler(t, map[string][]*taskmodels.Message{ + pendingID: { + { + ID: "message-immutable-claim", + TaskID: "task-immutable-claim", + TaskSessionID: "session-immutable-claim", + Metadata: map[string]any{ + "status": "answered", + "pending_id": pendingID, + "question_id": "q1", + "response_delivery_pending": true, + }, + }, + }, + }) + claimedMessage := &taskmodels.Message{ + ID: "message-immutable-claim", + TaskID: "task-immutable-claim", + TaskSessionID: "session-immutable-claim", + Metadata: map[string]any{ + "status": "answered", + "pending_id": pendingID, + "question_id": "q1", + "response_delivery_pending": true, + }, + } + claim := &clarificationResponseClaim{ + terminalStatus: "answered", + messages: []*taskmodels.Message{claimedMessage}, + } + + finalized, ok := h.finalizeClarificationResponseDelivery(context.Background(), pendingID, claim) + if !ok { + t.Fatal("delivery was not finalized") + } + if claim.messages[0] != claimedMessage { + t.Fatal("finalization replaced the immutable claim message") + } + if marker := claim.messages[0].Metadata["response_delivery_pending"]; marker != true { + t.Fatalf("claim delivery marker = %v, want true", marker) + } + if len(finalized) != 1 || finalized[0] == claimedMessage { + t.Fatalf("finalized messages = %#v, want a separate committed snapshot", finalized) + } + if marker := finalized[0].Metadata["response_delivery_pending"]; marker != nil { + t.Fatalf("finalized delivery marker = %v, want absent", marker) + } +} + +func startTestClarificationWaiter(t *testing.T, h *Handlers, pendingID string) <-chan error { + t.Helper() + store, ok := h.store.(*Store) + if !ok { + t.Fatalf("handler store = %T, want *Store", h.store) + } + entered := make(chan struct{}, 1) + store.SetOnWaitEntered(func(string) { entered <- struct{}{} }) + waitDone := make(chan error, 1) + go func() { + _, err := store.WaitForResponse(context.Background(), pendingID) + waitDone <- err + }() + <-entered + store.SetOnWaitEntered(nil) + return waitDone +} diff --git a/apps/backend/internal/clarification/handlers_test.go b/apps/backend/internal/clarification/handlers_test.go index 734575392b..baddd9db1b 100644 --- a/apps/backend/internal/clarification/handlers_test.go +++ b/apps/backend/internal/clarification/handlers_test.go @@ -4,8 +4,11 @@ import ( "bytes" "context" "encoding/json" + "errors" + "maps" "net/http" "net/http/httptest" + "slices" "strings" "testing" "time" @@ -15,6 +18,9 @@ import ( "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" taskmodels "github.com/kandev/kandev/internal/task/models" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" ) type stubMessageCreator struct { @@ -23,7 +29,23 @@ type stubMessageCreator struct { questionID string status string } - created [][]Question + created [][]Question + repo *stubMessageStore + publishedBundles int + publishedMessages [][]*taskmodels.Message + claimedMessages []*taskmodels.Message + finalizedBundles int + finalizeErr error + refuseFinalize bool + restoreErr error + refuseRestore bool + publishErr error + publishHasDeadline []bool + publishContextErrs []error + claimHasDeadline bool + claimContextErr error + restoreHasDeadline bool + restoreContextErr error } func (s *stubMessageCreator) CreateClarificationRequestMessages( @@ -48,14 +70,331 @@ func (s *stubMessageCreator) UpdateClarificationMessage( return nil } +func (s *stubMessageCreator) CompleteActiveClarificationBundle( + ctx context.Context, + pendingID, status string, + responses map[string]interface{}, +) ([]*taskmodels.Message, bool, error) { + _, s.claimHasDeadline = ctx.Deadline() + s.claimContextErr = ctx.Err() + msgs := s.repo.messages[pendingID] + if len(msgs) == 0 { + return nil, false, nil + } + active, err := s.repo.FindActiveClarificationMessagesBySessionID(ctx, msgs[0].TaskSessionID) + if err != nil { + return nil, false, err + } + activeBundle := false + for _, message := range active { + if stringFromMetadata(message.Metadata, "pending_id") == pendingID { + activeBundle = true + break + } + } + if !activeBundle { + return nil, false, nil + } + claimedMessages := make([]*taskmodels.Message, 0, len(msgs)) + for _, message := range msgs { + currentStatus := stringFromMetadata(message.Metadata, "status") + if currentStatus != "" && currentStatus != "pending" { + continue + } + questionID := stringFromMetadata(message.Metadata, "question_id") + message.Metadata["status"] = status + message.Metadata["response_delivery_pending"] = true + if response, ok := responses[questionID]; ok && response != nil { + message.Metadata["response"] = response + } + s.updates = append(s.updates, struct { + pendingID string + questionID string + status string + }{pendingID, questionID, status}) + claimedMessages = append(claimedMessages, message) + } + returnedMessages := make([]*taskmodels.Message, 0, len(claimedMessages)) + for _, message := range claimedMessages { + copyMessage := *message + copyMessage.Metadata = maps.Clone(message.Metadata) + returnedMessages = append(returnedMessages, ©Message) + } + s.claimedMessages = returnedMessages + return returnedMessages, len(returnedMessages) > 0, nil +} + +func (s *stubMessageCreator) FinalizeClarificationResponseDelivery( + _ context.Context, + pendingID, terminalStatus string, + claimedMessages []*taskmodels.Message, +) ([]*taskmodels.Message, bool, error) { + if s.finalizeErr != nil { + return nil, false, s.finalizeErr + } + if s.refuseFinalize || len(claimedMessages) == 0 { + return nil, false, nil + } + finalizedMessages := make([]*taskmodels.Message, 0, len(claimedMessages)) + for _, claimedMessage := range claimedMessages { + var storedMessage *taskmodels.Message + for _, candidate := range s.repo.messages[pendingID] { + if candidate.ID == claimedMessage.ID { + storedMessage = candidate + break + } + } + if storedMessage == nil || stringFromMetadata(storedMessage.Metadata, "status") != terminalStatus { + return nil, false, nil + } + if storedMessage.Metadata["response_delivery_pending"] != true { + return nil, false, nil + } + storedMessage.Metadata = maps.Clone(storedMessage.Metadata) + delete(storedMessage.Metadata, "response_delivery_pending") + copyMessage := *storedMessage + copyMessage.Metadata = maps.Clone(storedMessage.Metadata) + finalizedMessages = append(finalizedMessages, ©Message) + } + s.finalizedBundles++ + return finalizedMessages, true, nil +} + +func (s *stubMessageCreator) RestoreActiveClarificationBundle( + ctx context.Context, + pendingID, terminalStatus string, + claimedMessages []*taskmodels.Message, +) ([]*taskmodels.Message, bool, error) { + _, s.restoreHasDeadline = ctx.Deadline() + s.restoreContextErr = ctx.Err() + if s.restoreErr != nil { + return nil, false, s.restoreErr + } + if s.refuseRestore { + return nil, false, nil + } + if len(claimedMessages) == 0 { + return nil, false, nil + } + for _, message := range claimedMessages { + if stringFromMetadata(message.Metadata, "pending_id") != pendingID { + return nil, false, nil + } + if stringFromMetadata(message.Metadata, "status") != terminalStatus { + return nil, false, nil + } + } + restoredMessages := make([]*taskmodels.Message, 0, len(claimedMessages)) + for _, claimedMessage := range claimedMessages { + var storedMessage *taskmodels.Message + for _, candidate := range s.repo.messages[pendingID] { + if candidate.ID == claimedMessage.ID { + storedMessage = candidate + break + } + } + if storedMessage == nil || stringFromMetadata(storedMessage.Metadata, "status") != terminalStatus { + return nil, false, nil + } + if storedMessage.Metadata["response_delivery_pending"] != true { + return nil, false, nil + } + questionID := stringFromMetadata(storedMessage.Metadata, "question_id") + storedMessage.Metadata = maps.Clone(storedMessage.Metadata) + storedMessage.Metadata["status"] = "pending" + delete(storedMessage.Metadata, "response") + delete(storedMessage.Metadata, "response_delivery_pending") + s.updates = append(s.updates, struct { + pendingID string + questionID string + status string + }{pendingID, questionID, "pending"}) + copyMessage := *storedMessage + copyMessage.Metadata = maps.Clone(storedMessage.Metadata) + restoredMessages = append(restoredMessages, ©Message) + } + return restoredMessages, true, nil +} + +func (s *stubMessageCreator) PublishClarificationBundleUpdates( + ctx context.Context, + messages []*taskmodels.Message, +) error { + _, hasDeadline := ctx.Deadline() + s.publishHasDeadline = append(s.publishHasDeadline, hasDeadline) + s.publishContextErrs = append(s.publishContextErrs, ctx.Err()) + if s.publishErr != nil { + return s.publishErr + } + s.publishedBundles++ + published := make([]*taskmodels.Message, 0, len(messages)) + for _, message := range messages { + copyMessage := *message + copyMessage.Metadata = maps.Clone(message.Metadata) + published = append(published, ©Message) + } + s.publishedMessages = append(s.publishedMessages, published) + return nil +} + +func TestPublishClarificationBundleUpdatesUsesFreshBoundedContext(t *testing.T) { + h, _, _, messageCreator := setupTestHandler(t, nil) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + h.publishClarificationBundleUpdates(ctx, "pending-bounded-publish", nil) + + if len(messageCreator.publishHasDeadline) != 1 || !messageCreator.publishHasDeadline[0] { + t.Fatalf("publication deadlines = %v, want one bounded context", messageCreator.publishHasDeadline) + } + if len(messageCreator.publishContextErrs) != 1 || messageCreator.publishContextErrs[0] != nil { + t.Fatalf("publication context errors = %v, want fresh context", messageCreator.publishContextErrs) + } +} + +func TestPrimaryAnsweredEventUsesFreshBoundedContext(t *testing.T) { + h, _, eventBus, _ := setupTestHandler(t, nil) + pendingID, _ := h.store.CreateRequest(&Request{ + SessionID: "session-primary", + TaskID: "task-primary", + Questions: []Question{{ID: "q1", Prompt: "Continue?"}}, + }) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + h.publishPrimaryAnsweredEvent(ctx, pendingID, nil, true, "cancelled", "turn-primary") + + if len(eventBus.publishHasDeadline) != 1 || !eventBus.publishHasDeadline[0] { + t.Fatalf("publication deadlines = %v, want one bounded context", eventBus.publishHasDeadline) + } + if len(eventBus.contextErrs) != 1 || eventBus.contextErrs[0] != nil { + t.Fatalf("publication context errors = %v, want fresh context", eventBus.contextErrs) + } + if len(eventBus.events) != 1 { + t.Fatalf("published events = %d, want 1", len(eventBus.events)) + } + eventData, ok := eventBus.events[0].Data.(map[string]any) + if !ok { + t.Fatalf("primary answered event data = %T, want map", eventBus.events[0].Data) + } + if got := eventData["clarification_turn_id"]; got != "turn-primary" { + t.Fatalf("clarification turn id = %v, want turn-primary", got) + } +} + +func TestStaleDismissedEventUsesFreshBoundedContext(t *testing.T) { + message := &taskmodels.Message{ + ID: "message-stale", TaskID: "task-stale", TaskSessionID: "session-stale", + Metadata: map[string]any{ + "pending_id": "pending-stale", "question_id": "q1", + "question": map[string]any{"id": "q1", "prompt": "Continue?"}, + }, + } + h, repo, eventBus, _ := setupTestHandler(t, map[string][]*taskmodels.Message{ + "pending-stale": {message}, + }) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + h.publishStaleDismissedEvent(ctx, "pending-stale") + + if !repo.findHasDeadline || repo.findContextErr != nil { + t.Fatalf("lookup context deadline=%v err=%v, want fresh bounded context", + repo.findHasDeadline, repo.findContextErr) + } + if len(eventBus.publishHasDeadline) != 1 || !eventBus.publishHasDeadline[0] { + t.Fatalf("publication deadlines = %v, want one bounded context", eventBus.publishHasDeadline) + } + if len(eventBus.contextErrs) != 1 || eventBus.contextErrs[0] != nil { + t.Fatalf("publication context errors = %v, want fresh context", eventBus.contextErrs) + } +} + +func TestDetachedResumeResolutionUsesFreshBoundedContext(t *testing.T) { + message := &taskmodels.Message{ + ID: "message-resume", TaskID: "task-resume", TaskSessionID: "session-resume", + Metadata: map[string]any{ + "pending_id": "pending-resume", "question_id": "q1", + "question": map[string]any{"id": "q1", "prompt": "Continue?"}, + }, + } + h, repo, eventBus, _ := setupTestHandler(t, map[string][]*taskmodels.Message{ + "pending-resume": {message}, + }) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := h.resumeDetachedClarification(ctx, "pending-resume", []Answer{{ + QuestionID: "q1", SelectedOptions: []string{"yes"}, + }}, false, "", []*taskmodels.Message{message}) + if err != nil { + t.Fatalf("resume detached clarification: %v", err) + } + + if !repo.findHasDeadline || repo.findContextErr != nil { + t.Fatalf("lookup context deadline=%v err=%v, want fresh bounded context", + repo.findHasDeadline, repo.findContextErr) + } + if len(eventBus.resumeHasDeadline) != 1 || !eventBus.resumeHasDeadline[0] { + t.Fatalf("resume deadlines = %v, want one bounded context", eventBus.resumeHasDeadline) + } + if len(eventBus.resumeContextErrs) != 1 || eventBus.resumeContextErrs[0] != nil { + t.Fatalf("resume context errors = %v, want fresh context", eventBus.resumeContextErrs) + } +} + +func TestClarificationClaimRecoveryRejectsMixedTurns(t *testing.T) { + core, logs := observer.New(zapcore.WarnLevel) + observedLogger, err := logger.NewFromZap(zap.New(core)) + if err != nil { + t.Fatalf("create observed logger: %v", err) + } + h := &Handlers{logger: observedLogger} + messages := []*taskmodels.Message{ + {ID: "message-one", TurnID: "turn-one"}, + {ID: "message-two", TurnID: "turn-two"}, + } + if turnID := h.clarificationClaimTurnID("pending-mixed", messages); turnID != "" { + t.Fatalf("clarificationClaimTurnID = %q, want invalid identity", turnID) + } + warnings := logs.FilterMessage("clarification bundle has inconsistent turn IDs").All() + if len(warnings) != 1 || warnings[0].ContextMap()["pending_id"] != "pending-mixed" { + t.Fatalf("mixed-turn warnings = %#v, want pending identity", warnings) + } + _, _, err = clarificationClaimRecovery(messages) + if err == nil { + t.Fatal("clarificationClaimRecovery accepted messages from different turns") + } +} + +func TestClarificationClaimTurnIDUsesNonEmptyBundleIdentity(t *testing.T) { + h, _, _, _ := setupTestHandler(t, nil) + messages := []*taskmodels.Message{ + {ID: "message-legacy", TurnID: ""}, + {ID: "message-current", TurnID: "turn-current"}, + } + + primaryTurnID := h.clarificationClaimTurnID("pending-current", messages) + recoveryTurnID, messageIDs, err := clarificationClaimRecovery(messages) + if err != nil { + t.Fatalf("clarificationClaimRecovery: %v", err) + } + if primaryTurnID != "turn-current" || recoveryTurnID != primaryTurnID { + t.Fatalf("turn identities = primary %q recovery %q, want turn-current", primaryTurnID, recoveryTurnID) + } + if !slices.Equal(messageIDs, []string{"message-legacy", "message-current"}) { + t.Fatalf("claimed message IDs = %v", messageIDs) + } +} + func setupTestHandler(t *testing.T, msgs map[string][]*taskmodels.Message) (*Handlers, *stubMessageStore, *stubEventBus, *stubMessageCreator) { t.Helper() gin.SetMode(gin.TestMode) store := NewStore(time.Minute) repo := &stubMessageStore{messages: msgs} eventBus := &stubEventBus{} - messageCreator := &stubMessageCreator{} - h := NewHandlers(store, nil, messageCreator, repo, eventBus, logger.Default()) + messageCreator := &stubMessageCreator{repo: repo} + h := NewHandlers(store, nil, messageCreator, repo, eventBus, eventBus, logger.Default()) return h, repo, eventBus, messageCreator } @@ -130,18 +469,17 @@ func TestHttpRespond_RejectedAfterTimeout_NoNewTurn(t *testing.T) { } } -// TestHttpRespond_AnsweredAfterTimeout_PublishesEvent confirms that an -// affirmative answer (option selected or custom text) still goes through -// the fallback path and publishes the event so the orchestrator resumes -// the agent — the user chose to continue, so a new turn is expected. -func TestHttpRespond_AnsweredAfterTimeout_PublishesEvent(t *testing.T) { +// TestHttpRespond_AnsweredAfterTimeoutAwaitsResume confirms that an affirmative +// detached answer succeeds only after the orchestrator accepts the new turn. +func TestHttpRespond_AnsweredAfterTimeoutAwaitsResume(t *testing.T) { msgs := map[string][]*taskmodels.Message{ "pending-456": {{ ID: "m2", TaskID: "t1", TaskSessionID: "s1", + TurnID: "turn-clarification", Metadata: map[string]any{ - "status": "expired", + "status": "pending", "agent_disconnected": true, "pending_id": "pending-456", "question_id": "q1", @@ -149,7 +487,7 @@ func TestHttpRespond_AnsweredAfterTimeout_PublishesEvent(t *testing.T) { }, }}, } - h, _, eventBus, _ := setupTestHandler(t, msgs) + h, _, eventBus, messageCreator := setupTestHandler(t, msgs) body := RespondBody{ Answers: []Answer{{ @@ -162,15 +500,385 @@ func TestHttpRespond_AnsweredAfterTimeout_PublishesEvent(t *testing.T) { if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } - var found bool - for _, ev := range eventBus.events { - if ev.Type == events.ClarificationAnswered { - found = true - break + if len(eventBus.resumeRequests) != 1 { + t.Fatalf("resume requests = %d, want exactly 1", len(eventBus.resumeRequests)) + } + request := eventBus.resumeRequests[0] + if request.TaskID != "t1" || request.SessionID != "s1" || request.PendingID != "pending-456" { + t.Fatalf("resume request identifiers = %+v", request) + } + if request.ClarificationTurnID != "turn-clarification" || + len(request.ClaimedMessageIDs) != 1 || request.ClaimedMessageIDs[0] != "m2" { + t.Fatalf("resume recovery identity = %+v", request) + } + if request.AnswerText != "User selected: [opt1]" { + t.Fatalf("resume answer text = %q", request.AnswerText) + } + if len(messageCreator.updates) != 1 || messageCreator.updates[0].status != "answered" { + t.Errorf("detached answer updates = %+v, want one answered write", messageCreator.updates) + } +} + +func TestHttpRespond_DetachedMixedBundleAcceptsOnlyPendingAnswers(t *testing.T) { + terminal := &taskmodels.Message{ + ID: "message-terminal", TaskID: "task-mixed", TaskSessionID: "session-mixed", + Metadata: map[string]any{ + "status": "answered", "pending_id": "pending-mixed", "question_id": "q1", + }, + } + pending := &taskmodels.Message{ + ID: "message-pending", TaskID: "task-mixed", TaskSessionID: "session-mixed", + Metadata: map[string]any{ + "status": "pending", "pending_id": "pending-mixed", "question_id": "q2", + }, + } + h, _, eventBus, messageCreator := setupTestHandler(t, map[string][]*taskmodels.Message{ + "pending-mixed": {terminal, pending}, + }) + + rec := runRespond(t, h, "pending-mixed", RespondBody{ + Answers: []Answer{{QuestionID: "q2", SelectedOptions: []string{"continue"}}}, + }) + + if rec.Code != http.StatusOK { + t.Fatalf("response status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if len(messageCreator.updates) != 1 || messageCreator.updates[0].questionID != "q2" { + t.Fatalf("updated messages = %+v, want only pending q2", messageCreator.updates) + } + if terminal.Metadata["status"] != "answered" { + t.Fatalf("terminal sibling status = %v, want answered", terminal.Metadata["status"]) + } + if len(eventBus.resumeRequests) != 1 { + t.Fatalf("resume requests = %d, want 1", len(eventBus.resumeRequests)) + } +} + +func TestHttpRespond_DetachedAnswerPublishesOnlyForWinningClaim(t *testing.T) { + msgs := map[string][]*taskmodels.Message{ + "pending-race": {{ + ID: "message-race", TaskID: "task-race", TaskSessionID: "session-race", + Metadata: map[string]any{ + "status": "pending", "pending_id": "pending-race", "question_id": "q1", + "question": map[string]any{"id": "q1", "prompt": "Continue?"}, + }, + }}, + } + h, _, eventBus, _ := setupTestHandler(t, msgs) + body := RespondBody{Answers: []Answer{{QuestionID: "q1", SelectedOptions: []string{"yes"}}}} + + first := runRespond(t, h, "pending-race", body) + second := runRespond(t, h, "pending-race", body) + if first.Code != http.StatusOK || second.Code != http.StatusConflict { + t.Fatalf("response statuses = (%d, %d), want (200, 409)", first.Code, second.Code) + } + if len(eventBus.resumeRequests) != 1 { + t.Fatalf("detached resume requests = %d, want 1", len(eventBus.resumeRequests)) + } +} + +func TestHttpRespond_DetachedResumeFailurePublishesRestoredRetryableBundle(t *testing.T) { + msg := &taskmodels.Message{ + ID: "message-retry", TaskID: "task-retry", TaskSessionID: "session-retry", + Metadata: map[string]any{ + "status": "pending", "pending_id": "pending-retry", "question_id": "q1", + "question": map[string]any{"id": "q1", "prompt": "Continue?"}, + }, + } + h, repo, eventBus, messageCreator := setupTestHandler(t, map[string][]*taskmodels.Message{ + "pending-retry": {msg}, + }) + eventBus.resumeErr = errors.New("orchestrator rejected resume") + refreshSawNoPending := false + eventBus.beforeResume = func() { + active, err := repo.FindActiveClarificationMessagesBySessionID(context.Background(), "session-retry") + if err != nil { + t.Fatalf("interleaved pending refresh: %v", err) + } + refreshSawNoPending = len(active) == 0 + } + body := RespondBody{Answers: []Answer{{QuestionID: "q1", SelectedOptions: []string{"yes"}}}} + + cancelledCtx, cancel := context.WithCancel(context.Background()) + cancel() + first := runRespondWithContext(t, h, "pending-retry", body, cancelledCtx) + if first.Code != http.StatusInternalServerError { + t.Fatalf("first response status = %d, want 500; body=%s", first.Code, first.Body.String()) + } + if got := msg.Metadata["status"]; got != "pending" { + t.Fatalf("status after failed resume = %v, want pending for retry", got) + } + if got := messageCreator.claimedMessages[0].Metadata["status"]; got != "answered" { + t.Fatalf("restore mutated caller-owned claim status = %v, want answered", got) + } + if !refreshSawNoPending { + t.Fatal("interleaved refresh did not observe the temporary terminal claim") + } + if messageCreator.publishedBundles != 1 { + t.Fatalf("published bundles after failed resume = %d, want restored pending bundle", messageCreator.publishedBundles) + } + if got := messageCreator.publishedMessages[0][0].Metadata["status"]; got != "pending" { + t.Fatalf("published restored status = %v, want pending", got) + } + if !messageCreator.claimHasDeadline || messageCreator.claimContextErr != nil { + t.Fatalf("claim context deadline=%v err=%v, want fresh bounded context", + messageCreator.claimHasDeadline, messageCreator.claimContextErr) + } + if !messageCreator.restoreHasDeadline || messageCreator.restoreContextErr != nil { + t.Fatalf("restore context deadline=%v err=%v, want fresh bounded context", + messageCreator.restoreHasDeadline, messageCreator.restoreContextErr) + } + + eventBus.resumeErr = nil + eventBus.beforeResume = nil + second := runRespond(t, h, "pending-retry", body) + if second.Code != http.StatusOK { + t.Fatalf("retry status = %d, want 200; body=%s", second.Code, second.Body.String()) + } + if got := msg.Metadata["status"]; got != "answered" { + t.Fatalf("status after retry = %v, want answered", got) + } + if messageCreator.publishedBundles != 2 { + t.Fatalf("published bundles after retry = %d, want restored plus terminal", messageCreator.publishedBundles) + } + if len(eventBus.resumeRequests) != 2 { + t.Fatalf("resume attempts = %d, want failed attempt plus retry", len(eventBus.resumeRequests)) + } + for index, contextErr := range eventBus.resumeContextErrs { + if contextErr != nil { + t.Fatalf("resume attempt %d used cancelled context: %v", index, contextErr) + } + if !eventBus.resumeHasDeadline[index] { + t.Fatalf("resume attempt %d used unbounded context", index) } } - if !found { - t.Errorf("expected %s event to be published", events.ClarificationAnswered) +} + +type acceptedDetachedResumeTestError struct { + err error +} + +func (e acceptedDetachedResumeTestError) Error() string { return e.err.Error() } +func (e acceptedDetachedResumeTestError) Unwrap() error { return e.err } +func (acceptedDetachedResumeTestError) DetachedResumeAccepted() bool { + return true +} + +func TestHttpRespond_AcceptedResumePublicationFailureIsNotRestored(t *testing.T) { + msg := &taskmodels.Message{ + ID: "message-accepted", TaskID: "task-accepted", TaskSessionID: "session-accepted", + Metadata: map[string]any{ + "status": "pending", "pending_id": "pending-accepted", "question_id": "q1", + "question": map[string]any{"id": "q1", "prompt": "Continue?"}, + }, + } + h, _, eventBus, messageCreator := setupTestHandler(t, map[string][]*taskmodels.Message{ + "pending-accepted": {msg}, + }) + eventBus.resumeErr = acceptedDetachedResumeTestError{err: errors.New("publish accepted turn")} + + first := runRespond(t, h, "pending-accepted", RespondBody{ + Answers: []Answer{{QuestionID: "q1", SelectedOptions: []string{"yes"}}}, + }) + if first.Code != http.StatusInternalServerError { + t.Fatalf("response status = %d, want 500; body=%s", first.Code, first.Body.String()) + } + if strings.Contains(first.Body.String(), "can be retried") { + t.Fatalf("accepted response advertised an unsafe retry: %s", first.Body.String()) + } + if got := msg.Metadata["status"]; got != "answered" { + t.Fatalf("accepted response status = %v, want terminal answered", got) + } + if messageCreator.publishedBundles != 1 { + t.Fatalf("published terminal bundles = %d, want 1", messageCreator.publishedBundles) + } + + second := runRespond(t, h, "pending-accepted", RespondBody{ + Answers: []Answer{{QuestionID: "q1", SelectedOptions: []string{"yes"}}}, + }) + if second.Code != http.StatusConflict { + t.Fatalf("duplicate response status = %d, want 409; body=%s", second.Code, second.Body.String()) + } + if len(eventBus.resumeRequests) != 1 { + t.Fatalf("resume requests = %d, want one accepted dispatch", len(eventBus.resumeRequests)) + } +} + +func TestHttpRespond_DetachedResumeFailureDoesNotPromiseRetryWhenRecoveryFails(t *testing.T) { + for _, tt := range []struct { + name string + restoreErr error + refuseRestore bool + }{ + {name: "restore error", restoreErr: errors.New("database unavailable")}, + {name: "claim no longer restorable", refuseRestore: true}, + } { + t.Run(tt.name, func(t *testing.T) { + msg := &taskmodels.Message{ + ID: "message-recovery", TaskID: "task-recovery", TaskSessionID: "session-recovery", + Metadata: map[string]any{ + "status": "pending", "pending_id": "pending-recovery", "question_id": "q1", + "question": map[string]any{"id": "q1", "prompt": "Continue?"}, + }, + } + h, _, eventBus, messageCreator := setupTestHandler(t, map[string][]*taskmodels.Message{ + "pending-recovery": {msg}, + }) + eventBus.resumeErr = errors.New("orchestrator rejected resume") + messageCreator.restoreErr = tt.restoreErr + messageCreator.refuseRestore = tt.refuseRestore + + rec := runRespond(t, h, "pending-recovery", RespondBody{ + Answers: []Answer{{QuestionID: "q1", SelectedOptions: []string{"yes"}}}, + }) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("response status = %d, want 500; body=%s", rec.Code, rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "can be retried") { + t.Fatalf("failed restore advertised an unsafe retry: %s", rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "recover pending clarification state") { + t.Fatalf("failed restore response = %s, want recovery failure", rec.Body.String()) + } + }) + } +} + +func TestHttpRespond_DetachedResumePublishFailureKeepsRetryableRestore(t *testing.T) { + msg := &taskmodels.Message{ + ID: "message-publish-failure", TaskID: "task-publish-failure", + TaskSessionID: "session-publish-failure", + Metadata: map[string]any{ + "status": "pending", "pending_id": "pending-publish-failure", "question_id": "q1", + "question": map[string]any{"id": "q1", "prompt": "Continue?"}, + }, + } + h, _, eventBus, messageCreator := setupTestHandler(t, map[string][]*taskmodels.Message{ + "pending-publish-failure": {msg}, + }) + eventBus.resumeErr = errors.New("orchestrator rejected resume") + messageCreator.publishErr = errors.New("summary unavailable") + + recorder := runRespond(t, h, "pending-publish-failure", RespondBody{ + Answers: []Answer{{QuestionID: "q1", SelectedOptions: []string{"yes"}}}, + }) + if recorder.Code != http.StatusInternalServerError { + t.Fatalf("response status = %d, want 500; body=%s", recorder.Code, recorder.Body.String()) + } + if !strings.Contains(recorder.Body.String(), "response can be retried") { + t.Fatalf("response body = %q, want retryable restored state", recorder.Body.String()) + } + if got := msg.Metadata["status"]; got != "pending" { + t.Fatalf("database-backed status = %v, want restored pending", got) + } +} + +func TestHttpRespond_LiveSupersededBundleReturnsConflict(t *testing.T) { + old := &taskmodels.Message{ + ID: "old-message", TaskID: "task-live", TaskSessionID: "session-live", + Metadata: map[string]any{ + "status": "pending", "pending_id": "pending-live", "question_id": "q1", + "question": map[string]any{"id": "q1", "prompt": "Old question?"}, + }, + } + h, repo, eventBus, messageCreator := setupTestHandler(t, map[string][]*taskmodels.Message{ + "pending-live": {old}, + }) + pendingID, _ := h.store.CreateRequest(&Request{ + PendingID: "pending-live", SessionID: "session-live", TaskID: "task-live", + Questions: []Question{{ID: "q1", Prompt: "Old question?"}}, + }) + repo.activeBySession = map[string][]*taskmodels.Message{ + "session-live": {{ + ID: "new-message", TaskID: "task-live", TaskSessionID: "session-live", + Metadata: map[string]any{"status": "pending", "pending_id": "pending-new"}, + }}, + } + + rec := runRespond(t, h, pendingID, RespondBody{ + Answers: []Answer{{QuestionID: "q1", SelectedOptions: []string{"yes"}}}, + }) + if rec.Code != http.StatusConflict { + t.Fatalf("status = %d, want 409; body=%s", rec.Code, rec.Body.String()) + } + if len(messageCreator.updates) != 0 || len(eventBus.events) != 0 { + t.Fatalf("stale live response produced writes=%+v events=%+v", messageCreator.updates, eventBus.events) + } +} + +func TestHttpRespond_FallbackInactiveBundleReturnsConflictWithoutSideEffects(t *testing.T) { + tests := []struct { + name string + status string + rejected bool + superseded bool + }{ + {name: "superseded answer", status: "pending", superseded: true}, + {name: "superseded rejection", status: "pending", rejected: true, superseded: true}, + {name: "terminal answer", status: "answered"}, + {name: "terminal rejection", status: "rejected", rejected: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + old := &taskmodels.Message{ + ID: "old-message", TaskID: "t1", TaskSessionID: "s1", + Metadata: map[string]any{ + "status": tt.status, "agent_disconnected": true, + "pending_id": "pending-old", "question_id": "q1", + "question": map[string]any{"id": "q1", "prompt": "old question"}, + }, + } + h, repo, eventBus, messageCreator := setupTestHandler( + t, + map[string][]*taskmodels.Message{"pending-old": {old}}, + ) + repo.activeBySession = map[string][]*taskmodels.Message{"s1": {}} + if tt.superseded { + repo.activeBySession["s1"] = []*taskmodels.Message{{ + ID: "new-message", TaskID: "t1", TaskSessionID: "s1", + Metadata: map[string]any{"status": "pending", "pending_id": "pending-new"}, + }} + } + body := RespondBody{Rejected: tt.rejected, RejectReason: "skip"} + if !tt.rejected { + body.Answers = []Answer{{QuestionID: "q1", SelectedOptions: []string{"one"}}} + } + + rec := runRespond(t, h, "pending-old", body) + if rec.Code != http.StatusConflict { + t.Fatalf("status = %d, want 409; body=%s", rec.Code, rec.Body.String()) + } + if len(messageCreator.updates) != 0 { + t.Fatalf("inactive response wrote messages: %+v", messageCreator.updates) + } + if len(eventBus.events) != 0 { + t.Fatalf("inactive response published events: %+v", eventBus.events) + } + }) + } +} + +func TestHttpRespond_FallbackAuthorityErrorHasNoSideEffects(t *testing.T) { + msg := &taskmodels.Message{ + ID: "message", TaskID: "t1", TaskSessionID: "s1", + Metadata: map[string]any{ + "status": "pending", "pending_id": "pending", "question_id": "q1", + "question": map[string]any{"id": "q1", "prompt": "question"}, + }, + } + h, repo, eventBus, messageCreator := setupTestHandler( + t, + map[string][]*taskmodels.Message{"pending": {msg}}, + ) + repo.activeErr = errors.New("read failed") + rec := runRespond(t, h, "pending", RespondBody{ + Answers: []Answer{{QuestionID: "q1", SelectedOptions: []string{"one"}}}, + }) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", rec.Code) + } + if len(messageCreator.updates) != 0 || len(eventBus.events) != 0 { + t.Fatalf("authority error produced writes=%+v events=%+v", messageCreator.updates, eventBus.events) } } @@ -253,7 +961,7 @@ func TestHttpRespond_PartialAnswers_Rejected400(t *testing.T) { // question is answered the primary path delivers the full response and // updates each message exactly once. func TestHttpRespond_AllAnswers_PrimaryPath_Success(t *testing.T) { - h, _, _, msgCreator := setupTestHandler(t, map[string][]*taskmodels.Message{}) + h, repo, _, msgCreator := setupTestHandler(t, map[string][]*taskmodels.Message{}) pendingID, _ := h.store.CreateRequest(&Request{ SessionID: "s1", @@ -263,11 +971,22 @@ func TestHttpRespond_AllAnswers_PrimaryPath_Success(t *testing.T) { {ID: "q2", Prompt: "Second?"}, }, }) + repo.messages[pendingID] = []*taskmodels.Message{ + { + ID: "message-q1", TaskID: "t1", TaskSessionID: "s1", + Metadata: map[string]any{ + "status": "pending", "pending_id": pendingID, "question_id": "q1", + }, + }, + { + ID: "message-q2", TaskID: "t1", TaskSessionID: "s1", + Metadata: map[string]any{ + "status": "pending", "pending_id": pendingID, "question_id": "q2", + }, + }, + } - // Drain the response channel so Respond does not block indefinitely. - go func() { - _, _ = h.store.WaitForResponse(context.Background(), pendingID) - }() + waitDone := startTestClarificationWaiter(t, h, pendingID) body := RespondBody{ Answers: []Answer{ @@ -279,6 +998,9 @@ func TestHttpRespond_AllAnswers_PrimaryPath_Success(t *testing.T) { if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d (body=%s)", rec.Code, rec.Body.String()) } + if err := <-waitDone; err != nil { + t.Fatalf("live clarification waiter: %v", err) + } if len(msgCreator.updates) != 2 { t.Fatalf("expected 2 message updates (one per question), got %d: %+v", @@ -289,6 +1011,22 @@ func TestHttpRespond_AllAnswers_PrimaryPath_Success(t *testing.T) { t.Errorf("expected status=answered, got %q", u.status) } } + if msgCreator.finalizedBundles != 1 { + t.Fatalf("finalized bundles = %d, want 1", msgCreator.finalizedBundles) + } + for _, message := range repo.messages[pendingID] { + if marker := message.Metadata["response_delivery_pending"]; marker != nil { + t.Fatalf("message %s retained delivery marker %v", message.ID, marker) + } + } + if len(msgCreator.publishedMessages) != 1 { + t.Fatalf("published message bundles = %d, want 1", len(msgCreator.publishedMessages)) + } + for _, message := range msgCreator.publishedMessages[0] { + if marker := message.Metadata["response_delivery_pending"]; marker != nil { + t.Fatalf("published message %s retained delivery marker %v", message.ID, marker) + } + } } // TestValidateAndNormalizeQuestions_AssignsDefaults checks that question and @@ -368,12 +1106,27 @@ func TestBuildAnswerSummary_MultiQuestion(t *testing.T) { } func runRespond(t *testing.T, h *Handlers, pendingID string, body RespondBody) *httptest.ResponseRecorder { + t.Helper() + return runRespondWithContext(t, h, pendingID, body, context.Background()) +} + +func runRespondWithContext( + t *testing.T, + h *Handlers, + pendingID string, + body RespondBody, + ctx context.Context, +) *httptest.ResponseRecorder { t.Helper() payload, err := json.Marshal(body) if err != nil { t.Fatalf("marshal: %v", err) } - req := httptest.NewRequest(http.MethodPost, "/api/v1/clarification/"+pendingID+"/respond", bytes.NewReader(payload)) + req := httptest.NewRequest( + http.MethodPost, + "/api/v1/clarification/"+pendingID+"/respond", + bytes.NewReader(payload), + ).WithContext(ctx) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) diff --git a/apps/backend/internal/clarification/store.go b/apps/backend/internal/clarification/store.go index 7ae3717c5d..7651117c2c 100644 --- a/apps/backend/internal/clarification/store.go +++ b/apps/backend/internal/clarification/store.go @@ -17,6 +17,8 @@ var ( ErrAlreadyResponded = errors.New("response already submitted") ) +const maxStartedDeliveryConfirmationWait = 5 * time.Minute + // Store manages pending clarification requests. // It provides thread-safe storage and notification when responses arrive. type Store struct { @@ -29,6 +31,9 @@ type Store struct { // coordinate multi-waiter scenarios deterministically; always nil in // production. onWaitEntered func(pendingID string) + // onRespondLoaded coordinates cancellation-after-lookup tests. It is always + // nil in production. + onRespondLoaded func(pendingID string) } // NewStore creates a new clarification store. @@ -50,6 +55,14 @@ func (s *Store) SetOnWaitEntered(fn func(pendingID string)) { s.onWaitEntered = fn } +// SetOnRespondLoaded installs a test hook invoked after Respond loads a +// pending clarification and before it claims the entry. +func (s *Store) SetOnRespondLoaded(fn func(pendingID string)) { + s.mu.Lock() + defer s.mu.Unlock() + s.onRespondLoaded = fn +} + // CreateRequest creates a new clarification request and returns its pending ID // plus a boolean indicating whether a new entry was created (true) or an // existing one was reused (false). If a pending entry for the same session @@ -120,16 +133,10 @@ func (s *Store) WaitForResponse(ctx context.Context, pendingID string) (*Respons select { case <-pending.done: - // Clean up after receiving response - s.mu.Lock() - delete(s.pending, pendingID) - s.mu.Unlock() - return pending.resp, nil + return s.consumeResponse(pendingID, pending) case <-pending.CancelCh: // Agent's turn completed — cancel the blocking wait - s.mu.Lock() - delete(s.pending, pendingID) - s.mu.Unlock() + s.deletePendingIfCurrent(pendingID, pending) return nil, fmt.Errorf("clarification cancelled (agent moved on): %s", pendingID) case <-timeoutCtx.Done(): if ctx.Err() != nil { @@ -137,38 +144,190 @@ func (s *Store) WaitForResponse(ctx context.Context, pendingID string) (*Respons // because another waiter may still be blocked on it. return nil, ctx.Err() } - // Store-level timeout — safe to clean up. - s.mu.Lock() - delete(s.pending, pendingID) - s.mu.Unlock() + // Store-level timeout — cancel the shared entry unless a response won + // the race and already woke this waiter. + if !s.cancelPendingIfCurrent(pendingID, pending) { + select { + case <-pending.done: + return s.consumeResponse(pendingID, pending) + default: + } + } return nil, fmt.Errorf("clarification request timed out: %s", pendingID) } } +func (s *Store) consumeResponse( + pendingID string, + pending *PendingClarification, +) (*Response, error) { + pending.mu.Lock() + confirm := pending.deliveryConfirmation + abandoned := pending.deliveryAbandoned + pending.mu.Unlock() + if abandoned { + s.deletePendingIfCurrent(pendingID, pending) + return nil, errors.New("clarification response delivery was abandoned") + } + if confirm != nil { + pending.deliveryConfirmationOnce.Do(func() { + pending.mu.Lock() + if pending.deliveryAbandoned { + pending.deliveryConfirmationErr = errors.New("clarification response delivery was abandoned") + pending.deliveryConfirmationComplete = true + confirmationDone := pending.deliveryConfirmationDone + pending.mu.Unlock() + close(confirmationDone) + return + } + pending.deliveryConfirmationStarted = true + pending.mu.Unlock() + err := confirm() + pending.mu.Lock() + pending.deliveryConfirmationErr = err + pending.deliveryConfirmationComplete = true + confirmationDone := pending.deliveryConfirmationDone + pending.mu.Unlock() + close(confirmationDone) + }) + pending.mu.Lock() + confirmationErr := pending.deliveryConfirmationErr + pending.mu.Unlock() + if confirmationErr != nil { + s.deletePendingIfCurrent(pendingID, pending) + return nil, fmt.Errorf("confirm clarification response delivery: %w", confirmationErr) + } + } + pending.mu.Lock() + resp := pending.resp + pending.mu.Unlock() + s.deletePendingIfCurrent(pendingID, pending) + return resp, nil +} + +func (s *Store) deletePendingIfCurrent(pendingID string, pending *PendingClarification) { + s.mu.Lock() + defer s.mu.Unlock() + if s.pending[pendingID] == pending { + delete(s.pending, pendingID) + } +} + // Respond submits a response to a pending clarification request. // Returns an error if the request is not found. func (s *Store) Respond(pendingID string, resp *Response) error { + return s.respond(context.Background(), pendingID, resp, nil) +} + +// RespondWithDeliveryConfirmation submits a response and waits for its live +// waiter to durably confirm delivery before either side returns success. +func (s *Store) RespondWithDeliveryConfirmation( + ctx context.Context, + pendingID string, + resp *Response, + confirm func() error, +) error { + if ctx == nil { + return errors.New("clarification delivery confirmation context is required") + } + if confirm == nil { + return errors.New("clarification delivery confirmation is required") + } + return s.respond(ctx, pendingID, resp, confirm) +} + +func (s *Store) respond( + ctx context.Context, + pendingID string, + resp *Response, + confirm func() error, +) error { s.mu.RLock() pending, ok := s.pending[pendingID] + hook := s.onRespondLoaded s.mu.RUnlock() + if hook != nil { + hook(pendingID) + } if !ok { return fmt.Errorf("%w: %s", ErrNotFound, pendingID) } pending.mu.Lock() - defer pending.mu.Unlock() + if pending.cancelled { + pending.mu.Unlock() + return fmt.Errorf("%w: %s", ErrNotFound, pendingID) + } if pending.resolved { + pending.mu.Unlock() return fmt.Errorf("%w: %s", ErrAlreadyResponded, pendingID) } resp.PendingID = pendingID resp.RespondedAt = time.Now() pending.resp = resp + pending.deliveryConfirmation = confirm + if confirm != nil { + pending.deliveryConfirmationDone = make(chan struct{}) + } pending.resolved = true close(pending.done) - return nil + confirmationDone := pending.deliveryConfirmationDone + pending.mu.Unlock() + if confirmationDone == nil { + return nil + } + waitCtx, cancel := context.WithTimeout(ctx, s.timeout) + defer cancel() + + select { + case <-confirmationDone: + return clarificationDeliveryConfirmationResult(pending) + case <-waitCtx.Done(): + pending.mu.Lock() + if pending.deliveryConfirmationComplete { + confirmationErr := pending.deliveryConfirmationErr + pending.mu.Unlock() + return confirmationErr + } + if pending.deliveryConfirmationStarted { + pending.mu.Unlock() + return s.waitForStartedDeliveryConfirmation(ctx, pending, confirmationDone) + } + pending.deliveryAbandoned = true + pending.mu.Unlock() + s.deletePendingIfCurrent(pendingID, pending) + return fmt.Errorf("wait for clarification delivery confirmation: %w", waitCtx.Err()) + } +} + +func (s *Store) waitForStartedDeliveryConfirmation( + ctx context.Context, + pending *PendingClarification, + confirmationDone <-chan struct{}, +) error { + // A started callback owns its durable operation through completion, even if + // the responder stops waiting. WithoutCancel protects that operation from + // caller cancellation; the second bound extends total latency by at most + // min(s.timeout, five minutes). If it expires, callback inputs must remain + // immutable and callback results must remain callback-owned. + finishTimeout := min(s.timeout, maxStartedDeliveryConfirmationWait) + finishCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), finishTimeout) + defer cancel() + select { + case <-confirmationDone: + return clarificationDeliveryConfirmationResult(pending) + case <-finishCtx.Done(): + return fmt.Errorf("wait for started clarification delivery confirmation: %w", finishCtx.Err()) + } +} + +func clarificationDeliveryConfirmationResult(pending *PendingClarification) error { + pending.mu.Lock() + defer pending.mu.Unlock() + return pending.deliveryConfirmationErr } // CancelRequest cancels a single pending clarification by id, unblocking any @@ -183,9 +342,7 @@ func (s *Store) CancelRequest(pendingID string) bool { if !ok { return false } - close(pending.CancelCh) - delete(s.pending, pendingID) - return true + return s.cancelPendingLocked(pendingID, pending) } // ListPending returns a snapshot of all pending clarification requests. @@ -210,15 +367,41 @@ func (s *Store) CancelSession(sessionID string) []string { var cancelled []string for id, pending := range s.pending { - if pending.Request.SessionID == sessionID { - close(pending.CancelCh) - delete(s.pending, id) + if pending.Request.SessionID == sessionID && s.cancelPendingLocked(id, pending) { cancelled = append(cancelled, id) } } return cancelled } +func (s *Store) cancelPendingIfCurrent( + pendingID string, + pending *PendingClarification, +) bool { + s.mu.Lock() + defer s.mu.Unlock() + if s.pending[pendingID] != pending { + return false + } + return s.cancelPendingLocked(pendingID, pending) +} + +// cancelPendingLocked cancels an unresolved entry while s.mu is write-locked. +func (s *Store) cancelPendingLocked( + pendingID string, + pending *PendingClarification, +) bool { + pending.mu.Lock() + defer pending.mu.Unlock() + if pending.resolved || pending.cancelled { + return false + } + pending.cancelled = true + close(pending.CancelCh) + delete(s.pending, pendingID) + return true +} + func questionsEqual(a, b []Question) bool { if len(a) != len(b) { return false diff --git a/apps/backend/internal/clarification/store_delivery_confirmation_test.go b/apps/backend/internal/clarification/store_delivery_confirmation_test.go new file mode 100644 index 0000000000..78fe336613 --- /dev/null +++ b/apps/backend/internal/clarification/store_delivery_confirmation_test.go @@ -0,0 +1,338 @@ +package clarification + +import ( + "context" + "errors" + "sync" + "testing" + "testing/synctest" + "time" +) + +func TestWaitForResponseConfirmsDurableDeliveryBeforeReturning(t *testing.T) { + s := NewStore(time.Minute) + id, _ := s.CreateRequest(&Request{SessionID: "s1"}) + confirmationStarted := make(chan struct{}) + releaseConfirmation := make(chan struct{}) + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(releaseConfirmation) }) } + t.Cleanup(release) + waitDone := make(chan error, 1) + respondDone := make(chan error, 1) + go func() { + _, err := s.WaitForResponse(context.Background(), id) + waitDone <- err + }() + go func() { + respondDone <- s.RespondWithDeliveryConfirmation(context.Background(), id, &Response{}, func() error { + close(confirmationStarted) + <-releaseConfirmation + return nil + }) + }() + select { + case <-confirmationStarted: + case <-time.After(time.Second): + t.Fatal("waiter did not start durable delivery confirmation") + } + if s.CancelRequest(id) { + t.Fatal("resolved clarification was cancelled during delivery confirmation") + } + select { + case err := <-waitDone: + t.Fatalf("waiter returned before durable confirmation: %v", err) + default: + } + select { + case err := <-respondDone: + t.Fatalf("responder returned before durable confirmation: %v", err) + default: + } + release() + select { + case err := <-waitDone: + if err != nil { + t.Fatalf("WaitForResponse: %v", err) + } + case <-time.After(time.Second): + t.Fatal("waiter did not return after durable confirmation") + } + select { + case err := <-respondDone: + if err != nil { + t.Fatalf("RespondWithDeliveryConfirmation: %v", err) + } + case <-time.After(time.Second): + t.Fatal("responder did not return after durable confirmation") + } +} + +func TestWaitForResponseRejectsFailedDeliveryConfirmation(t *testing.T) { + s := NewStore(time.Minute) + id, _ := s.CreateRequest(&Request{SessionID: "s1"}) + wantErr := errors.New("database unavailable") + waitDone := make(chan error, 1) + go func() { + _, err := s.WaitForResponse(context.Background(), id) + waitDone <- err + }() + + if err := s.RespondWithDeliveryConfirmation( + context.Background(), + id, + &Response{}, + func() error { return wantErr }, + ); !errors.Is(err, wantErr) { + t.Fatalf("RespondWithDeliveryConfirmation error = %v, want %v", err, wantErr) + } + select { + case err := <-waitDone: + if !errors.Is(err, wantErr) { + t.Fatalf("WaitForResponse error = %v, want %v", err, wantErr) + } + case <-time.After(time.Second): + t.Fatal("waiter did not return after failed confirmation") + } +} + +func TestRespondWithDeliveryConfirmationAbandonsUnconsumedResponse(t *testing.T) { + s := NewStore(time.Minute) + id, _ := s.CreateRequest(&Request{SessionID: "s1"}) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + confirmed := false + + err := s.RespondWithDeliveryConfirmation(ctx, id, &Response{}, func() error { + confirmed = true + return nil + }) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("RespondWithDeliveryConfirmation error = %v, want deadline exceeded", err) + } + if confirmed { + t.Fatal("delivery was confirmed without a consuming waiter") + } + if _, err := s.WaitForResponse(context.Background(), id); err == nil { + t.Fatal("abandoned response remained consumable") + } +} + +func TestRespondWithDeliveryConfirmationUsesStoreTimeoutWithoutWaiter(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + s := NewStore(time.Second) + id, _ := s.CreateRequest(&Request{SessionID: "s1"}) + respondDone := make(chan error, 1) + go func() { + respondDone <- s.RespondWithDeliveryConfirmation( + context.Background(), + id, + &Response{}, + func() error { return nil }, + ) + }() + + synctest.Wait() + time.Sleep(time.Second) + synctest.Wait() + select { + case err := <-respondDone: + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("RespondWithDeliveryConfirmation error = %v, want store deadline", err) + } + default: + waitDone := make(chan error, 1) + go func() { + _, err := s.WaitForResponse(context.Background(), id) + waitDone <- err + }() + synctest.Wait() + <-respondDone + <-waitDone + t.Fatal("RespondWithDeliveryConfirmation ignored the store timeout") + } + }) +} + +func TestRespondWithDeliveryConfirmationReturnsNotFoundWhenCancellationWinsAfterLookup(t *testing.T) { + s := NewStore(time.Minute) + id, _ := s.CreateRequest(&Request{SessionID: "s1"}) + respondLoaded := make(chan struct{}) + releaseRespond := make(chan struct{}) + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(releaseRespond) }) } + t.Cleanup(release) + s.SetOnRespondLoaded(func(string) { + close(respondLoaded) + <-releaseRespond + }) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + respondDone := make(chan error, 1) + confirmationCalled := make(chan struct{}, 1) + go func() { + respondDone <- s.RespondWithDeliveryConfirmation(ctx, id, &Response{}, func() error { + confirmationCalled <- struct{}{} + return nil + }) + }() + + <-respondLoaded + if !s.CancelRequest(id) { + t.Fatal("CancelRequest returned false for known clarification") + } + cancel() + release() + + select { + case err := <-respondDone: + if !errors.Is(err, ErrNotFound) { + t.Fatalf("RespondWithDeliveryConfirmation error = %v, want %v", err, ErrNotFound) + } + case <-time.After(time.Second): + t.Fatal("RespondWithDeliveryConfirmation blocked on a cancelled clarification") + } + select { + case <-confirmationCalled: + t.Fatal("cancelled clarification attempted delivery confirmation") + default: + } +} + +func TestRespondWithDeliveryConfirmationFinishesStartedConfirmationAfterDeadline(t *testing.T) { + s := NewStore(time.Minute) + id, _ := s.CreateRequest(&Request{SessionID: "s1"}) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + confirmationStarted := make(chan struct{}) + releaseConfirmation := make(chan struct{}) + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(releaseConfirmation) }) } + t.Cleanup(release) + waitDone := make(chan error, 1) + respondDone := make(chan error, 1) + go func() { + _, err := s.WaitForResponse(context.Background(), id) + waitDone <- err + }() + go func() { + respondDone <- s.RespondWithDeliveryConfirmation(ctx, id, &Response{}, func() error { + close(confirmationStarted) + <-releaseConfirmation + return nil + }) + }() + + <-confirmationStarted + <-ctx.Done() + select { + case err := <-respondDone: + t.Fatalf("started confirmation was abandoned at deadline: %v", err) + default: + } + release() + if err := <-respondDone; err != nil { + t.Fatalf("RespondWithDeliveryConfirmation: %v", err) + } + if err := <-waitDone; err != nil { + t.Fatalf("WaitForResponse: %v", err) + } +} + +func TestRespondWithDeliveryConfirmationBoundsStartedConfirmationAfterDeadline(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + s := NewStore(time.Second) + id, _ := s.CreateRequest(&Request{SessionID: "s1"}) + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + confirmationStarted := make(chan struct{}) + releaseConfirmation := make(chan struct{}) + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(releaseConfirmation) }) } + defer release() + waitDone := make(chan error, 1) + respondDone := make(chan error, 1) + go func() { + _, err := s.WaitForResponse(context.Background(), id) + waitDone <- err + }() + go func() { + respondDone <- s.RespondWithDeliveryConfirmation(ctx, id, &Response{}, func() error { + close(confirmationStarted) + <-releaseConfirmation + return nil + }) + }() + + synctest.Wait() + select { + case <-confirmationStarted: + default: + t.Fatal("waiter did not start delivery confirmation") + } + time.Sleep(100 * time.Millisecond) + synctest.Wait() + time.Sleep(time.Second) + synctest.Wait() + select { + case err := <-respondDone: + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("RespondWithDeliveryConfirmation error = %v, want store deadline", err) + } + default: + t.Fatal("RespondWithDeliveryConfirmation blocked after its finish timeout") + } + select { + case err := <-waitDone: + t.Fatalf("WaitForResponse returned before confirmation finished: %v", err) + default: + } + + release() + synctest.Wait() + if err := <-waitDone; err != nil { + t.Fatalf("WaitForResponse: %v", err) + } + }) +} + +func TestRespondWithDeliveryConfirmationCapsStartedConfirmationBelowStoreTimeout(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + s := NewStore(10 * time.Minute) + id, _ := s.CreateRequest(&Request{SessionID: "s1"}) + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + confirmationStarted := make(chan struct{}) + releaseConfirmation := make(chan struct{}) + defer close(releaseConfirmation) + go func() { + _, _ = s.WaitForResponse(context.Background(), id) + }() + respondDone := make(chan error, 1) + go func() { + respondDone <- s.RespondWithDeliveryConfirmation(ctx, id, &Response{}, func() error { + close(confirmationStarted) + <-releaseConfirmation + return nil + }) + }() + + synctest.Wait() + select { + case <-confirmationStarted: + default: + t.Fatal("waiter did not start delivery confirmation") + } + time.Sleep(100 * time.Millisecond) + synctest.Wait() + time.Sleep(5 * time.Minute) + synctest.Wait() + select { + case err := <-respondDone: + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("RespondWithDeliveryConfirmation error = %v, want capped deadline", err) + } + default: + t.Fatal("RespondWithDeliveryConfirmation used the longer store timeout") + } + }) +} diff --git a/apps/backend/internal/clarification/types.go b/apps/backend/internal/clarification/types.go index 9d7c52f826..bc4000aa72 100644 --- a/apps/backend/internal/clarification/types.go +++ b/apps/backend/internal/clarification/types.go @@ -59,8 +59,20 @@ type PendingClarification struct { resp *Response mu sync.Mutex resolved bool - CancelCh chan struct{} // Closed when session's turn completes (agent moved on) - CreatedAt time.Time + cancelled bool + // mu guards the deliveryConfirmation* and deliveryAbandoned fields except + // deliveryConfirmationOnce. deliveryConfirmationDone exists only when a + // callback was supplied; started and complete distinguish an in-flight + // callback from its result. + deliveryConfirmation func() error + deliveryConfirmationOnce sync.Once + deliveryConfirmationErr error + deliveryConfirmationDone chan struct{} + deliveryConfirmationStarted bool + deliveryConfirmationComplete bool + deliveryAbandoned bool + CancelCh chan struct{} // Closed when session's turn completes (agent moved on) + CreatedAt time.Time } // Status represents the status of a clarification request. diff --git a/apps/backend/internal/integration/test_server_test.go b/apps/backend/internal/integration/test_server_test.go index 78650fd96f..17eb26a576 100644 --- a/apps/backend/internal/integration/test_server_test.go +++ b/apps/backend/internal/integration/test_server_test.go @@ -230,6 +230,33 @@ func (a *testTurnServiceAdapter) StartTurn(ctx context.Context, sessionID string return a.svc.StartTurn(ctx, sessionID) } +func (a *testTurnServiceAdapter) ReserveTurn( + ctx context.Context, + sessionID string, + recovery *models.PromptDispatchRecovery, +) (*models.Turn, error) { + return a.svc.ReserveTurn(ctx, sessionID, recovery) +} + +func (a *testTurnServiceAdapter) PublishReservedTurn(ctx context.Context, turn *models.Turn) error { + return a.svc.PublishReservedTurn(ctx, turn) +} + +func (a *testTurnServiceAdapter) MarkReservedTurnDispatchAttempted(ctx context.Context, turn *models.Turn) error { + return a.svc.MarkReservedTurnDispatchAttempted(ctx, turn) +} + +func (a *testTurnServiceAdapter) RollbackReservedTurn( + ctx context.Context, + sessionID, turnID string, +) (bool, error) { + return a.svc.RollbackReservedTurn(ctx, sessionID, turnID) +} + +func (a *testTurnServiceAdapter) ReconcileUnpublishedPromptTurns(ctx context.Context) (int, error) { + return a.svc.ReconcileUnpublishedPromptTurns(ctx) +} + func (a *testTurnServiceAdapter) CompleteTurn(ctx context.Context, turnID string) error { return a.svc.CompleteTurn(ctx, turnID) } @@ -246,6 +273,14 @@ func (a *testTurnServiceAdapter) UpdateTurn(ctx context.Context, turn *models.Tu return a.svc.UpdateTurn(ctx, turn) } +func (a *testTurnServiceAdapter) PatchTurnMetadata( + ctx context.Context, + sessionID, turnID string, + updates map[string]interface{}, +) error { + return a.svc.PatchTurnMetadata(ctx, sessionID, turnID, updates) +} + func (a *testTurnServiceAdapter) AbandonOpenTurns(ctx context.Context, sessionID string) error { return a.svc.AbandonOpenTurns(ctx, sessionID) } diff --git a/apps/backend/internal/mcp/handlers/clarification_pause_test.go b/apps/backend/internal/mcp/handlers/clarification_pause_test.go index bef8a70f81..41a70e40d8 100644 --- a/apps/backend/internal/mcp/handlers/clarification_pause_test.go +++ b/apps/backend/internal/mcp/handlers/clarification_pause_test.go @@ -20,24 +20,35 @@ type recordingClarificationInputPauser struct { count int err error before func() + results []clarificationPauseResult } func (p *recordingClarificationInputPauser) PauseForClarificationInput(_ context.Context, sessionID string) (int, error) { if p.before != nil { p.before() } + resultIndex := len(p.sessions) p.sessions = append(p.sessions, sessionID) + if resultIndex < len(p.results) { + return p.results[resultIndex].count, p.results[resultIndex].err + } return p.count, p.err } +type clarificationPauseResult struct { + count int + err error +} + type recordingSessionCanceller struct { sessions []string count int + err error } -func (c *recordingSessionCanceller) DetachSessionAndNotify(_ context.Context, sessionID string) int { +func (c *recordingSessionCanceller) DetachSessionAndNotify(_ context.Context, sessionID string) (int, error) { c.sessions = append(c.sessions, sessionID) - return c.count + return c.count, c.err } type immediateClarificationService struct { @@ -308,7 +319,7 @@ func TestHandleClarificationTimeout_FallsBackWhenHardPauseFails(t *testing.T) { resp, err := h.handleClarificationTimeout(context.Background(), msg) require.NoError(t, err) require.Equal(t, ws.MessageTypeResponse, resp.Type) - require.Equal(t, []string{"s1"}, pauser.sessions) + require.Equal(t, []string{"s1", "s1"}, pauser.sessions) require.Equal(t, []string{"s1"}, canceller.sessions) var payload map[string]interface{} @@ -319,6 +330,29 @@ func TestHandleClarificationTimeout_FallsBackWhenHardPauseFails(t *testing.T) { require.NotContains(t, string(resp.Payload), "db unavailable") } +func TestHandleClarificationTimeout_RetriesCompletePauseBeforeDetachFallback(t *testing.T) { + pauser := &recordingClarificationInputPauser{results: []clarificationPauseResult{ + {err: errors.New("db unavailable")}, + {count: 2}, + }} + canceller := &recordingSessionCanceller{count: 3} + h := &Handlers{logger: testLogger(t).WithFields(), sessionCanceller: canceller} + h.SetClarificationInputPauser(pauser) + + msg := makeWSMessage(t, ws.ActionMCPClarificationTimeout, map[string]interface{}{"session_id": "s1"}) + resp, err := h.handleClarificationTimeout(context.Background(), msg) + require.NoError(t, err) + require.Equal(t, ws.MessageTypeResponse, resp.Type) + require.Equal(t, []string{"s1", "s1"}, pauser.sessions) + require.Empty(t, canceller.sessions) + + var payload map[string]interface{} + require.NoError(t, json.Unmarshal(resp.Payload, &payload)) + require.Equal(t, true, payload["ok"]) + require.Equal(t, true, payload["paused"]) + require.Equal(t, float64(2), payload["cancelled"]) +} + func TestHandleAskUserQuestion_NoAnswerPausesSession(t *testing.T) { svc, repo := newTestTaskService(t) ctx := context.Background() diff --git a/apps/backend/internal/mcp/handlers/handlers.go b/apps/backend/internal/mcp/handlers/handlers.go index d76e892675..3d48e7443d 100644 --- a/apps/backend/internal/mcp/handlers/handlers.go +++ b/apps/backend/internal/mcp/handlers/handlers.go @@ -67,7 +67,7 @@ type workspaceSourceJSON struct { // SessionCanceller detaches in-memory clarification waiters while keeping DB // messages pending. Used by the MCP-timeout handler. type SessionCanceller interface { - DetachSessionAndNotify(ctx context.Context, sessionID string) int + DetachSessionAndNotify(ctx context.Context, sessionID string) (int, error) } // ClarificationInputPauser performs the orchestrator-owned hard pause for @@ -3900,6 +3900,9 @@ func (h *Handlers) handleDeleteWalkthrough(ctx context.Context, msg *ws.Message) // clarification so the user's eventual answer goes through the event fallback path // (new turn) instead of the primary path (which would be dropped). func (h *Handlers) handleClarificationTimeout(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + const cancelledField = "cancelled" + const pausedField = "paused" + var req struct { SessionID string `json:"session_id"` } @@ -3911,34 +3914,69 @@ func (h *Handlers) handleClarificationTimeout(ctx context.Context, msg *ws.Messa } if h.inputPauser != nil { - cancelled, err := h.inputPauser.PauseForClarificationInput(context.WithoutCancel(ctx), req.SessionID) + cancelled, paused, err := h.pauseOrDetachForClarificationTimeout( + context.WithoutCancel(ctx), + req.SessionID, + ) if err != nil { - h.logger.Warn("failed to pause session after clarification timeout", - zap.String("session_id", req.SessionID), - zap.Error(err)) - if h.sessionCanceller == nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, - "failed to pause session for clarification input", nil) - } - cancelled = h.sessionCanceller.DetachSessionAndNotify(context.WithoutCancel(ctx), req.SessionID) - h.logger.Info("detached clarification waiters after pause failure", - zap.String("session_id", req.SessionID), - zap.Int("count", cancelled)) - return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{"ok": true, "cancelled": cancelled, "paused": false}) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) } - h.logger.Info("paused session after agent MCP clarification timeout", - zap.String("session_id", req.SessionID), - zap.Int("count", cancelled)) - return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{"ok": true, "cancelled": cancelled, "paused": true}) + return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ + "ok": true, cancelledField: cancelled, pausedField: paused, + }) } if h.sessionCanceller == nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "sessionCanceller is required", nil) } - cancelled := h.sessionCanceller.DetachSessionAndNotify(ctx, req.SessionID) + cancelled, err := h.sessionCanceller.DetachSessionAndNotify(ctx, req.SessionID) + if err != nil { + h.logger.Warn("failed to detach clarification after MCP timeout", + zap.String("session_id", req.SessionID), + zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, + "failed to detach clarification after MCP timeout", nil) + } h.logger.Info("detached pending clarifications on agent MCP timeout", zap.String("session_id", req.SessionID), zap.Int("count", cancelled)) - return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{"ok": true, "cancelled": cancelled}) + return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{"ok": true, cancelledField: cancelled}) +} + +func (h *Handlers) pauseOrDetachForClarificationTimeout( + ctx context.Context, + sessionID string, +) (int, bool, error) { + cancelled, err := h.inputPauser.PauseForClarificationInput(ctx, sessionID) + if err == nil { + h.logger.Info("paused session after agent MCP clarification timeout", + zap.String("session_id", sessionID), zap.Int("count", cancelled)) + return cancelled, true, nil + } + h.logger.Warn("failed to pause session after clarification timeout", + zap.String("session_id", sessionID), zap.Error(err)) + + // Retry the complete idempotent pause before falling back to waiter + // detachment. Each pauser attempt owns a fresh bounded context. + cancelled, err = h.inputPauser.PauseForClarificationInput(ctx, sessionID) + if err == nil { + h.logger.Info("paused session after retrying clarification timeout", + zap.String("session_id", sessionID), zap.Int("count", cancelled)) + return cancelled, true, nil + } + h.logger.Warn("failed to pause session after retrying clarification timeout", + zap.String("session_id", sessionID), zap.Error(err)) + if h.sessionCanceller == nil { + return 0, false, errors.New("failed to pause session for clarification input") + } + cancelled, err = h.sessionCanceller.DetachSessionAndNotify(ctx, sessionID) + if err != nil { + h.logger.Warn("failed to detach clarification after pause failure", + zap.String("session_id", sessionID), zap.Error(err)) + return 0, false, errors.New("failed to detach clarification after pause failure") + } + h.logger.Info("detached clarification waiters after pause failure", + zap.String("session_id", sessionID), zap.Int("count", cancelled)) + return cancelled, false, nil } diff --git a/apps/backend/internal/mcp/handlers/handlers_test.go b/apps/backend/internal/mcp/handlers/handlers_test.go index d2fb908958..2047dcd013 100644 --- a/apps/backend/internal/mcp/handlers/handlers_test.go +++ b/apps/backend/internal/mcp/handlers/handlers_test.go @@ -2975,7 +2975,7 @@ func TestHandleCreateTask_BlockedBy_Accepted(t *testing.T) { } func TestHandleClarificationTimeout_DetachesMessages(t *testing.T) { - svc, repo := newTestTaskService(t) + svc, repo, eventBus := newTestTaskServiceWithEventBus(t) ctx := context.Background() require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Test"})) @@ -2999,9 +2999,7 @@ func TestHandleClarificationTimeout_DetachesMessages(t *testing.T) { require.NoError(t, repo.CreateTurn(ctx, turn)) store := clarification.NewStore(time.Minute) - eventBus := bus.NewMemoryEventBus(testLogger(t)) - t.Cleanup(func() { eventBus.Close() }) - canceller := clarification.NewCanceller(store, repo, eventBus, testLogger(t)) + canceller := clarification.NewCanceller(store, repo, svc, testLogger(t)) pendingID := "test-pending-id" require.NoError(t, repo.CreateMessage(ctx, &models.Message{ @@ -3017,6 +3015,13 @@ func TestHandleClarificationTimeout_DetachesMessages(t *testing.T) { "status": "pending", }, })) + var messageUpdated *bus.Event + subscription, err := eventBus.Subscribe(events.MessageUpdated, func(_ context.Context, event *bus.Event) error { + messageUpdated = event + return nil + }) + require.NoError(t, err) + t.Cleanup(func() { _ = subscription.Unsubscribe() }) // Drain the in-memory store so the handler must fall back to DB-driven cleanup store.CancelSession(sess.ID) @@ -3027,10 +3032,26 @@ func TestHandleClarificationTimeout_DetachesMessages(t *testing.T) { require.NoError(t, err) require.Equal(t, ws.MessageTypeResponse, resp.Type) - msgs, err := repo.FindPendingClarificationMessagesBySessionID(ctx, sess.ID) + msgs, err := repo.FindActiveClarificationMessagesBySessionID(ctx, sess.ID) require.NoError(t, err) require.Len(t, msgs, 1, "clarification should stay pending for deferred answer") require.Equal(t, true, msgs[0].Metadata["agent_disconnected"]) + require.NotNil(t, messageUpdated) + eventData, ok := messageUpdated.Data.(map[string]interface{}) + require.True(t, ok) + require.Equal(t, string(models.TaskPendingActionClarification), eventData["pending_action"]) + require.IsType(t, models.PendingActionRevision{}, eventData["pending_action_revision"]) + + messageUpdated = nil + expired, err := canceller.ExpireSessionAndNotify(ctx, sess.ID) + require.NoError(t, err) + require.Equal(t, 1, expired) + require.NotNil(t, messageUpdated) + eventData, ok = messageUpdated.Data.(map[string]interface{}) + require.True(t, ok) + require.Contains(t, eventData, "pending_action") + require.Nil(t, eventData["pending_action"]) + require.IsType(t, models.PendingActionRevision{}, eventData["pending_action_revision"]) } func TestHandleClarificationTimeout_WithoutCanceller_ReturnsError(t *testing.T) { diff --git a/apps/backend/internal/orchestrator/clarification_guard.go b/apps/backend/internal/orchestrator/clarification_guard.go index 65e2616203..93931f77d8 100644 --- a/apps/backend/internal/orchestrator/clarification_guard.go +++ b/apps/backend/internal/orchestrator/clarification_guard.go @@ -15,7 +15,7 @@ func (s *Service) sessionHasPendingClarification(ctx context.Context, sessionID if sessionID == "" { return false } - msgs, err := s.repo.FindPendingClarificationMessagesBySessionID(ctx, sessionID) + msgs, err := s.repo.FindActiveClarificationMessagesBySessionID(ctx, sessionID) if err != nil { s.logger.Warn("failed to check pending clarifications; blocking turn-complete transition", zap.String("session_id", sessionID), diff --git a/apps/backend/internal/orchestrator/clarification_guard_test.go b/apps/backend/internal/orchestrator/clarification_guard_test.go index 4cc9e44617..c0c88c7b1b 100644 --- a/apps/backend/internal/orchestrator/clarification_guard_test.go +++ b/apps/backend/internal/orchestrator/clarification_guard_test.go @@ -40,6 +40,43 @@ func TestSessionHasPendingClarification(t *testing.T) { } } +func TestSessionHasPendingClarificationIgnoresOlderDurableTurn(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "task-current-turn", "session-current-turn", "step1") + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + base := time.Date(2026, time.August, 14, 16, 0, 0, 0, time.UTC) + requireNoError(t, repo.CreateTurn(ctx, &models.Turn{ + ID: "turn-old", TaskSessionID: "session-current-turn", TaskID: "task-current-turn", + StartedAt: base, CreatedAt: base, + })) + requireNoError(t, repo.CreateMessage(ctx, &models.Message{ + ID: "clarification-old", TaskSessionID: "session-current-turn", TaskID: "task-current-turn", + TurnID: "turn-old", AuthorType: models.MessageAuthorAgent, + Type: models.MessageTypeClarificationRequest, Content: "old", CreatedAt: base, + Metadata: map[string]any{"pending_id": "pending-old", "status": "pending"}, + })) + requireNoError(t, repo.CreateTurn(ctx, &models.Turn{ + ID: "turn-new", TaskSessionID: "session-current-turn", TaskID: "task-current-turn", + StartedAt: base.Add(time.Minute), CreatedAt: base.Add(time.Minute), + })) + + if svc.sessionHasPendingClarification(ctx, "session-current-turn") { + t.Fatal("older-turn clarification blocked turn completion") + } +} + +func TestSessionHasPendingClarificationFailsClosedOnRepositoryError(t *testing.T) { + repo := setupTestRepo(t) + seedSession(t, repo, "task-error", "session-error", "step1") + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if !svc.sessionHasPendingClarification(ctx, "session-error") { + t.Fatal("repository error opened the workflow barrier") + } +} + func seedPendingClarificationMessage(t *testing.T, repo interface { CreateTurn(ctx context.Context, turn *models.Turn) error CreateMessage(ctx context.Context, message *models.Message) error diff --git a/apps/backend/internal/orchestrator/clarification_turn_authority.go b/apps/backend/internal/orchestrator/clarification_turn_authority.go new file mode 100644 index 0000000000..8f582d68c0 --- /dev/null +++ b/apps/backend/internal/orchestrator/clarification_turn_authority.go @@ -0,0 +1,53 @@ +package orchestrator + +import ( + "context" + "errors" + + "go.uber.org/zap" +) + +func (s *Service) clarificationTurnStillCurrent(ctx context.Context, data clarificationAnsweredData) bool { + if s.turnService == nil { + s.logger.Warn("skipping clarification fallback: turn service unavailable", + zap.String("session_id", data.SessionID), + zap.String("pending_id", data.PendingID)) + return false + } + if data.ClarificationTurnID == "" { + s.logger.Debug("skipping clarification fallback: event carries no turn ID", + zap.String("session_id", data.SessionID), + zap.String("pending_id", data.PendingID)) + return false + } + turn, err := s.turnService.GetActiveTurn(ctx, data.SessionID) + if err != nil { + if ctx.Err() != nil || errors.Is(err, context.Canceled) { + s.logger.Debug("clarification fallback authority check canceled", + zap.String("session_id", data.SessionID), + zap.String("pending_id", data.PendingID), + zap.String("clarification_turn_id", data.ClarificationTurnID), + zap.Error(err)) + return false + } + s.logger.Warn("failed to verify clarification fallback turn authority", + zap.String("session_id", data.SessionID), + zap.String("pending_id", data.PendingID), + zap.String("clarification_turn_id", data.ClarificationTurnID), + zap.Error(err)) + return false + } + if turn == nil || turn.ID != data.ClarificationTurnID { + currentTurnID := "" + if turn != nil { + currentTurnID = turn.ID + } + s.logger.Info("skipping superseded clarification fallback", + zap.String("session_id", data.SessionID), + zap.String("pending_id", data.PendingID), + zap.String("clarification_turn_id", data.ClarificationTurnID), + zap.String("current_turn_id", currentTurnID)) + return false + } + return true +} diff --git a/apps/backend/internal/orchestrator/clarification_turn_authority_test.go b/apps/backend/internal/orchestrator/clarification_turn_authority_test.go new file mode 100644 index 0000000000..c46adfe328 --- /dev/null +++ b/apps/backend/internal/orchestrator/clarification_turn_authority_test.go @@ -0,0 +1,145 @@ +package orchestrator + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/task/models" +) + +func TestClarificationTurnAuthorityDistinguishesMissingDependencyFromMissingIdentity(t *testing.T) { + core, logs := observer.New(zapcore.DebugLevel) + observedLogger, err := logger.NewFromZap(zap.New(core)) + if err != nil { + t.Fatalf("create observed logger: %v", err) + } + svc := &Service{logger: observedLogger} + data := clarificationAnsweredData{SessionID: "session-1", PendingID: "pending-1"} + + data.ClarificationTurnID = "turn-1" + if svc.clarificationTurnStillCurrent(context.Background(), data) { + t.Fatal("missing turn service accepted clarification fallback") + } + warnings := logs.FilterMessage("skipping clarification fallback: turn service unavailable").All() + if len(warnings) != 1 || warnings[0].Level != zapcore.WarnLevel { + t.Fatalf("missing turn service logs = %#v, want one warning", warnings) + } + logs.TakeAll() + + svc.turnService = &repoTurnService{repo: setupTestRepo(t)} + data.ClarificationTurnID = "" + if svc.clarificationTurnStillCurrent(context.Background(), data) { + t.Fatal("missing clarification turn ID accepted fallback") + } + debugEntries := logs.FilterMessage("skipping clarification fallback: event carries no turn ID").All() + if len(debugEntries) != 1 || debugEntries[0].Level != zapcore.DebugLevel { + t.Fatalf("missing turn ID logs = %#v, want one debug entry", debugEntries) + } +} + +func TestClarificationTurnAuthorityLogsCanceledLookupAtDebug(t *testing.T) { + core, logs := observer.New(zapcore.DebugLevel) + observedLogger, err := logger.NewFromZap(zap.New(core)) + if err != nil { + t.Fatalf("create observed logger: %v", err) + } + svc := &Service{ + logger: observedLogger, + turnService: &failingActiveTurnLookup{err: context.Canceled}, + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + data := clarificationAnsweredData{ + SessionID: "session-1", PendingID: "pending-1", ClarificationTurnID: "turn-1", + } + + if svc.clarificationTurnStillCurrent(ctx, data) { + t.Fatal("canceled turn lookup accepted clarification fallback") + } + if warnings := logs.FilterLevelExact(zapcore.WarnLevel).All(); len(warnings) != 0 { + t.Fatalf("canceled turn lookup warnings = %#v, want none", warnings) + } + debugEntries := logs.FilterMessage("clarification fallback authority check canceled").All() + if len(debugEntries) != 1 || debugEntries[0].Level != zapcore.DebugLevel { + t.Fatalf("canceled turn lookup logs = %#v, want one debug entry", debugEntries) + } +} + +func TestClarificationWatchdogDoesNotDispatchAfterTurnIsSuperseded(t *testing.T) { + svc, agentMgr := setupSupersededClarificationTurn(t) + svc.clarificationWatchdogTimeout = time.Millisecond + t.Cleanup(func() { svc.cancelAllClarificationWatchdogs() }) + svc.scheduleClarificationWatchdog(clarificationAnsweredData{ + TaskID: "task-watchdog-authority", SessionID: "session-watchdog-authority", + PendingID: "pending-watchdog-authority", ClarificationTurnID: "turn-clarification", + AnswerText: "Continue", + }) + require.Eventually(t, func() bool { + return countClarificationWatchdogs(svc) == 0 + }, time.Second, time.Millisecond, "watchdog goroutine should exit after turn is superseded") + + agentMgr.mu.Lock() + defer agentMgr.mu.Unlock() + if got := len(agentMgr.capturedPromptCalls); got != 0 { + t.Fatalf("stale watchdog prompt calls = %d, want 0", got) + } +} + +func TestPromptAdmissionRejectsSupersededClarificationTurn(t *testing.T) { + svc, agentMgr := setupSupersededClarificationTurn(t) + _, err := svc.promptTask( + context.Background(), + "task-watchdog-authority", + "session-watchdog-authority", + "Continue", + "", + false, + nil, + false, + promptTaskOptions{expectedCurrentTurnID: "turn-clarification"}, + ) + if err == nil { + t.Fatal("superseded clarification prompt admission succeeded") + } + agentMgr.mu.Lock() + defer agentMgr.mu.Unlock() + if got := len(agentMgr.capturedPromptCalls); got != 0 { + t.Fatalf("superseded admission prompt calls = %d, want 0", got) + } +} + +func setupSupersededClarificationTurn(t *testing.T) (*Service, *mockAgentManager) { + t.Helper() + ctx := context.Background() + repo := setupTestRepo(t) + seedTaskAndSession(t, repo, "task-watchdog-authority", "session-watchdog-authority", models.TaskSessionStateWaitingForInput) + seedExecutorRunning(t, repo, "session-watchdog-authority", "task-watchdog-authority", "exec-watchdog-authority") + base := time.Date(2026, time.August, 16, 16, 40, 0, 0, time.UTC) + completedAt := base.Add(time.Second) + if err := repo.CreateTurn(ctx, &models.Turn{ + ID: "turn-clarification", TaskID: "task-watchdog-authority", + TaskSessionID: "session-watchdog-authority", StartedAt: base, CreatedAt: base, + CompletedAt: &completedAt, + }); err != nil { + t.Fatalf("create clarification turn: %v", err) + } + if err := repo.CreateTurn(ctx, &models.Turn{ + ID: "turn-successor", TaskID: "task-watchdog-authority", + TaskSessionID: "session-watchdog-authority", StartedAt: base.Add(time.Minute), + CreatedAt: base.Add(time.Minute), + }); err != nil { + t.Fatalf("create successor turn: %v", err) + } + + agentMgr := &mockAgentManager{isAgentRunning: true, repoForExecutionLookup: repo} + svc := createTestServiceWithScheduler(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr) + svc.turnService = &repoTurnService{repo: repo} + return svc, agentMgr +} diff --git a/apps/backend/internal/orchestrator/event_handlers_agent.go b/apps/backend/internal/orchestrator/event_handlers_agent.go index 652dc7735f..c3922d09ba 100644 --- a/apps/backend/internal/orchestrator/event_handlers_agent.go +++ b/apps/backend/internal/orchestrator/event_handlers_agent.go @@ -20,6 +20,30 @@ import ( v1 "github.com/kandev/kandev/pkg/api/v1" ) +type reservedPromptCallbackContextKey struct{} + +// agentReadyDetachedContext ignores transient event-delivery cancellation but +// preserves shutdown cancellation for service-owned deferred callbacks. +func agentReadyDetachedContext(ctx context.Context) context.Context { + if owned, _ := ctx.Value(reservedPromptCallbackContextKey{}).(bool); owned { + return ctx + } + return context.WithoutCancel(ctx) +} + +func reservedPromptCallbackContext( + ownerCtx context.Context, + retryCtx context.Context, +) (context.Context, context.CancelFunc) { + callbackCtx, cancel := context.WithCancel(retryCtx) + stopOwnerCancellation := context.AfterFunc(ownerCtx, cancel) + callbackCtx = context.WithValue(callbackCtx, reservedPromptCallbackContextKey{}, true) + return callbackCtx, func() { + stopOwnerCancellation() + cancel() + } +} + // handleAgentRunning handles agent running events (user sent input in passthrough mode) // This is called when the user sends input to the agent, indicating a new turn started. func (s *Service) handleAgentRunning(ctx context.Context, data watcher.AgentEventData) { @@ -452,27 +476,90 @@ func (s *Service) handleAgentReady(ctx context.Context, data watcher.AgentEventD lock.Unlock() } }() - for s.isCancelInFlight(data.SessionID) { - // The cancellation owner temporarily releases this mutex while the - // lifecycle manager waits for terminal stream frames. Do not complete - // the still-running turn in that window, but also do not discard this - // ready event: if cancellation fails, it is the event that must finish - // the unchanged turn and drain its queue. + waitedForReservation := false + for { + for s.isCancelInFlight(data.SessionID) { + // The cancellation owner temporarily releases this mutex while the + // lifecycle manager waits for terminal stream frames. Do not complete + // the still-running turn in that window, but also do not discard this + // ready event: if cancellation fails, it is the event that must finish + // the unchanged turn and drain its queue. + lock.Unlock() + guardLocked = false + if err := s.waitForCancelInFlight(agentReadyDetachedContext(ctx), data.SessionID); err != nil { + // A cancellation error does not make this ready event stale. The + // owner may have failed before mutating session/turn state; once the + // operation is gone, re-read both below and let this event settle its + // captured turn. Returning here would strand the turn and any queued + // peer message with no future ready event to drain it. + s.logger.Warn("cancellation failed while agent.ready was waiting; re-evaluating the captured turn", + zap.String("task_id", data.TaskID), + zap.String("session_id", data.SessionID), + zap.Error(err)) + } + lock.Lock() + guardLocked = true + } + + reservation := s.reservedPromptTurn(data.SessionID) + if reservation == nil { + break + } + waitedForReservation = true lock.Unlock() guardLocked = false - if err := s.waitForCancelInFlight(context.WithoutCancel(ctx), data.SessionID); err != nil { - // A cancellation error does not make this ready event stale. The - // owner may have failed before mutating session/turn state; once the - // operation is gone, re-read both below and let this event settle its - // captured turn. Returning here would strand the turn and any queued - // peer message with no future ready event to drain it. - s.logger.Warn("cancellation failed while agent.ready was waiting; re-evaluating the captured turn", - zap.String("task_id", data.TaskID), - zap.String("session_id", data.SessionID), - zap.Error(err)) + waitTimeout := s.agentReadyReservationWaitTimeout + if waitTimeout <= 0 { + waitTimeout = detachedClarificationDispatchTimeout + promptFailureCleanupTimeout } + waitCtx, cancel := context.WithTimeout(agentReadyDetachedContext(ctx), waitTimeout) + accepted, waitErr := reservation.wait(waitCtx) + cancel() lock.Lock() guardLocked = true + if waitErr != nil { + if data.PromptGeneration == 0 { + s.logger.Warn("dropping generationless agent.ready after reserved prompt wait timed out", + zap.String("task_id", data.TaskID), + zap.String("session_id", data.SessionID), + zap.String("turn_id", reservation.id), + zap.Error(waitErr)) + return + } + // A later reservation may outlive this callback invocation. Preserve + // request values, then attach the lifecycle owner again when it runs. + retryCtx := context.WithoutCancel(ctx) + deferred := s.deferReservedPromptCallback(reservation, func(ownerCtx context.Context) { + callbackCtx, cancelCallback := reservedPromptCallbackContext(ownerCtx, retryCtx) + defer cancelCallback() + s.handleAgentReady(callbackCtx, data) + }) + if !deferred { + continue + } + s.logger.Warn("agent.ready timed out waiting for reserved prompt dispatch; deferred reconciliation until resolution", + zap.String("task_id", data.TaskID), + zap.String("session_id", data.SessionID), + zap.String("turn_id", reservation.id), + zap.Error(waitErr)) + return + } + if !accepted { + s.logger.Debug("revalidating agent.ready after overlapping prompt reservation rolled back", + zap.String("task_id", data.TaskID), + zap.String("session_id", data.SessionID), + zap.String("turn_id", reservation.id)) + } + } + if waitedForReservation { + ctx = agentReadyDetachedContext(ctx) + if data.PromptGeneration == 0 { + s.logger.Debug("ignoring generationless agent.ready that overlapped a prompt reservation", + zap.String("task_id", data.TaskID), + zap.String("session_id", data.SessionID)) + return + } + turnAtEventFire, turnSnapshotErr = s.peekActiveTurnID(ctx, data.SessionID) } // Re-validate now that the guard is held: a concurrent interrupt (or @@ -756,7 +843,11 @@ func (s *Service) executeQueuedMessageWithReservation( afterClaim := s.queuedLifecycleAfterClaim(promptCtx, queuedMsg, attachments, lifecyclePrompt) _, err := s.promptTask(promptCtx, queuedMsg.TaskID, queuedMsg.SessionID, promptContent, queuedMsg.Model, queuedMsg.PlanMode, attachments, false, - claimEntryID, lifecyclePrompt, afterClaim) + promptTaskOptions{ + claimEntryID: claimEntryID, + lifecyclePrompt: lifecyclePrompt, + afterClaim: afterClaim, + }) s.finishQueuedMessageExecution( promptCtx, callerSessionID, reservedSessionID, queuedMsg, lifecyclePrompt, userMessageRecorded, err, diff --git a/apps/backend/internal/orchestrator/event_handlers_agent_clarification_test.go b/apps/backend/internal/orchestrator/event_handlers_agent_clarification_test.go index 5c8579fa58..6809aa7119 100644 --- a/apps/backend/internal/orchestrator/event_handlers_agent_clarification_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_agent_clarification_test.go @@ -16,12 +16,26 @@ type repoBackedTurnService struct { repo testRepo } +type failingReservedTurnAttemptMarker struct { + TurnService + err error +} + +func (s failingReservedTurnAttemptMarker) MarkReservedTurnDispatchAttempted(context.Context, *models.Turn) error { + return s.err +} + type testRepo interface { CreateTurn(ctx context.Context, turn *models.Turn) error CompleteTurn(ctx context.Context, id string) error GetTurn(ctx context.Context, id string) (*models.Turn, error) GetActiveTurnBySessionID(ctx context.Context, sessionID string) (*models.Turn, error) UpdateTurn(ctx context.Context, turn *models.Turn) error + PatchTurnMetadata( + ctx context.Context, + sessionID, turnID string, + updates map[string]interface{}, + ) (bool, time.Time, error) } func (s *repoBackedTurnService) StartTurn(ctx context.Context, sessionID string) (*models.Turn, error) { @@ -36,6 +50,37 @@ func (s *repoBackedTurnService) StartTurn(ctx context.Context, sessionID string) return turn, nil } +func (s *repoBackedTurnService) ReserveTurn( + ctx context.Context, + sessionID string, + _ *models.PromptDispatchRecovery, +) (*models.Turn, error) { + return s.StartTurn(ctx, sessionID) +} + +func (s *repoBackedTurnService) PublishReservedTurn(context.Context, *models.Turn) error { return nil } + +func (s *repoBackedTurnService) MarkReservedTurnDispatchAttempted(context.Context, *models.Turn) error { + return nil +} + +func (s *repoBackedTurnService) RollbackReservedTurn( + ctx context.Context, + sessionID, turnID string, +) (bool, error) { + deleter, ok := s.repo.(interface { + DeleteTurnIfUnreferenced(context.Context, string, string) (bool, error) + }) + if !ok { + return false, errors.New("test turn repository cannot roll back reserved turns") + } + return deleter.DeleteTurnIfUnreferenced(ctx, sessionID, turnID) +} + +func (s *repoBackedTurnService) ReconcileUnpublishedPromptTurns(context.Context) (int, error) { + return 0, nil +} + func (s *repoBackedTurnService) CompleteTurn(ctx context.Context, turnID string) error { return s.repo.CompleteTurn(ctx, turnID) } @@ -45,13 +90,29 @@ func (s *repoBackedTurnService) GetTurn(ctx context.Context, turnID string) (*mo } func (s *repoBackedTurnService) GetActiveTurn(ctx context.Context, sessionID string) (*models.Turn, error) { - return s.repo.GetActiveTurnBySessionID(ctx, sessionID) + turn, err := s.repo.GetActiveTurnBySessionID(ctx, sessionID) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return turn, err } func (s *repoBackedTurnService) UpdateTurn(ctx context.Context, turn *models.Turn) error { return s.repo.UpdateTurn(ctx, turn) } +func (s *repoBackedTurnService) PatchTurnMetadata( + ctx context.Context, + sessionID, turnID string, + updates map[string]interface{}, +) error { + updated, _, err := s.repo.PatchTurnMetadata(ctx, sessionID, turnID, updates) + if err == nil && !updated { + return sql.ErrNoRows + } + return err +} + func (s *repoBackedTurnService) AbandonOpenTurns(ctx context.Context, sessionID string) error { for { turn, err := s.repo.GetActiveTurnBySessionID(ctx, sessionID) diff --git a/apps/backend/internal/orchestrator/event_handlers_clarification.go b/apps/backend/internal/orchestrator/event_handlers_clarification.go index 5cff44ac19..a14f5138dd 100644 --- a/apps/backend/internal/orchestrator/event_handlers_clarification.go +++ b/apps/backend/internal/orchestrator/event_handlers_clarification.go @@ -11,13 +11,24 @@ import ( "go.uber.org/zap" "github.com/kandev/kandev/internal/agent/runtime/lifecycle" + "github.com/kandev/kandev/internal/clarification" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/orchestrator/messagequeue" "github.com/kandev/kandev/internal/task/models" ) -const clarificationInputPauseTimeout = 30 * time.Second +const ( + clarificationInputPauseTimeout = 30 * time.Second + // Detached answers may first cold-resume the runtime. Keep that startup + // bounded while preserving the synchronous accept-or-restore contract; a + // 202 response could falsely acknowledge a dispatch that never reached it. + detachedClarificationDispatchTimeout = 30 * time.Second +) + +func clarificationInputPhaseContext(ctx context.Context) (context.Context, context.CancelFunc) { + return context.WithTimeout(context.WithoutCancel(ctx), clarificationInputPauseTimeout) +} // subscribeClarificationEvents subscribes to clarification-related events. func (s *Service) subscribeClarificationEvents() { @@ -100,13 +111,14 @@ func (s *Service) handleClarificationStaleDismissed(ctx context.Context, event * // clarificationAnsweredData is the event payload for ClarificationAnswered events. type clarificationAnsweredData struct { - SessionID string `json:"session_id"` - TaskID string `json:"task_id"` - PendingID string `json:"pending_id"` - Question string `json:"question"` - AnswerText string `json:"answer_text"` - Rejected bool `json:"rejected"` - RejectReason string `json:"reject_reason"` + SessionID string `json:"session_id"` + TaskID string `json:"task_id"` + PendingID string `json:"pending_id"` + ClarificationTurnID string `json:"clarification_turn_id"` + Question string `json:"question"` + AnswerText string `json:"answer_text"` + Rejected bool `json:"rejected"` + RejectReason string `json:"reject_reason"` } type clarificationWatchdogEntry struct { @@ -134,6 +146,66 @@ func (s *Service) handleClarificationAnswered(ctx context.Context, event *bus.Ev return nil } + if err := s.resumeDetachedClarification(ctx, data); err != nil { + s.logger.Error("failed to resume agent with clarification answer", + zap.String("task_id", data.TaskID), + zap.String("session_id", data.SessionID), + zap.Error(err)) + } + return nil +} + +// ResumeDetachedClarification synchronously reports whether the orchestrator +// accepted a detached answer. The HTTP handler keeps the durable bundle +// retryable unless this call succeeds. +func (s *Service) ResumeDetachedClarification( + ctx context.Context, + request clarification.DetachedClarificationResume, +) error { + // The HTTP boundary already supplies a fresh bounded persistence context. + // Preserve its remaining total budget and explicit caller cancellation here. + resumeCtx, cancel := context.WithTimeout(ctx, detachedClarificationDispatchTimeout) + defer cancel() + return s.resumeDetachedClarificationWithPrompt(resumeCtx, clarificationAnsweredData{ + SessionID: request.SessionID, + TaskID: request.TaskID, + PendingID: request.PendingID, + ClarificationTurnID: request.ClarificationTurnID, + Question: request.Question, + AnswerText: request.AnswerText, + Rejected: request.Rejected, + RejectReason: request.RejectReason, + }, true, promptTaskOptions{ + preservePromptContext: true, + reserveTurnUntilDispatch: true, + promptDispatchRecovery: &models.PromptDispatchRecovery{ + PendingID: request.PendingID, + TurnID: request.ClarificationTurnID, + MessageIDs: request.ClaimedMessageIDs, + }, + }) +} + +func (s *Service) resumeDetachedClarification(ctx context.Context, data clarificationAnsweredData) error { + // Legacy bus events do not carry a claimed turn ID. Their active-bundle + // producer is gated by FindActiveClarificationMessagesBySessionID and its + // turnAuthorityPredicate, so this path intentionally leaves + // expectedCurrentTurnID unset. Keep that SQL authority boundary intact. + return s.resumeDetachedClarificationWithPrompt(ctx, data, false, promptTaskOptions{}) +} + +func (s *Service) resumeDetachedClarificationWithPrompt( + ctx context.Context, + data clarificationAnsweredData, + dispatchOnly bool, + options promptTaskOptions, +) error { + if data.SessionID == "" || data.TaskID == "" { + return errors.New("detached clarification resume missing session_id or task_id") + } + if err := s.authorizeTaskSessionPair(ctx, data.TaskID, data.SessionID); err != nil { + return err + } prompt := buildClarificationPrompt(data) s.logger.Info("resuming agent with clarification answer", @@ -147,13 +219,17 @@ func (s *Service) handleClarificationAnswered(ctx context.Context, event *bus.Ev // reconcileTaskStateForRuntime. s.writeTaskInProgressForRuntime(ctx, data.TaskID, data.SessionID) - if _, err := s.PromptTask(ctx, data.TaskID, data.SessionID, prompt, "", false, nil, false); err != nil { - if !s.retryClarificationAfterCancel(ctx, data, prompt, err) { - s.logger.Error("failed to resume agent with clarification answer", - zap.String("task_id", data.TaskID), - zap.String("session_id", data.SessionID), - zap.Error(err)) + if _, err := s.promptTask( + ctx, data.TaskID, data.SessionID, prompt, "", false, nil, dispatchOnly, options, + ); err != nil { + // The synchronous HTTP path must not turn an asynchronous queue handoff + // into false acknowledgement. Its handler restores the claimed bundle on + // any error so the user can retry. Event/watchdog recovery keeps the + // existing cancel-and-queue fallback. + if !dispatchOnly && s.retryClarificationAfterCancel(ctx, data, prompt, err) { + return nil } + return fmt.Errorf("prompt task with clarification answer: %w", err) } return nil } @@ -235,26 +311,39 @@ func (s *Service) runClarificationWatchdog( case <-watchCtx.Done(): return case <-timer.C: - current, ok := s.clarificationWatchdogs.LoadAndDelete(key) + current, ok := s.clarificationWatchdogs.Load(key) if !ok || current != entry { return } + defer s.clarificationWatchdogs.CompareAndDelete(key, entry) if entry.cancel != nil { - entry.cancel() + defer entry.cancel() } - s.resumeClarificationViaFallback(data) + s.resumeClarificationViaFallback(watchCtx, data) } } -func (s *Service) resumeClarificationViaFallback(data clarificationAnsweredData) { +func (s *Service) resumeClarificationViaFallback(ctx context.Context, data clarificationAnsweredData) { prompt := buildClarificationPrompt(data) s.logger.Warn("clarification resume watchdog expired; triggering fallback resume", zap.String("task_id", data.TaskID), zap.String("session_id", data.SessionID), zap.String("pending_id", data.PendingID)) - ctx := context.Background() - if _, err := s.PromptTask(ctx, data.TaskID, data.SessionID, prompt, "", false, nil, false); err != nil { + if !s.clarificationTurnStillCurrent(ctx, data) { + return + } + if _, err := s.promptTask( + ctx, + data.TaskID, + data.SessionID, + prompt, + "", + false, + nil, + false, + promptTaskOptions{expectedCurrentTurnID: data.ClarificationTurnID}, + ); err != nil { if !s.retryClarificationAfterCancel(ctx, data, prompt, err) { s.logger.Error("failed to resume agent via clarification watchdog fallback", zap.String("task_id", data.TaskID), @@ -298,25 +387,8 @@ func (s *Service) retryClarificationAfterCancel(ctx context.Context, data clarif // dispatchTakenQueuedMessage) makes the session "busy" under the guard, so // a concurrent interrupt/drain still backs off exactly as an inline retry // would have. - lock, release := s.acquireCancelInFlightGuard(data.SessionID) - lock.Lock() - guardLocked := true - unlockGuard := func() { - if guardLocked { - lock.Unlock() - guardLocked = false - } - } - relockGuard := func() { - if !guardLocked { - lock.Lock() - guardLocked = true - } - } - defer func() { - unlockGuard() - release() - }() + guard := s.lockCancelInFlightGuard(data.SessionID) + defer guard.release() // Coordinator stop may have won while this recovery waited for the shared // guard. Re-read inside the critical section and never revive a terminal @@ -334,8 +406,11 @@ func (s *Service) retryClarificationAfterCancel(ctx context.Context, data clarif zap.String("session_state", string(session.State))) return false } + if !s.clarificationTurnStillCurrent(ctx, data) { + return false + } - if err := s.cancelAgentSilentWithGuard(ctx, data.TaskID, data.SessionID, unlockGuard, relockGuard); err != nil { + if err := s.cancelAgentSilentWithGuard(ctx, data.TaskID, data.SessionID, guard.unlock, guard.relock); err != nil { s.logger.Warn("cancel failed (agent likely dead), force-transitioning session state", zap.String("session_id", data.SessionID), zap.Error(err)) @@ -453,8 +528,11 @@ func (s *Service) PauseForClarificationInput(ctx context.Context, sessionID stri if sessionID == "" { return 0, fmt.Errorf("session_id is required") } - writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), clarificationInputPauseTimeout) + writeCtx, cancel := clarificationInputPhaseContext(ctx) defer cancel() + guard := s.lockCancelInFlightGuard(sessionID) + defer guard.release() + guardedPersistenceStartedAt := time.Now() session, err := s.repo.GetTaskSession(writeCtx, sessionID) if err != nil { return 0, fmt.Errorf("load session for clarification pause: %w", err) @@ -462,55 +540,63 @@ func (s *Service) PauseForClarificationInput(ctx context.Context, sessionID stri if session == nil { return 0, nil } + expectedTurnID := "" + var expectedTurn *string + if s.turnService != nil { + expectedTurnID, err = s.peekActiveTurnID(writeCtx, sessionID) + if err != nil { + return 0, fmt.Errorf("inspect clarification turn before pause: %w", err) + } + expectedTurn = &expectedTurnID + } hasPendingClarification := s.sessionHasPendingClarification(writeCtx, sessionID) detached := 0 + var detachErr error if s.clarificationCanceller != nil { - detached = s.clarificationCanceller.DetachSessionAndNotify(writeCtx, sessionID) + detached, detachErr = s.clarificationCanceller.DetachSessionAndNotify(writeCtx, sessionID) + if detachErr != nil { + detachErr = fmt.Errorf("detach clarification before pause: %w", detachErr) + } } + s.logger.Debug("clarification pause persistence completed under cancellation guard", + zap.String("task_id", session.TaskID), + zap.String("session_id", sessionID), + zap.Duration("guard_hold_duration", time.Since(guardedPersistenceStartedAt))) if isTerminalSessionState(session.State) { + return detached, detachErr + } + if !hasPendingClarification && detached == 0 && detachErr == nil { return detached, nil } - if !hasPendingClarification && detached == 0 { - return detached, nil - } + pauseCtx, cancelPause := clarificationInputPhaseContext(writeCtx) + defer cancelPause() if _, has := models.LoadPendingStepSignal(session.Metadata); has { - s.clearPendingStepSignal(writeCtx, session) + s.clearPendingStepSignal(pauseCtx, session) } - // The backend wait path and agentctl timeout notification can race for the + // Detach and cancellation registration share the same guard as prompt turn + // creation. The backend wait path and agentctl timeout notification can race for the // same ask_user_question call. A duplicate cancel is safe: lifecycle returns // ErrCancelEscalated/ErrNoExecutionForSession once the first pause wins, and // completeTurnForSession is idempotent when there is no active turn left. - // - // Claim the shared per-session guard around the cancel itself — see the - // Service.cancelInFlight field doc comment: every cancel/take-and-dispatch - // decision for a session must serialize through this one guard, including - // this clarification-timeout cancel, or it can race a concurrent parent - // interrupt (or another drain) for the same session. - lock, release := s.acquireCancelInFlightGuard(sessionID) - lock.Lock() - guardLocked := true - unlockGuard := func() { - if guardLocked { - lock.Unlock() - guardLocked = false - } + if err := s.cancelAgentSilentExpectedWithGuard( + pauseCtx, + session.TaskID, + sessionID, + expectedTurn, + guard.unlock, + guard.relock, + ); errors.Is(err, ErrSendNowTurnChanged) { + s.logger.Debug("skipping stale clarification pause after successor turn", + zap.String("task_id", session.TaskID), + zap.String("session_id", sessionID), + zap.String("expected_turn_id", expectedTurnID)) + return detached, detachErr + } else if err != nil { + return detached, errors.Join(detachErr, err) } - relockGuard := func() { - if !guardLocked { - lock.Lock() - guardLocked = true - } - } - defer func() { - unlockGuard() - release() - }() - if err := s.cancelAgentSilentWithGuard(writeCtx, session.TaskID, sessionID, unlockGuard, relockGuard); err != nil { - return detached, err - } - return detached, nil + return detached, detachErr } // cancelAgentSilent cancels the agent turn without creating a visible message @@ -610,23 +696,8 @@ func (s *Service) runSilentCancellation(requestCtx context.Context, taskID, sess } func (s *Service) runSilentCancellationOwned(ctx context.Context, taskID, sessionID string, operation *cancelOperation) error { - lock, release := s.acquireCancelInFlightGuard(sessionID) - defer release() - lock.Lock() - guardLocked := true - unlockGuard := func() { - if guardLocked { - lock.Unlock() - guardLocked = false - } - } - relockGuard := func() { - if !guardLocked { - lock.Lock() - guardLocked = true - } - } - defer unlockGuard() + guard := s.lockCancelInFlightGuard(sessionID) + defer guard.release() // Capture only the execution/turn identity before yielding the guard. The // peer-interrupt path can race a ready handler that is blocked in its first @@ -643,7 +714,7 @@ func (s *Service) runSilentCancellationOwned(ctx context.Context, taskID, sessio return ErrSendNowTurnChanged } s.setCancellationIdentity(sessionID, operation, identity) - if err := s.cancelAgentWhileUnlocked(ctx, sessionID, unlockGuard, relockGuard); err != nil { + if err := s.cancelAgentWhileUnlocked(ctx, sessionID, guard.unlock, guard.relock); err != nil { return err } session, err := s.repo.GetTaskSession(ctx, sessionID) @@ -695,8 +766,42 @@ func (s *Service) cancelAgentSilentWithGuard( unlockGuard func(), relockGuard func(), ) error { - _, err := s.cancelAgentSilentWithGuardAction(ctx, taskID, sessionID, unlockGuard, relockGuard, nil) - return err + return s.cancelAgentSilentExpectedWithGuard( + ctx, taskID, sessionID, nil, unlockGuard, relockGuard, + ) +} + +// cancelAgentSilentExpectedWithGuard registers cancellation before releasing +// the caller's guard. A prompt claim therefore cannot slip into the gap, and a +// turn created outside the orchestrator is rejected by the expected identity. +// A non-nil expectedTurnID records either one specific turn or an explicit +// no-turn expectation; nil preserves the fallback when turnService is unwired. +// unlockGuard and relockGuard must be non-nil paired callbacks for the +// caller-held guard. A joining non-owner keeps the original owner's +// expected-turn snapshot instead of replacing it. +func (s *Service) cancelAgentSilentExpectedWithGuard( + ctx context.Context, + taskID, sessionID string, + expectedTurnID *string, + unlockGuard, relockGuard func(), +) error { + if s.repo == nil { + return errors.New("cancel agent silently: repository is not configured") + } + operation, owner, _ := s.claimCancellationWithAction( + sessionID, + cancellationKindSilent, + nil, + ) + if owner && expectedTurnID != nil { + s.setCancellationExpectedTurn(sessionID, operation, *expectedTurnID) + } + if owner { + go s.runSilentCancellation(ctx, taskID, sessionID, operation) + } + unlockGuard() + defer relockGuard() + return operation.wait(ctx) } func (s *Service) cancelAgentSilentWithGuardAction( diff --git a/apps/backend/internal/orchestrator/event_handlers_clarification_test.go b/apps/backend/internal/orchestrator/event_handlers_clarification_test.go index 134b5c4744..0f131b5f6b 100644 --- a/apps/backend/internal/orchestrator/event_handlers_clarification_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_clarification_test.go @@ -11,6 +11,7 @@ import ( "time" "github.com/kandev/kandev/internal/agent/runtime/lifecycle" + "github.com/kandev/kandev/internal/clarification" "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/orchestrator/executor" "github.com/kandev/kandev/internal/task/models" @@ -22,9 +23,71 @@ type zeroClarificationCanceller struct { sessions []string } -func (c *zeroClarificationCanceller) DetachSessionAndNotify(_ context.Context, sessionID string) int { +func (c *zeroClarificationCanceller) DetachSessionAndNotify(_ context.Context, sessionID string) (int, error) { c.sessions = append(c.sessions, sessionID) - return 0 + return 0, nil +} + +func (c *zeroClarificationCanceller) ExpireSessionAndNotify(context.Context, string) (int, error) { + return 0, nil +} + +type failingDetachClarificationCanceller struct { + err error +} + +func (c *failingDetachClarificationCanceller) DetachSessionAndNotify(context.Context, string) (int, error) { + return 0, c.err +} + +func (c *failingDetachClarificationCanceller) ExpireSessionAndNotify(context.Context, string) (int, error) { + return 0, nil +} + +func TestClarificationInputPhaseContextStartsFreshAfterPriorCancellation(t *testing.T) { + prior, cancelPrior := context.WithCancel(context.Background()) + cancelPrior() + + phase, cancelPhase := clarificationInputPhaseContext(prior) + defer cancelPhase() + if err := phase.Err(); err != nil { + t.Fatalf("fresh clarification phase inherited prior cancellation: %v", err) + } + if _, ok := phase.Deadline(); !ok { + t.Fatal("fresh clarification phase has no bounded deadline") + } +} + +type failingStartTurnService struct { + TurnService + err error +} + +func (s *failingStartTurnService) StartTurn(context.Context, string) (*models.Turn, error) { + return nil, s.err +} + +func (s *failingStartTurnService) ReserveTurn( + context.Context, + string, + *models.PromptDispatchRecovery, +) (*models.Turn, error) { + return nil, s.err +} + +type blockingClarificationCanceller struct { + entered chan struct{} + release chan struct{} +} + +func (c *blockingClarificationCanceller) DetachSessionAndNotify(context.Context, string) (int, error) { + close(c.entered) + <-c.release + return 1, nil +} + +func (c *blockingClarificationCanceller) ExpireSessionAndNotify(context.Context, string) (int, error) { + return 0, nil } func TestHandleClarificationAnswered(t *testing.T) { @@ -93,6 +156,457 @@ func TestHandleClarificationAnswered(t *testing.T) { }) } +func TestResumeDetachedClarificationReportsPromptFailure(t *testing.T) { + repo := setupTestRepo(t) + agentMgr := &mockAgentManager{isAgentRunning: true} + svc := createTestServiceWithScheduler(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr) + svc.eventBus = &recordingEventBus{} + seedTaskAndSession(t, repo, "t1", "s1", models.TaskSessionStateCompleted) + + err := svc.ResumeDetachedClarification(context.Background(), clarification.DetachedClarificationResume{ + TaskID: "t1", + SessionID: "s1", + PendingID: "pending-1", + Question: "Which database?", + AnswerText: "User selected: PostgreSQL", + }) + if err == nil { + t.Fatal("detached resume reported success after PromptTask failed") + } +} + +func TestResumeDetachedClarificationDispatchFailureLeavesBundleRestorable(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "task-retry", "session-retry", "step-1") + seedExecutorRunning(t, repo, "session-retry", "task-retry", "exec-retry") + if err := repo.UpdateTaskSessionState( + ctx, + "session-retry", + models.TaskSessionStateWaitingForInput, + "", + ); err != nil { + t.Fatalf("set session waiting: %v", err) + } + + startedAt := time.Now().UTC().Add(-time.Minute) + completedAt := startedAt.Add(time.Second) + if err := repo.CreateTurn(ctx, &models.Turn{ + ID: "turn-clarification", + TaskSessionID: "session-retry", + TaskID: "task-retry", + StartedAt: startedAt, + CompletedAt: &completedAt, + }); err != nil { + t.Fatalf("create clarification turn: %v", err) + } + if err := repo.CreateMessage(ctx, &models.Message{ + ID: "message-clarification", + TaskSessionID: "session-retry", + TaskID: "task-retry", + TurnID: "turn-clarification", + AuthorType: models.MessageAuthorAgent, + Type: models.MessageTypeClarificationRequest, + Metadata: map[string]interface{}{ + "pending_id": "pending-retry", + "question_id": "q1", + "status": "pending", + }, + CreatedAt: startedAt, + }); err != nil { + t.Fatalf("create clarification message: %v", err) + } + claimedMessages, claimed, err := repo.CompleteActiveClarificationBundle( + ctx, + "pending-retry", + "answered", + map[string]interface{}{"q1": map[string]interface{}{"question_id": "q1"}}, + ) + if err != nil || !claimed { + t.Fatalf("claim clarification bundle: claimed=%v err=%v", claimed, err) + } + + dispatchErr := errors.New("prompt dispatch failed") + agentMgr := &mockAgentManager{ + isAgentRunning: true, + repoForExecutionLookup: repo, + promptErr: dispatchErr, + } + svc := createEngineService(t, repo, newMockStepGetter(), agentMgr) + svc.turnService = &repoBackedTurnService{repo: repo} + + err = svc.ResumeDetachedClarification(ctx, clarification.DetachedClarificationResume{ + TaskID: "task-retry", + SessionID: "session-retry", + PendingID: "pending-retry", + Question: "Continue?", + AnswerText: "Continue", + }) + if !errors.Is(err, dispatchErr) { + t.Fatalf("resume error = %v, want %v", err, dispatchErr) + } + + _, restored, err := repo.RestoreActiveClarificationBundle( + ctx, + "pending-retry", + "answered", + claimedMessages, + ) + if err != nil || !restored { + t.Fatalf("restore after failed dispatch: restored=%v err=%v", restored, err) + } + turns, err := repo.ListTurnsBySession(ctx, "session-retry") + if err != nil { + t.Fatalf("list turns: %v", err) + } + if len(turns) != 1 || turns[0].ID != "turn-clarification" { + t.Fatalf("turns after failed dispatch = %#v, want only original clarification turn", turns) + } + + _, claimed, err = repo.CompleteActiveClarificationBundle( + ctx, + "pending-retry", + "answered", + map[string]interface{}{"q1": map[string]interface{}{"question_id": "q1"}}, + ) + if err != nil || !claimed { + t.Fatalf("reclaim clarification bundle: claimed=%v err=%v", claimed, err) + } + agentMgr.mu.Lock() + agentMgr.promptErr = nil + agentMgr.mu.Unlock() + if err := svc.ResumeDetachedClarification(ctx, clarification.DetachedClarificationResume{ + TaskID: "task-retry", + SessionID: "session-retry", + PendingID: "pending-retry", + Question: "Continue?", + AnswerText: "Continue", + }); err != nil { + t.Fatalf("retry detached resume: %v", err) + } + turns, err = repo.ListTurnsBySession(ctx, "session-retry") + if err != nil { + t.Fatalf("list turns after retry: %v", err) + } + if len(turns) != 2 || turns[1].ID != "turn-session-retry" { + t.Fatalf("turns after accepted retry = %#v, want one successor turn", turns) + } +} + +func TestResumeDetachedClarificationReportsAcceptedPublicationFailure(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "task-publish-failure", "session-publish-failure", "step-1") + seedExecutorRunning(t, repo, "session-publish-failure", "task-publish-failure", "exec-publish-failure") + if err := repo.UpdateTaskSessionState( + ctx, + "session-publish-failure", + models.TaskSessionStateWaitingForInput, + "", + ); err != nil { + t.Fatalf("set session waiting: %v", err) + } + + publicationErr := errors.New("publish accepted turn") + agentMgr := &mockAgentManager{isAgentRunning: true, repoForExecutionLookup: repo} + svc := createEngineService(t, repo, newMockStepGetter(), agentMgr) + svc.turnService = failingReservedTurnPublisher{ + TurnService: &repoBackedTurnService{repo: repo}, + err: publicationErr, + } + + err := svc.ResumeDetachedClarification(ctx, clarification.DetachedClarificationResume{ + TaskID: "task-publish-failure", + SessionID: "session-publish-failure", + PendingID: "pending-publish-failure", + Question: "Continue?", + AnswerText: "Continue", + }) + if err == nil { + t.Fatal("resume reported success without durable dispatch publication") + } + var accepted interface{ DetachedResumeAccepted() bool } + if !errors.As(err, &accepted) || !accepted.DetachedResumeAccepted() { + t.Fatalf("resume error = %v, want accepted-dispatch marker", err) + } + if !errors.Is(err, publicationErr) { + t.Fatalf("resume error = %v, want publication error %v", err, publicationErr) + } + if pending := svc.reservedPromptTurnID("session-publish-failure"); pending != "" { + t.Fatalf("accepted turn remained rollback-eligible: %q", pending) + } + active, ok := svc.activeTurns.Load("session-publish-failure") + if !ok || active == "" { + t.Fatalf("accepted turn active ownership = %v, %v", active, ok) + } +} + +func TestResumeDetachedClarificationCleansUpAcceptedDispatchFailure(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "task-accepted-failure", "session-accepted-failure", "step-1") + seedExecutorRunning(t, repo, "session-accepted-failure", "task-accepted-failure", "exec-accepted-failure") + if err := repo.UpdateTaskSessionState( + ctx, + "session-accepted-failure", + models.TaskSessionStateWaitingForInput, + "", + ); err != nil { + t.Fatalf("set session waiting: %v", err) + } + + dispatchErr := errors.New("stream failed after prompt acceptance") + publicationErr := errors.New("publish accepted turn") + agentMgr := &mockAgentManager{ + isAgentRunning: true, + repoForExecutionLookup: repo, + promptErr: dispatchErr, + promptAcceptedOnError: true, + } + svc := createEngineService(t, repo, newMockStepGetter(), agentMgr) + svc.turnService = failingReservedTurnPublisher{ + TurnService: &repoBackedTurnService{repo: repo}, + err: publicationErr, + } + + err := svc.ResumeDetachedClarification(ctx, clarification.DetachedClarificationResume{ + TaskID: "task-accepted-failure", + SessionID: "session-accepted-failure", + PendingID: "pending-accepted-failure", + Question: "Continue?", + AnswerText: "Continue", + }) + if !errors.Is(err, dispatchErr) || !errors.Is(err, publicationErr) { + t.Fatalf("resume error = %v, want dispatch %v and publication %v", err, dispatchErr, publicationErr) + } + var accepted interface{ DetachedResumeAccepted() bool } + if !errors.As(err, &accepted) || !accepted.DetachedResumeAccepted() { + t.Fatalf("resume error = %v, want accepted-dispatch marker", err) + } + session, getErr := repo.GetTaskSession(ctx, "session-accepted-failure") + if getErr != nil { + t.Fatalf("load session after failure: %v", getErr) + } + if session.State != models.TaskSessionStateWaitingForInput { + t.Fatalf("session state after accepted failure = %q, want %q", session.State, models.TaskSessionStateWaitingForInput) + } +} + +func TestResumeDetachedClarificationRejectsBeforeDispatchWhenTurnPersistenceFails(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "task-turn-failure", "session-turn-failure", "step-1") + seedExecutorRunning(t, repo, "session-turn-failure", "task-turn-failure", "exec-turn-failure") + if err := repo.UpdateTaskSessionState( + ctx, + "session-turn-failure", + models.TaskSessionStateWaitingForInput, + "", + ); err != nil { + t.Fatalf("set session waiting: %v", err) + } + + persistenceErr := errors.New("persist successor turn") + agentMgr := &mockAgentManager{isAgentRunning: true, repoForExecutionLookup: repo} + svc := createEngineService(t, repo, newMockStepGetter(), agentMgr) + svc.turnService = &failingStartTurnService{ + TurnService: &repoBackedTurnService{repo: repo}, + err: persistenceErr, + } + + err := svc.ResumeDetachedClarification(ctx, clarification.DetachedClarificationResume{ + TaskID: "task-turn-failure", + SessionID: "session-turn-failure", + PendingID: "pending-turn-failure", + Question: "Continue?", + AnswerText: "Continue", + }) + if !errors.Is(err, persistenceErr) { + t.Fatalf("resume error = %v, want %v", err, persistenceErr) + } + agentMgr.mu.Lock() + promptCalls := len(agentMgr.capturedPromptCalls) + agentMgr.mu.Unlock() + if promptCalls != 0 { + t.Fatalf("prompt dispatch calls = %d, want none before successor persistence", promptCalls) + } + session, err := repo.GetTaskSession(ctx, "session-turn-failure") + if err != nil { + t.Fatalf("load session after persistence failure: %v", err) + } + if session.State != models.TaskSessionStateWaitingForInput { + t.Fatalf("session state after persistence failure = %q, want waiting", session.State) + } +} + +func TestResumeDetachedClarificationRejectsBeforeDispatchWhenAttemptMarkerFails(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "task-marker-failure", "session-marker-failure", "step-1") + seedExecutorRunning(t, repo, "session-marker-failure", "task-marker-failure", "exec-marker-failure") + if err := repo.UpdateTaskSessionState( + ctx, + "session-marker-failure", + models.TaskSessionStateWaitingForInput, + "", + ); err != nil { + t.Fatalf("set session waiting: %v", err) + } + + persistenceErr := errors.New("persist dispatch attempt marker") + agentMgr := &mockAgentManager{isAgentRunning: true, repoForExecutionLookup: repo} + svc := createEngineService(t, repo, newMockStepGetter(), agentMgr) + svc.turnService = failingReservedTurnAttemptMarker{ + TurnService: &repoBackedTurnService{repo: repo}, + err: persistenceErr, + } + + err := svc.ResumeDetachedClarification(ctx, clarification.DetachedClarificationResume{ + TaskID: "task-marker-failure", + SessionID: "session-marker-failure", + PendingID: "pending-marker-failure", + Question: "Continue?", + AnswerText: "Continue", + }) + if !errors.Is(err, persistenceErr) { + t.Fatalf("resume error = %v, want %v", err, persistenceErr) + } + agentMgr.mu.Lock() + promptCalls := len(agentMgr.capturedPromptCalls) + agentMgr.mu.Unlock() + if promptCalls != 0 { + t.Fatalf("prompt dispatch calls = %d, want none before attempt marker persistence", promptCalls) + } + if pending := svc.reservedPromptTurnID("session-marker-failure"); pending != "" { + t.Fatalf("private reservation = %q, want rollback after marker failure", pending) + } + turns, err := repo.ListTurnsBySession(ctx, "session-marker-failure") + if err != nil { + t.Fatalf("list turns after marker failure: %v", err) + } + if len(turns) != 0 { + t.Fatalf("turns after marker failure = %#v, want rolled-back reservation", turns) + } + session, err := repo.GetTaskSession(ctx, "session-marker-failure") + if err != nil { + t.Fatalf("load session after marker failure: %v", err) + } + if session.State != models.TaskSessionStateWaitingForInput { + t.Fatalf("session state after marker failure = %q, want waiting", session.State) + } +} + +func TestResumeDetachedClarificationUsesBoundedDispatchOnlyPrompt(t *testing.T) { + repo := setupTestRepo(t) + seedSession(t, repo, "task-bounded", "session-bounded", "step-1") + seedExecutorRunning(t, repo, "session-bounded", "task-bounded", "exec-bounded") + if err := repo.UpdateTaskSessionState( + context.Background(), + "session-bounded", + models.TaskSessionStateWaitingForInput, + "", + ); err != nil { + t.Fatalf("set session waiting: %v", err) + } + type promptObservation struct { + dispatchOnly bool + hasDeadline bool + } + observed := make(chan promptObservation, 1) + release := make(chan struct{}) + defer close(release) + agentMgr := &mockAgentManager{isAgentRunning: true, repoForExecutionLookup: repo} + agentMgr.promptAgentFunc = func( + ctx context.Context, + _ string, + _ string, + _ []v1.MessageAttachment, + dispatchOnly bool, + ) (*executor.PromptResult, error) { + _, hasDeadline := ctx.Deadline() + observed <- promptObservation{dispatchOnly: dispatchOnly, hasDeadline: hasDeadline} + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-release: + return &executor.PromptResult{}, nil + } + } + svc := createEngineService(t, repo, newMockStepGetter(), agentMgr) + resumeCtx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + done := make(chan error, 1) + go func() { + done <- svc.ResumeDetachedClarification(resumeCtx, clarification.DetachedClarificationResume{ + TaskID: "task-bounded", + SessionID: "session-bounded", + PendingID: "pending-bounded", + Question: "Continue?", + AnswerText: "Continue", + }) + }() + + var observation promptObservation + select { + case observation = <-observed: + case err := <-done: + t.Fatalf("resume returned before prompt dispatch: %v", err) + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for prompt dispatch") + } + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("resume error = %v, want context cancellation", err) + } + case <-time.After(250 * time.Millisecond): + t.Fatal("detached resume ignored cancellation before prompt acknowledgment") + } + if !observation.dispatchOnly || !observation.hasDeadline { + t.Fatalf("prompt observation = %+v, want dispatch-only with deadline", observation) + } +} + +func TestResumeDetachedClarificationDoesNotTreatAsyncRecoveryAsAcknowledgment(t *testing.T) { + repo := setupTestRepo(t) + promptEntered := make(chan struct{}, 1) + promptRelease := make(chan struct{}) + t.Cleanup(func() { close(promptRelease) }) + agentMgr := &mockAgentManager{isAgentRunning: true, repoForExecutionLookup: repo} + agentMgr.promptAgentFunc = func( + context.Context, + string, + string, + []v1.MessageAttachment, + bool, + ) (*executor.PromptResult, error) { + promptEntered <- struct{}{} + <-promptRelease + return &executor.PromptResult{}, nil + } + svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr) + svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{}) + seedTaskAndSession(t, repo, "task-busy", "session-busy", models.TaskSessionStateRunning) + seedExecutorRunning(t, repo, "session-busy", "task-busy", "exec-busy") + + err := svc.ResumeDetachedClarification(context.Background(), clarification.DetachedClarificationResume{ + TaskID: "task-busy", + SessionID: "session-busy", + PendingID: "pending-busy", + Question: "Continue?", + AnswerText: "Continue", + }) + if err == nil { + t.Fatal("detached resume reported acknowledgment after only an asynchronous retry handoff") + } + select { + case <-promptEntered: + t.Fatal("bounded detached resume must not hand an unacknowledged retry to the async queue") + default: + } +} + func TestHandleClarificationStaleDismissed(t *testing.T) { ctx := context.Background() @@ -404,6 +918,90 @@ func TestPauseForClarificationInput_SilentlyCancelsTurnWithoutWorkflowTransition } } +func TestPauseForClarificationInput_ContinuesHardPauseAfterDetachFailure(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + seedExecutorRunning(t, repo, "s1", "t1", "exec-1") + seedPendingClarificationMessage(t, repo, "t1", "s1") + + wantErr := errors.New("detach failed") + agentMgr := &mockAgentManager{repoForExecutionLookup: repo} + svc := createEngineService(t, repo, newMockStepGetter(), agentMgr) + svc.SetClarificationCanceller(&failingDetachClarificationCanceller{err: wantErr}) + svc.turnService = &repoBackedTurnService{repo: repo} + + detached, err := svc.PauseForClarificationInput(ctx, "s1") + if detached != 0 || !errors.Is(err, wantErr) { + t.Fatalf("PauseForClarificationInput = %d, %v; want 0 and detach error", detached, err) + } + if got := agentMgr.cancelAgentCalls.Load(); got != 1 { + t.Fatalf("detach failure must still hard-pause the active turn, got %d cancel calls", got) + } + session, err := repo.GetTaskSession(ctx, "s1") + if err != nil { + t.Fatalf("get session: %v", err) + } + if session.State != models.TaskSessionStateWaitingForInput { + t.Fatalf("expected session waiting for input after detach failure, got %q", session.State) + } +} + +func TestPauseForClarificationInput_RetriesHardPauseAfterSuccessfulDetach(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + seedExecutorRunning(t, repo, "s1", "t1", "exec-1") + seedPendingClarificationMessage(t, repo, "t1", "s1") + requireNoError(t, repo.UpdateTaskSessionState( + ctx, + "s1", + models.TaskSessionStateWaitingForInput, + "", + )) + + wantErr := errors.New("cancel temporarily unavailable") + agentMgr := &mockAgentManager{repoForExecutionLookup: repo} + agentMgr.cancelAgentFunc = func(context.Context, string) error { + if agentMgr.cancelAgentCalls.Load() == 1 { + return wantErr + } + return nil + } + store := clarification.NewStore(time.Minute) + store.CreateRequest(&clarification.Request{ + PendingID: "pending-s1", + SessionID: "s1", + }) + svc := createEngineService(t, repo, newMockStepGetter(), agentMgr) + svc.SetClarificationCanceller(clarification.NewCanceller(store, repo, nil, testLogger())) + svc.turnService = &repoBackedTurnService{repo: repo} + + detached, err := svc.PauseForClarificationInput(ctx, "s1") + if detached != 1 || !errors.Is(err, wantErr) { + t.Fatalf("first PauseForClarificationInput = %d, %v; want 1 and cancel error", detached, err) + } + pending, err := repo.FindActiveClarificationMessagesBySessionID(ctx, "s1") + if err != nil || len(pending) != 1 || pending[0].Metadata["agent_disconnected"] != true { + t.Fatalf("detached clarification authority = %#v, %v; want one pending detached row", pending, err) + } + detached, err = svc.PauseForClarificationInput(ctx, "s1") + if err != nil { + t.Fatalf("retry PauseForClarificationInput: %v", err) + } + if detached != 0 { + t.Fatalf("retry detached bundles = %d, want 0 for already-detached row", detached) + } + if got := agentMgr.cancelAgentCalls.Load(); got != 2 { + t.Fatalf("hard-pause cancel calls = %d, want retry after successful detach", got) + } + if turn, turnErr := repo.GetActiveTurnBySessionID(ctx, "s1"); turnErr != nil && !errors.Is(turnErr, sql.ErrNoRows) { + t.Fatalf("get active turn: %v", turnErr) + } else if turn != nil { + t.Fatalf("retry left clarification turn active: %#v", turn) + } +} + func TestPauseForClarificationInput_CancelsWhileSessionAlreadyWaiting(t *testing.T) { ctx := context.Background() repo := setupTestRepo(t) @@ -435,6 +1033,97 @@ func TestPauseForClarificationInput_CancelsWhileSessionAlreadyWaiting(t *testing } } +func TestPauseForClarificationInput_DoesNotCancelSuccessorCreatedDuringDetach(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + seedExecutorRunning(t, repo, "s1", "t1", "exec-1") + seedPendingClarificationMessage(t, repo, "t1", "s1") + + agentMgr := &mockAgentManager{repoForExecutionLookup: repo} + canceller := &blockingClarificationCanceller{ + entered: make(chan struct{}), + release: make(chan struct{}), + } + var releaseOnce sync.Once + releaseDetach := func() { releaseOnce.Do(func() { close(canceller.release) }) } + t.Cleanup(releaseDetach) + svc := createEngineService(t, repo, newMockStepGetter(), agentMgr) + svc.SetClarificationCanceller(canceller) + svc.turnService = &repoBackedTurnService{repo: repo} + + done := make(chan error, 1) + go func() { + _, err := svc.PauseForClarificationInput(ctx, "s1") + done <- err + }() + select { + case <-canceller.entered: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for clarification detach") + } + if _, err := svc.turnService.StartTurn(ctx, "s1"); err != nil { + t.Fatalf("start successor turn: %v", err) + } + releaseDetach() + select { + case err := <-done: + if err != nil { + t.Fatalf("pause after successor turn: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for clarification pause") + } + if got := agentMgr.cancelAgentCalls.Load(); got != 0 { + t.Fatalf("stale clarification pause cancelled successor: %d calls", got) + } +} + +func TestPauseForClarificationInput_DoesNotCancelFirstTurnCreatedDuringDetach(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + seedExecutorRunning(t, repo, "s1", "t1", "exec-1") + + agentMgr := &mockAgentManager{repoForExecutionLookup: repo} + canceller := &blockingClarificationCanceller{ + entered: make(chan struct{}), + release: make(chan struct{}), + } + var releaseOnce sync.Once + releaseDetach := func() { releaseOnce.Do(func() { close(canceller.release) }) } + t.Cleanup(releaseDetach) + svc := createEngineService(t, repo, newMockStepGetter(), agentMgr) + svc.SetClarificationCanceller(canceller) + svc.turnService = &repoBackedTurnService{repo: repo} + + done := make(chan error, 1) + go func() { + _, err := svc.PauseForClarificationInput(ctx, "s1") + done <- err + }() + select { + case <-canceller.entered: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for clarification detach") + } + if _, err := svc.turnService.StartTurn(ctx, "s1"); err != nil { + t.Fatalf("start first turn during detach: %v", err) + } + releaseDetach() + select { + case err := <-done: + if err != nil { + t.Fatalf("pause after first turn: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for clarification pause") + } + if got := agentMgr.cancelAgentCalls.Load(); got != 0 { + t.Fatalf("stale clarification pause cancelled first turn: %d calls", got) + } +} + func TestPauseForClarificationInput_IgnoresStaleTimeoutWithoutPendingClarification(t *testing.T) { ctx := context.Background() repo := setupTestRepo(t) @@ -653,33 +1342,127 @@ func TestHandleAgentStreamEvent_CancelsClarificationWatchdogs(t *testing.T) { } } -func TestClarificationWatchdog_ExpiresAndClearsEntry(t *testing.T) { +func TestClarificationWatchdog_MatchingTurnDispatchesAndClearsEntry(t *testing.T) { repo := setupTestRepo(t) - agentMgr := &mockAgentManager{isAgentRunning: true} + promptDone := make(chan struct{}) + agentMgr := &mockAgentManager{ + isAgentRunning: true, + repoForExecutionLookup: repo, + promptDone: promptDone, + } svc := createTestServiceWithScheduler(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr) svc.clarificationWatchdogTimeout = 20 * time.Millisecond t.Cleanup(func() { svc.cancelAllClarificationWatchdogs() }) - seedTaskAndSession(t, repo, "t1", "s1", models.TaskSessionStateCompleted) + seedTaskAndSession(t, repo, "t1", "s1", models.TaskSessionStateWaitingForInput) + seedExecutorRunning(t, repo, "s1", "t1", "exec-watchdog-positive") + turnService := &repoTurnService{repo: repo} + svc.turnService = turnService + clarificationTurn, err := turnService.StartTurn(context.Background(), "s1") + if err != nil { + t.Fatalf("start clarification turn: %v", err) + } event := bus.NewEvent("clarification.primary_answered", "test", map[string]any{ - "session_id": "s1", - "task_id": "t1", - "pending_id": "p1", - "question": "Which approach?", - "answer_text": "User selected: Option A", + "session_id": "s1", + "task_id": "t1", + "pending_id": "p1", + "clarification_turn_id": clarificationTurn.ID, + "question": "Which approach?", + "answer_text": "User selected: Option A", }) if err := svc.handleClarificationPrimaryAnswered(context.Background(), event); err != nil { t.Fatalf("unexpected error: %v", err) } - time.Sleep(100 * time.Millisecond) + select { + case <-promptDone: + case <-time.After(time.Second): + t.Fatal("matching clarification watchdog did not dispatch a prompt") + } + deadline := time.Now().Add(time.Second) + for countClarificationWatchdogs(svc) != 0 && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } if got := countClarificationWatchdogs(svc); got != 0 { t.Fatalf("expected watchdog map to be empty after timeout, got %d", got) } } +func TestClarificationWatchdogExpirationPreservesSuccessorEntry(t *testing.T) { + svc := &Service{} + key := svc.clarificationWatchdogKey("session-1", "pending-1") + expired := &clarificationWatchdogEntry{} + successor := &clarificationWatchdogEntry{} + svc.clarificationWatchdogs.Store(key, successor) + t.Cleanup(func() { svc.clarificationWatchdogs.Delete(key) }) + + svc.runClarificationWatchdog( + context.Background(), key, expired, clarificationAnsweredData{}, 0, + ) + + current, ok := svc.clarificationWatchdogs.Load(key) + if !ok || current != successor { + t.Fatalf("successor watchdog = %v, %v; want preserved successor %p", current, ok, successor) + } +} + +type blockingClarificationTurnLookup struct { + *repoTurnService + entered chan context.Context + release chan struct{} +} + +func (s *blockingClarificationTurnLookup) GetActiveTurn( + ctx context.Context, + _ string, +) (*models.Turn, error) { + s.entered <- ctx + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-s.release: + return nil, nil + } +} + +func TestClarificationWatchdogCancellationInterruptsFallbackLookup(t *testing.T) { + repo := setupTestRepo(t) + svc := createTestServiceWithScheduler( + repo, + newMockStepGetter(), + newMockTaskRepo(), + &mockAgentManager{isAgentRunning: true}, + ) + lookup := &blockingClarificationTurnLookup{ + repoTurnService: &repoTurnService{repo: repo}, + entered: make(chan context.Context, 1), + release: make(chan struct{}), + } + svc.turnService = lookup + svc.clarificationWatchdogTimeout = time.Millisecond + defer close(lookup.release) + + svc.scheduleClarificationWatchdog(clarificationAnsweredData{ + TaskID: "task-watchdog-shutdown", SessionID: "session-watchdog-shutdown", + PendingID: "pending-watchdog-shutdown", ClarificationTurnID: "turn-watchdog-shutdown", + }) + + var lookupCtx context.Context + select { + case lookupCtx = <-lookup.entered: + case <-time.After(time.Second): + t.Fatal("watchdog fallback did not enter turn lookup") + } + svc.cancelAllClarificationWatchdogs() + select { + case <-lookupCtx.Done(): + case <-time.After(100 * time.Millisecond): + t.Fatal("watchdog cancellation did not interrupt fallback turn lookup") + } +} + // TestRetryClarificationAfterCancel_DoesNotStarveUserCancel is the regression // test for the production hang where a clarification-timeout recovery left a // session permanently unstoppable. retryClarificationAfterCancel used to send @@ -718,6 +1501,12 @@ func TestRetryClarificationAfterCancel_DoesNotStarveUserCancel(t *testing.T) { seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateRunning) seedExecutorRunning(t, repo, "session1", "task1", "exec-1") + turnService := &repoTurnService{repo: repo} + svc.turnService = turnService + clarificationTurn, err := turnService.StartTurn(ctx, "session1") + if err != nil { + t.Fatalf("start clarification turn: %v", err) + } // Kick off the clarification-timeout recovery. Its silent cancel succeeds // (mock CancelAgent returns nil), then it hands the retry prompt to the @@ -726,7 +1515,7 @@ func TestRetryClarificationAfterCancel_DoesNotStarveUserCancel(t *testing.T) { recoveryDone := make(chan struct{}) go func() { svc.retryClarificationAfterCancel(ctx, clarificationAnsweredData{ - TaskID: "task1", SessionID: "session1", + TaskID: "task1", SessionID: "session1", ClarificationTurnID: clarificationTurn.ID, }, "the clarification answer", fmt.Errorf("wrap: %w", ErrAgentPromptInProgress)) close(recoveryDone) }() diff --git a/apps/backend/internal/orchestrator/event_handlers_streaming.go b/apps/backend/internal/orchestrator/event_handlers_streaming.go index a3fa035b33..fd9be9e84e 100644 --- a/apps/backend/internal/orchestrator/event_handlers_streaming.go +++ b/apps/backend/internal/orchestrator/event_handlers_streaming.go @@ -1009,6 +1009,14 @@ func (s *Service) updateTaskSessionStateWithHook( if onChanged != nil { onChanged() } + if isTerminalSessionState(nextState) { + if err := s.expireTerminalClarificationWaiters(ctx, sessionID); err != nil { + s.logger.Error("failed to expire clarification on terminal session; response claims remain quarantined", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.Error(err)) + } + } // Work has resumed: a session entering STARTING/RUNNING clears the // startup interruption marker and republishes the task so open clients // drop the red interruption icon. No-op when the marker is absent. @@ -1150,6 +1158,14 @@ func (s *Service) transitionTaskSessionState( if onChanged != nil { onChanged() } + if isTerminalSessionState(nextState) { + if err := s.expireTerminalClarificationWaiters(ctx, sessionID); err != nil { + s.logger.Error("failed to expire clarification on strict terminal transition; response claims remain quarantined", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.Error(err)) + } + } s.publishTaskSessionStateChanged( ctx, taskID, @@ -2327,13 +2343,41 @@ func (s *Service) detachClarificationWaiters(ctx context.Context, sessionID stri if s.clarificationCanceller == nil || sessionID == "" { return } - if n := s.clarificationCanceller.DetachSessionAndNotify(ctx, sessionID); n > 0 { + n, err := s.clarificationCanceller.DetachSessionAndNotify(ctx, sessionID) + if err != nil { + s.logger.Warn("failed to detach pending clarifications on turn complete", + zap.String("session_id", sessionID), + zap.Error(err)) + return + } + if n > 0 { s.logger.Info("detached pending clarifications on turn complete", zap.String("session_id", sessionID), zap.Int("count", n)) } } +func (s *Service) expireClarificationWaiters(ctx context.Context, sessionID string) error { + if s.clarificationCanceller == nil || sessionID == "" { + return nil + } + n, err := s.clarificationCanceller.ExpireSessionAndNotify(ctx, sessionID) + if n > 0 { + s.logger.Info("expired pending clarifications on terminal session", + zap.String("session_id", sessionID), + zap.Int("count", n)) + } + return err +} + +const terminalClarificationExpiryTimeout = 10 * time.Second + +func (s *Service) expireTerminalClarificationWaiters(ctx context.Context, sessionID string) error { + expireCtx, cancel := context.WithTimeout(ctx, terminalClarificationExpiryTimeout) + defer cancel() + return s.expireClarificationWaiters(expireCtx, sessionID) +} + // sessionStateString renders a session's state for logging, returning "" when // the session is nil (e.g. a complete event with no session ID). Kept tiny so // state-transition trace logs don't add branching to their hot-path callers. @@ -2633,22 +2677,19 @@ func (s *Service) persistPromptMetadataOnTurn( turn *models.Turn, ) { model, agentType := resolvePromptUsageLabels(payload, session) - metadata := turn.Metadata - if metadata == nil { - metadata = make(map[string]interface{}) + updates := map[string]interface{}{ + "prompt_usage": promptUsageMetadata(payload.Data.Usage), } - metadata["prompt_usage"] = promptUsageMetadata(payload.Data.Usage) if model != "" { - metadata[sessionModelConfigKey] = model + updates[sessionModelConfigKey] = model } if agentType != "" { - metadata["agent_type"] = agentType + updates["agent_type"] = agentType } if payload.AgentID != "" { - metadata["agent_id"] = payload.AgentID + updates["agent_id"] = payload.AgentID } - turn.Metadata = metadata - if err := s.turnService.UpdateTurn(ctx, turn); err != nil { + if err := s.turnService.PatchTurnMetadata(ctx, turn.TaskSessionID, turn.ID, updates); err != nil { s.logger.Warn("failed to persist prompt usage metadata on turn", zap.String("turn_id", turn.ID), zap.String("session_id", payload.SessionID), diff --git a/apps/backend/internal/orchestrator/event_handlers_streaming_test.go b/apps/backend/internal/orchestrator/event_handlers_streaming_test.go index d8d0e2ebfe..0e35f03435 100644 --- a/apps/backend/internal/orchestrator/event_handlers_streaming_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_streaming_test.go @@ -112,12 +112,22 @@ func (r *taskServiceStateRepository) UpdateTaskStateIfSessionState( } type recordingClarificationCanceller struct { - sessions []string + sessions []string + expiredSessions []string + expireContextDeadline []bool + expireErr error } -func (c *recordingClarificationCanceller) DetachSessionAndNotify(_ context.Context, sessionID string) int { +func (c *recordingClarificationCanceller) DetachSessionAndNotify(_ context.Context, sessionID string) (int, error) { c.sessions = append(c.sessions, sessionID) - return 1 + return 1, nil +} + +func (c *recordingClarificationCanceller) ExpireSessionAndNotify(ctx context.Context, sessionID string) (int, error) { + c.expiredSessions = append(c.expiredSessions, sessionID) + _, hasDeadline := ctx.Deadline() + c.expireContextDeadline = append(c.expireContextDeadline, hasDeadline) + return 1, c.expireErr } type listTaskSessionsErrorRepo struct { @@ -349,6 +359,39 @@ func TestUpdateTaskSessionStatePublishesPersistedUpdatedAt(t *testing.T) { require.Equal(t, session.UpdatedAt.UTC().Format(time.RFC3339Nano), data["updated_at"]) } +func TestUpdateTaskSessionStateExpiresClarificationsOnTerminalTransition(t *testing.T) { + for _, terminalState := range []models.TaskSessionState{ + models.TaskSessionStateCompleted, + models.TaskSessionStateFailed, + models.TaskSessionStateCancelled, + } { + t.Run(string(terminalState), func(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + canceller := &recordingClarificationCanceller{} + svc.clarificationCanceller = canceller + + svc.updateTaskSessionState(ctx, "t1", "s1", terminalState, "", false) + + require.Equal(t, []string{"s1"}, canceller.expiredSessions) + require.Equal(t, []bool{true}, canceller.expireContextDeadline) + }) + } +} + +func TestExpireClarificationWaitersReturnsPersistenceError(t *testing.T) { + repo := setupTestRepo(t) + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + wantErr := errors.New("expiry write failed") + svc.clarificationCanceller = &recordingClarificationCanceller{expireErr: wantErr} + + err := svc.expireClarificationWaiters(context.Background(), "s1") + + require.ErrorIs(t, err, wantErr) +} + func TestUpdateTaskSessionState_EnabledClaudePublishesSettledBackground(t *testing.T) { ctx := context.Background() repo := setupTestRepo(t) @@ -385,6 +428,8 @@ func TestTransitionTaskSessionStateReportsAcceptedWrite(t *testing.T) { eb := &recordingEventBus{} svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) svc.eventBus = eb + canceller := &recordingClarificationCanceller{} + svc.clarificationCanceller = canceller changed, finalState, err := svc.transitionTaskSessionState( ctx, @@ -400,6 +445,8 @@ func TestTransitionTaskSessionStateReportsAcceptedWrite(t *testing.T) { require.Equal(t, models.TaskSessionStateCancelled, finalState) require.Len(t, eb.events, 1) require.Equal(t, events.TaskSessionStateChanged, eb.events[0].subject) + require.Equal(t, []string{"s1"}, canceller.expiredSessions) + require.Equal(t, []bool{true}, canceller.expireContextDeadline) } func TestTransitionTaskSessionStateReportsPersistenceFailure(t *testing.T) { diff --git a/apps/backend/internal/orchestrator/event_handlers_test.go b/apps/backend/internal/orchestrator/event_handlers_test.go index 7994ca66b9..8fee7ef1f7 100644 --- a/apps/backend/internal/orchestrator/event_handlers_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_test.go @@ -271,6 +271,7 @@ type mockAgentManager struct { restartProcessErr error promptErr error promptResult *executor.PromptResult + promptAcceptedOnError bool promptAgentFunc func(context.Context, string, string, []v1.MessageAttachment, bool) (*executor.PromptResult, error) launchAgentFunc func(context.Context, *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) startAgentProcessCalls []string @@ -459,7 +460,7 @@ func (m *mockAgentManager) PromptAgent(ctx context.Context, executionID string, func (m *mockAgentManager) PromptAgentWithDispatchCallback(ctx context.Context, executionID string, prompt string, attachments []v1.MessageAttachment, dispatchOnly bool, onDispatched func()) (*executor.PromptResult, error) { result, err := m.PromptAgent(ctx, executionID, prompt, attachments, dispatchOnly) - if err == nil && onDispatched != nil { + if (err == nil || m.promptAcceptedOnError) && onDispatched != nil { onDispatched() } return result, err diff --git a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go index f1d5ff0414..bb8fdd7eec 100644 --- a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go @@ -6,6 +6,7 @@ import ( "fmt" "sync" "testing" + "time" "github.com/kandev/kandev/internal/agent/agents" agentdto "github.com/kandev/kandev/internal/agent/dto" @@ -800,7 +801,15 @@ func (m *mockRepository) GetActiveTurnBySessionID(ctx context.Context, sessionID return nil, nil } func (m *mockRepository) UpdateTurn(ctx context.Context, turn *models.Turn) error { return nil } -func (m *mockRepository) CompleteTurn(ctx context.Context, id string) error { return nil } +func (m *mockRepository) PatchTurnMetadata( + context.Context, + string, + string, + map[string]interface{}, +) (bool, time.Time, error) { + return false, time.Time{}, nil +} +func (m *mockRepository) CompleteTurn(ctx context.Context, id string) error { return nil } func (m *mockRepository) CompletePendingToolCallsForTurn(ctx context.Context, turnID string) (int64, error) { return 0, nil } diff --git a/apps/backend/internal/orchestrator/foreground_claim_test.go b/apps/backend/internal/orchestrator/foreground_claim_test.go index af2f6ad023..20024842b7 100644 --- a/apps/backend/internal/orchestrator/foreground_claim_test.go +++ b/apps/backend/internal/orchestrator/foreground_claim_test.go @@ -207,8 +207,8 @@ func TestPromptTask_SupersededQueuedDispatchReleasesForegroundClaim(t *testing.T svc.markForegroundIdle(sessionID) _, err := svc.promptTask( - context.Background(), taskID, sessionID, "queued prompt", "", false, nil, false, "stale-entry", - false, nil, + context.Background(), taskID, sessionID, "queued prompt", "", false, nil, false, + promptTaskOptions{claimEntryID: "stale-entry"}, ) if !errors.Is(err, ErrAgentPromptInProgress) { t.Fatalf("a queued dispatch into a RUNNING session must be rejected, got: %v", err) diff --git a/apps/backend/internal/orchestrator/prompt_claim_rollback_context_test.go b/apps/backend/internal/orchestrator/prompt_claim_rollback_context_test.go new file mode 100644 index 0000000000..e2241b46b4 --- /dev/null +++ b/apps/backend/internal/orchestrator/prompt_claim_rollback_context_test.go @@ -0,0 +1,167 @@ +package orchestrator + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/clarification" + "github.com/kandev/kandev/internal/task/models" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +type cancelingReservedTurnAttemptMarker struct { + TurnService + cancel context.CancelFunc + err error +} + +func (s cancelingReservedTurnAttemptMarker) MarkReservedTurnDispatchAttempted( + context.Context, + *models.Turn, +) error { + s.cancel() + return s.err +} + +type cancelingStartTurnService struct { + TurnService + cancel context.CancelFunc + err error +} + +func (s cancelingStartTurnService) StartTurn(context.Context, string) (*models.Turn, error) { + s.cancel() + return nil, s.err +} + +func TestDetachedClarificationRollsBackClaimAfterMarkerCallCancelsContext(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + const taskID = "task-marker-cancel" + const sessionID = "session-marker-cancel" + seedSession(t, repo, taskID, sessionID, "step-1") + seedExecutorRunning(t, repo, sessionID, taskID, "exec-marker-cancel") + if err := repo.UpdateTaskSessionState(ctx, sessionID, models.TaskSessionStateWaitingForInput, ""); err != nil { + t.Fatalf("set session waiting: %v", err) + } + + requestCtx, cancel := context.WithCancel(ctx) + persistenceErr := errors.New("persist dispatch attempt marker") + agentMgr := &mockAgentManager{isAgentRunning: true, repoForExecutionLookup: repo} + svc := createEngineService(t, repo, newMockStepGetter(), agentMgr) + svc.turnService = cancelingReservedTurnAttemptMarker{ + TurnService: &repoBackedTurnService{repo: repo}, + cancel: cancel, + err: persistenceErr, + } + + err := svc.ResumeDetachedClarification(requestCtx, clarification.DetachedClarificationResume{ + TaskID: taskID, SessionID: sessionID, PendingID: "pending-marker-cancel", + Question: "Continue?", AnswerText: "Continue", + }) + if !errors.Is(err, persistenceErr) { + t.Fatalf("resume error = %v, want %v", err, persistenceErr) + } + if pending := svc.reservedPromptTurnID(sessionID); pending != "" { + t.Fatalf("private reservation = %q, want rollback after marker failure", pending) + } + turns, err := repo.ListTurnsBySession(ctx, sessionID) + if err != nil { + t.Fatalf("list turns after marker failure: %v", err) + } + if len(turns) != 0 { + t.Fatalf("turns after marker failure = %#v, want rolled-back reservation", turns) + } + session, err := repo.GetTaskSession(ctx, sessionID) + if err != nil { + t.Fatalf("load session after marker failure: %v", err) + } + if session.State != models.TaskSessionStateWaitingForInput { + t.Fatalf("session state after marker failure = %q, want waiting", session.State) + } +} + +func TestLifecyclePromptRollsBackClaimAfterTurnCallCancelsContext(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + const taskID = "task-turn-cancel" + const sessionID = "session-turn-cancel" + seedSession(t, repo, taskID, sessionID, "step-1") + if err := repo.UpdateTaskSessionState(ctx, sessionID, models.TaskSessionStateWaitingForInput, ""); err != nil { + t.Fatalf("set session waiting: %v", err) + } + + requestCtx, cancel := context.WithCancel(ctx) + persistenceErr := errors.New("persist lifecycle turn") + taskRepo := newMockTaskRepo() + seedMockTaskState(taskRepo, taskID, v1.TaskStateReview) + svc := createTestService(repo, newMockStepGetter(), taskRepo) + svc.turnService = cancelingStartTurnService{ + TurnService: &repoBackedTurnService{repo: repo}, + cancel: cancel, + err: persistenceErr, + } + + _, _, err := svc.claimLifecyclePromptDispatch(requestCtx, taskID, sessionID, "", nil) + if !errors.Is(err, persistenceErr) { + t.Fatalf("claim error = %v, want %v", err, persistenceErr) + } + session, err := repo.GetTaskSession(ctx, sessionID) + if err != nil { + t.Fatalf("load session after turn failure: %v", err) + } + if session.State != models.TaskSessionStateWaitingForInput { + t.Fatalf("session state after turn failure = %q, want waiting", session.State) + } + task, err := taskRepo.GetTask(ctx, taskID) + if err != nil { + t.Fatalf("load task after turn failure: %v", err) + } + if task.State != v1.TaskStateReview { + t.Fatalf("task state after turn failure = %q, want review", task.State) + } +} + +func TestOrdinaryPromptRollsBackClaimAfterTurnCallCancelsContext(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + const taskID = "task-ordinary-turn-cancel" + const sessionID = "session-ordinary-turn-cancel" + seedSession(t, repo, taskID, sessionID, "step-1") + if err := repo.UpdateTaskSessionState(ctx, sessionID, models.TaskSessionStateWaitingForInput, ""); err != nil { + t.Fatalf("set session waiting: %v", err) + } + + requestCtx, cancel := context.WithCancel(ctx) + persistenceErr := errors.New("persist ordinary turn") + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + svc.turnService = cancelingStartTurnService{ + TurnService: &repoBackedTurnService{repo: repo}, + cancel: cancel, + err: persistenceErr, + } + + _, _, err := svc.claimPromptDispatch( + requestCtx, + taskID, + sessionID, + "", + false, + false, + nil, + nil, + nil, + "", + ) + if !errors.Is(err, persistenceErr) { + t.Fatalf("claim error = %v, want %v", err, persistenceErr) + } + session, err := repo.GetTaskSession(ctx, sessionID) + if err != nil { + t.Fatalf("load session after turn failure: %v", err) + } + if session.State != models.TaskSessionStateWaitingForInput { + t.Fatalf("session state after turn failure = %q, want waiting", session.State) + } +} diff --git a/apps/backend/internal/orchestrator/queue_send_now.go b/apps/backend/internal/orchestrator/queue_send_now.go index e5c42f6cb4..fdadd36ec9 100644 --- a/apps/backend/internal/orchestrator/queue_send_now.go +++ b/apps/backend/internal/orchestrator/queue_send_now.go @@ -475,7 +475,7 @@ func (s *Service) promptSendNowClaim(ctx context.Context, claim *messagequeue.Se } _, err := s.promptTask(ctx, claim.Dispatch.TaskID, sessionID, promptContent, claim.Dispatch.Model, - claim.Dispatch.PlanMode, attachments, false, claim.Dispatch.ID, false, nil) + claim.Dispatch.PlanMode, attachments, false, promptTaskOptions{claimEntryID: claim.Dispatch.ID}) return err } diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index 7849a13c3c..69023f9b43 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -126,10 +126,20 @@ type SubagentContextRecorder interface { // TurnService is an interface for managing session turns type TurnService interface { StartTurn(ctx context.Context, sessionID string) (*models.Turn, error) + ReserveTurn( + ctx context.Context, + sessionID string, + recovery *models.PromptDispatchRecovery, + ) (*models.Turn, error) + MarkReservedTurnDispatchAttempted(ctx context.Context, turn *models.Turn) error + PublishReservedTurn(ctx context.Context, turn *models.Turn) error + RollbackReservedTurn(ctx context.Context, sessionID, turnID string) (bool, error) + ReconcileUnpublishedPromptTurns(ctx context.Context) (int, error) CompleteTurn(ctx context.Context, turnID string) error GetTurn(ctx context.Context, turnID string) (*models.Turn, error) GetActiveTurn(ctx context.Context, sessionID string) (*models.Turn, error) UpdateTurn(ctx context.Context, turn *models.Turn) error + PatchTurnMetadata(ctx context.Context, sessionID, turnID string, updates map[string]interface{}) error // AbandonOpenTurns buries any open turns for a session by setting // completed_at = started_at (zero duration), so a subsequent prompt starts // a fresh turn instead of adopting one that was orphaned by a crash or @@ -311,7 +321,7 @@ type sessionExecutorStore interface { // prior launch failed before recordInitialMessage ran. ListMessages(ctx context.Context, sessionID string) ([]*models.Message, error) // Pending clarification rows — durable guard for on_turn_complete while the user is answering. - FindPendingClarificationMessagesBySessionID(ctx context.Context, sessionID string) ([]*models.Message, error) + FindActiveClarificationMessagesBySessionID(ctx context.Context, sessionID string) ([]*models.Message, error) // Workspace GetWorkspace(ctx context.Context, id string) (*models.Workspace, error) // Task environment @@ -349,6 +359,108 @@ func (s *Service) ClaimTaskTitleSession(ctx context.Context, taskID, sessionID s return owned, nil } +type reservedPromptTurn struct { + id string + done chan struct{} + mu sync.Mutex + resolved bool + accepted bool + afterResolve []reservedPromptCallback +} + +type reservedPromptCallback struct { + owner *reservedPromptCallbackOwner + run func(context.Context) +} + +func newReservedPromptTurn(id string) *reservedPromptTurn { + return &reservedPromptTurn{id: id, done: make(chan struct{})} +} + +func (r *reservedPromptTurn) resolve(accepted bool) []reservedPromptCallback { + r.mu.Lock() + defer r.mu.Unlock() + if r.resolved { + return nil + } + r.resolved = true + r.accepted = accepted + close(r.done) + callbacks := r.afterResolve + r.afterResolve = nil + return callbacks +} + +func (r *reservedPromptTurn) wait(ctx context.Context) (bool, error) { + select { + case <-r.done: + r.mu.Lock() + accepted := r.accepted + r.mu.Unlock() + return accepted, nil + case <-ctx.Done(): + return false, ctx.Err() + } +} + +func (r *reservedPromptTurn) deferUntilResolved(callback reservedPromptCallback) bool { + r.mu.Lock() + defer r.mu.Unlock() + if r.resolved { + return false + } + r.afterResolve = append(r.afterResolve, callback) + return true +} + +// reservedPromptCallbackOwner gives deferred reservation reconciliation one +// lifecycle owner. Stop prevents new launches, cancels the shared context, and +// waits until every callback has observed cancellation and returned. +type reservedPromptCallbackOwner struct { + mu sync.Mutex + ctx context.Context + cancel context.CancelFunc + stopped bool + workers sync.WaitGroup +} + +func newReservedPromptCallbackOwner() *reservedPromptCallbackOwner { + ctx, cancel := context.WithCancel(context.Background()) + return &reservedPromptCallbackOwner{ctx: ctx, cancel: cancel} +} + +func (o *reservedPromptCallbackOwner) launch(callback func(context.Context)) bool { + if o == nil || callback == nil { + return false + } + o.mu.Lock() + if o.stopped { + o.mu.Unlock() + return false + } + ctx := o.ctx + o.workers.Add(1) + o.mu.Unlock() + go func() { + defer o.workers.Done() + callback(ctx) + }() + return true +} + +func (o *reservedPromptCallbackOwner) stop() { + if o == nil { + return + } + o.mu.Lock() + if !o.stopped { + o.stopped = true + o.cancel() + } + o.mu.Unlock() + o.workers.Wait() +} + // Service is the main orchestrator service type Service struct { config ServiceConfig @@ -595,6 +707,16 @@ type Service struct { // Active turns map: sessionID -> turnID activeTurns sync.Map + // reservedPromptTurns keeps durably created turns private until agentctl + // accepts their prompt. Each reservation broadcasts its accepted-or-rolled- + // back result so a concurrent ready event can wait, then revalidate prompt + // generation before touching turn or workflow state. + reservedPromptTurns sync.Map + // reservedPromptCallbacks owns the reconciliation callbacks released when + // a prompt reservation settles. The owner is replaced on Start and drained + // before scheduler/watcher teardown on Stop. + reservedPromptCallbacksMu sync.Mutex + reservedPromptCallbacks *reservedPromptCallbackOwner // dispatchingQueued tracks the pre-acceptance reservation for the exact // queued message handed to an async worker. acceptedQueuedDispatch keeps @@ -609,6 +731,9 @@ type Service struct { // narrow interval after handleAgentReady releases its per-session guard // and before it starts a deferred durable-lifecycle dispatch. afterReadyLifecycleReservation func() + // agentReadyReservationWaitTimeout is a deterministic test seam. Zero uses + // the production timeout derived from the prompt dispatch budgets. + agentReadyReservationWaitTimeout time.Duration // foregroundActivity tracks, per session, whether the open turn is actively // generating in the foreground or only waiting on a spawned background task @@ -861,6 +986,7 @@ func NewService( messageQueue: msgQueue, clarificationWatchdogTimeout: 15 * time.Second, gitSnapshotCache: newGitSnapshotCache(), + reservedPromptCallbacks: newReservedPromptCallbackOwner(), sendNowCtx: sendNowCtx, sendNowCancel: sendNowCancel, } @@ -1053,8 +1179,9 @@ func (s *Service) SetTaskGitCredentialPolicyResolver(resolver executor.TaskGitCr // // When to call: During orchestrator initialization, after creating the task service. // -// If not set: Turns won't be tracked (orchestrator continues functioning normally, but -// no timing data is recorded and turn IDs in messages will be empty). +// This dependency must be set before Start. Startup reconciliation fails closed +// without durable turn access because it cannot safely classify unpublished +// prompt reservations. func (s *Service) SetTurnService(turnService TurnService) { s.turnService = turnService } @@ -1312,13 +1439,14 @@ func (s *Service) SetPromptReferenceExpander(e PromptReferenceExpander) { s.promptExpander = e } -// ClarificationCanceller detaches in-memory clarification waiters when an agent's -// turn completes while questions are still pending in the DB. +// ClarificationCanceller updates pending clarification ownership when an agent +// turn detaches or its session becomes terminal. type ClarificationCanceller interface { - DetachSessionAndNotify(ctx context.Context, sessionID string) int + DetachSessionAndNotify(ctx context.Context, sessionID string) (int, error) + ExpireSessionAndNotify(ctx context.Context, sessionID string) (int, error) } -// SetClarificationCanceller sets the canceller for cleaning up pending clarifications on turn complete. +// SetClarificationCanceller sets the canceller for turn and terminal-session cleanup. func (s *Service) SetClarificationCanceller(c ClarificationCanceller) { s.clarificationCanceller = c } @@ -1435,42 +1563,135 @@ func (s *Service) startTurnForSession(ctx context.Context, sessionID string) str // active turn. Callers that must compensate a failed dispatch may only close a // turn they created; an adopted turn belongs to an earlier dispatch. func (s *Service) startTurnForSessionWithOwnership(ctx context.Context, sessionID string) (string, bool) { - if s.turnService == nil { - return "", false - } - - if turnIDVal, ok := s.activeTurns.Load(sessionID); ok { - if turnID, ok := turnIDVal.(string); ok && turnID != "" { - s.bindAcceptedDispatchTurn(sessionID, turnID) - return turnID, false - } - } - - if turn, err := s.turnService.GetActiveTurn(ctx, sessionID); turn != nil { - s.activeTurns.Store(sessionID, turn.ID) - s.bindAcceptedDispatchTurn(sessionID, turn.ID) - return turn.ID, false - } else if err != nil { - // A real DB read failure here would otherwise be silently dropped, and - // we'd fall through to StartTurn — potentially writing a duplicate next - // to an existing open turn we couldn't see. Log it; the next sweep via - // completeTurnForSession will mop up any duplicate. - s.logger.Warn("failed to look up active turn before starting a new one", - zap.String("session_id", sessionID), - zap.Error(err)) - } - - turn, err := s.turnService.StartTurn(ctx, sessionID) + turnID, created, _, err := s.startTurnForSessionWithOwnershipChecked(ctx, sessionID, false, nil) if err != nil { s.logger.Warn("failed to start turn", zap.String("session_id", sessionID), zap.Error(err)) return "", false } + return turnID, created +} +// startTurnForSessionWithOwnershipChecked makes persistence failure part of +// prompt admission. A caller may reserve a newly created turn so turn.started +// is published only after the external dispatch is acknowledged. +func (s *Service) startTurnForSessionWithOwnershipChecked( + ctx context.Context, + sessionID string, + reserve bool, + recovery *models.PromptDispatchRecovery, +) (string, bool, *models.Turn, error) { + if s.turnService == nil { + return "", false, nil, nil + } + if reservedID := s.reservedPromptTurnID(sessionID); reservedID != "" { + return "", false, nil, fmt.Errorf( + "%w: session %s has unpublished reserved turn %s", + ErrAgentPromptInProgress, + sessionID, + reservedID, + ) + } + + if turnIDVal, ok := s.activeTurns.Load(sessionID); ok { + if turnID, ok := turnIDVal.(string); ok && turnID != "" { + s.bindAcceptedDispatchTurn(sessionID, turnID) + return turnID, false, nil, nil + } + } + + turn, err := s.turnService.GetActiveTurn(ctx, sessionID) + if err != nil { + return "", false, nil, fmt.Errorf("look up active turn: %w", err) + } + if turn != nil { + s.activeTurns.Store(sessionID, turn.ID) + s.bindAcceptedDispatchTurn(sessionID, turn.ID) + return turn.ID, false, nil, nil + } + + if reserve { + turn, err = s.turnService.ReserveTurn(ctx, sessionID, recovery) + } else { + turn, err = s.turnService.StartTurn(ctx, sessionID) + } + if err != nil { + return "", false, nil, fmt.Errorf("persist turn: %w", err) + } + + if reserve { + s.reservedPromptTurns.Store(sessionID, newReservedPromptTurn(turn.ID)) + return turn.ID, true, turn, nil + } s.activeTurns.Store(sessionID, turn.ID) s.bindAcceptedDispatchTurn(sessionID, turn.ID) - return turn.ID, true + return turn.ID, true, nil, nil +} + +func (s *Service) reservedPromptTurnID(sessionID string) string { + reservation := s.reservedPromptTurn(sessionID) + if reservation == nil { + return "" + } + return reservation.id +} + +func (s *Service) reservedPromptTurn(sessionID string) *reservedPromptTurn { + value, ok := s.reservedPromptTurns.Load(sessionID) + if !ok { + return nil + } + reservation, _ := value.(*reservedPromptTurn) + return reservation +} + +func (s *Service) resolveReservedPromptTurn(sessionID, turnID string, accepted bool) bool { + reservation := s.reservedPromptTurn(sessionID) + if reservation == nil || reservation.id != turnID { + return false + } + s.reservedPromptTurns.CompareAndDelete(sessionID, reservation) + callbacks := reservation.resolve(accepted) + for _, callback := range callbacks { + if callback.owner != nil { + callback.owner.launch(callback.run) + } + } + return true +} + +func (s *Service) deferReservedPromptCallback( + reservation *reservedPromptTurn, + callback func(context.Context), +) bool { + if reservation == nil || callback == nil { + return false + } + s.reservedPromptCallbacksMu.Lock() + owner := s.reservedPromptCallbacks + if owner == nil { + owner = newReservedPromptCallbackOwner() + s.reservedPromptCallbacks = owner + } + s.reservedPromptCallbacksMu.Unlock() + return reservation.deferUntilResolved(reservedPromptCallback{owner: owner, run: callback}) +} + +func (s *Service) resetReservedPromptCallbacks() { + next := newReservedPromptCallbackOwner() + s.reservedPromptCallbacksMu.Lock() + previous := s.reservedPromptCallbacks + s.reservedPromptCallbacks = next + s.reservedPromptCallbacksMu.Unlock() + previous.stop() +} + +func (s *Service) stopReservedPromptCallbacks() { + s.reservedPromptCallbacksMu.Lock() + owner := s.reservedPromptCallbacks + s.reservedPromptCallbacksMu.Unlock() + owner.stop() } // completeTurnForSession closes any open turn for the session. @@ -1727,6 +1948,9 @@ func (s *Service) getActiveTurnID(sessionID string) string { return turnID } } + if turnID := s.reservedPromptTurnID(sessionID); turnID != "" { + return turnID + } // No active turn exists - start one lazily // This handles edge cases like resumed sessions or race conditions return s.startTurnForSession(context.Background(), sessionID) @@ -1802,12 +2026,28 @@ func (s *Service) Start(ctx context.Context) error { s.mu.Unlock() s.logger.Info("starting orchestrator service") + s.resetReservedPromptCallbacks() s.resetSendNowWorkers() // Reconcile session state from persisted runtime state on startup. // This does NOT launch any agent processes — sessions are recovered lazily // when the user opens them (via task.session.status → task.session.resume). - s.reconcileSessionsOnStartup(ctx) + if s.turnService == nil { + err := errors.New("reconcile unpublished prompt turns on startup: turn service is unavailable") + s.logger.Error("failed to reconcile unpublished prompt turns on startup", zap.Error(err)) + s.mu.Lock() + s.running = false + s.mu.Unlock() + return err + } + if err := s.reconcileUnpublishedPromptTurnsOnStartup(ctx); err != nil { + s.logger.Error("failed to reconcile unpublished prompt turns on startup", zap.Error(err)) + s.mu.Lock() + s.running = false + s.mu.Unlock() + return err + } + s.reconcileExecutorSessionsOnStartup(ctx) if s.workflowStore != nil { s.workflowStore.ReconcileQueuedTasks(ctx) } @@ -1889,6 +2129,7 @@ func (s *Service) Stop() error { // Stop components in reverse order var errs []error + s.stopReservedPromptCallbacks() if err := s.scheduler.Stop(); err != nil { s.logger.Error("failed to stop scheduler", zap.Error(err)) @@ -1925,8 +2166,29 @@ func (s *Service) Stop() error { // clear stale execution IDs, fix task state, preserve ExecutorRunning record // 4. Pre-poll remote executor status for remote runtimes (sprites, remote_docker) // -// Called by: Start() method during orchestrator initialization. +// Tests and explicit reconciliation callers use this combined entrypoint; +// Start calls both phases directly so prompt/clarification recovery errors are fatal. func (s *Service) reconcileSessionsOnStartup(ctx context.Context) { + if err := s.reconcileUnpublishedPromptTurnsOnStartup(ctx); err != nil { + s.logger.Error("failed to reconcile unpublished prompt turns on startup", zap.Error(err)) + return + } + s.reconcileExecutorSessionsOnStartup(ctx) +} + +func (s *Service) reconcileUnpublishedPromptTurnsOnStartup(ctx context.Context) error { + if s.turnService != nil { + reconciled, reconcileErr := s.turnService.ReconcileUnpublishedPromptTurns(ctx) + if reconcileErr != nil { + return fmt.Errorf("reconcile unpublished prompt turns on startup: %w", reconcileErr) + } else if reconciled > 0 { + s.logger.Info("reconciled unpublished prompt turns on startup", zap.Int("count", reconciled)) + } + } + return nil +} + +func (s *Service) reconcileExecutorSessionsOnStartup(ctx context.Context) { runningExecutors, err := s.repo.ListExecutorsRunning(ctx) if err != nil { s.logger.Warn("failed to list executors running on startup", zap.Error(err)) diff --git a/apps/backend/internal/orchestrator/session_scope_matrix_test.go b/apps/backend/internal/orchestrator/session_scope_matrix_test.go index 0c7f2de1a0..a6d9970bfc 100644 --- a/apps/backend/internal/orchestrator/session_scope_matrix_test.go +++ b/apps/backend/internal/orchestrator/session_scope_matrix_test.go @@ -5,6 +5,7 @@ import ( "errors" "testing" + "github.com/kandev/kandev/internal/clarification" "github.com/kandev/kandev/internal/task/models" ) @@ -93,6 +94,12 @@ func sessionScopeCases() []deniedCase { _, err := s.SteerTask(context.Background(), taskID, sessionID, "steer", "", false, nil) return err }}, + {"ResumeDetachedClarification", func(s *Service) error { + return s.ResumeDetachedClarification( + context.Background(), + clarification.DetachedClarificationResume{TaskID: taskID, SessionID: sessionID}, + ) + }}, } } diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index 27cec8807a..9a65681589 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -3,6 +3,7 @@ package orchestrator import ( "context" + "database/sql" "errors" "fmt" "maps" @@ -108,6 +109,8 @@ var ( agentPromptReadyInterval = 100 * time.Millisecond ) +const promptFailureCleanupTimeout = 5 * time.Second + const promptReadinessRecoveryStopReason = "prompt readiness recovery" type agentPromptStreamRecoverer interface { @@ -3503,11 +3506,92 @@ func (s *Service) persistSessionCommit(ctx context.Context, sessionID string, tr // If planMode is true, a plan mode prefix is prepended to the prompt. // Attachments (images) are passed through to the agent if provided. func (s *Service) PromptTask(ctx context.Context, taskID, sessionID string, prompt string, model string, planMode bool, attachments []v1.MessageAttachment, dispatchOnly bool) (*PromptResult, error) { - return s.promptTask(ctx, taskID, sessionID, prompt, model, planMode, attachments, dispatchOnly, "", false, nil) + return s.promptTask(ctx, taskID, sessionID, prompt, model, planMode, attachments, dispatchOnly, promptTaskOptions{}) } -// promptTask is PromptTask's implementation, taking an additional -// claimEntryID parameter used only by queued dispatches. When non-empty, this +type promptTaskOptions struct { + claimEntryID string + lifecyclePrompt bool + afterClaim func() error + preservePromptContext bool + // reserveTurnUntilDispatch persists detached-resume ownership before agentctl + // dispatch, while delaying the visible turn.started event until acceptance. + reserveTurnUntilDispatch bool + promptDispatchRecovery *models.PromptDispatchRecovery + expectedCurrentTurnID string +} + +type promptDispatchOutcome struct { + mu sync.Mutex + accepted bool + publicationErr error +} + +func (o *promptDispatchOutcome) recordAccepted(publicationErr error) { + if o == nil { + return + } + o.mu.Lock() + o.accepted = true + o.publicationErr = publicationErr + o.mu.Unlock() +} + +func (o *promptDispatchOutcome) snapshot() (bool, error) { + if o == nil { + return false, nil + } + o.mu.Lock() + defer o.mu.Unlock() + return o.accepted, o.publicationErr +} + +// acceptedPromptDispatchError means agentctl accepted the prompt, but durable +// publication or later transport handling failed. Detached clarification +// callers must not reopen the already-delivered answer on this error. +type acceptedPromptDispatchError struct { + err error +} + +func (e *acceptedPromptDispatchError) Error() string { + return fmt.Sprintf("prompt accepted but post-dispatch handling failed: %v", e.err) +} + +func (e *acceptedPromptDispatchError) Unwrap() error { return e.err } + +func (*acceptedPromptDispatchError) DetachedResumeAccepted() bool { return true } + +func wrapAcceptedPromptDispatchFailure( + dispatchAccepted bool, + failureErr, publicationErr error, +) error { + if !dispatchAccepted { + return failureErr + } + combined := errors.Join(failureErr, publicationErr) + if combined == nil { + return nil + } + return &acceptedPromptDispatchError{err: combined} +} + +func (options promptTaskOptions) executorContext(ctx context.Context) context.Context { + if options.preservePromptContext { + return ctx + } + return context.WithoutCancel(ctx) +} + +func (promptTaskOptions) failureContext(ctx context.Context) (context.Context, context.CancelFunc) { + // Preserve a small bounded window for state rollback after either the + // dispatch deadline or an ordinary transport request expires; otherwise a + // cancelled caller context can leave RUNNING state behind. + return context.WithTimeout(context.WithoutCancel(ctx), promptFailureCleanupTimeout) +} + +// promptTask is PromptTask's implementation. Its options carry queued-dispatch +// ownership and the bounded-context exception. When options.claimEntryID is +// non-empty, this // acquires sessionID's cancelInFlight guard around a second ownership check // and the "mark the session RUNNING" step immediately before startTurnForSession // and the (potentially long-blocking) executor.Prompt call below it. The worker @@ -3532,7 +3616,7 @@ func (s *Service) PromptTask(ctx context.Context, taskID, sessionID string, prom // executeQueuedMessage's own deferred cleanup instead // (clearQueuedDispatchInFlightIfCurrent), which is safe since none of them // block on an agent turn. -func (s *Service) promptTask(ctx context.Context, taskID, sessionID string, prompt string, model string, planMode bool, attachments []v1.MessageAttachment, dispatchOnly bool, claimEntryID string, lifecyclePrompt bool, afterClaim func() error) (*PromptResult, error) { +func (s *Service) promptTask(ctx context.Context, taskID, sessionID string, prompt string, model string, planMode bool, attachments []v1.MessageAttachment, dispatchOnly bool, options promptTaskOptions) (*PromptResult, error) { s.logPromptTaskCall(taskID, sessionID, prompt, model, planMode, attachments, dispatchOnly) if err := s.validatePromptTaskStart(sessionID); err != nil { return nil, err @@ -3567,17 +3651,11 @@ func (s *Service) promptTask(ctx context.Context, taskID, sessionID string, prom // would call PromptAgent with the OLD execution ID and get ErrExecutionNotFound. // Re-reading from the DB after ensureSessionRunning guarantees we use the // freshly-persisted AgentExecutionID. - if reloaded, err := s.repo.GetTaskSession(ctx, sessionID); err == nil && reloaded != nil { - session = reloaded - // Re-apply transforms in case metadata changed during ensureSessionRunning. - effectivePrompt = s.effectivePromptForSession(sessionID, prompt, planMode, session) - } + session = s.reloadPromptSession(ctx, sessionID, session) + // Re-apply transforms in case metadata changed during ensureSessionRunning. + effectivePrompt = s.effectivePromptForSession(sessionID, prompt, planMode, session) activityExecutionID, _ := s.agentManager.GetExecutionIDForSession(ctx, sessionID) - foregroundDispatch := s.beginForegroundDispatch( - sessionID, - foregroundClaim, - activityExecutionID, - ) + foregroundDispatch := s.beginForegroundDispatch(sessionID, foregroundClaim, activityExecutionID) if foregroundDispatch == nil { s.releaseForegroundClaimOnFailure(ctx, taskID, sessionID, foregroundClaim) return nil, fmt.Errorf("%w, please wait for completion", ErrAgentPromptInProgress) @@ -3595,8 +3673,9 @@ func (s *Service) promptTask(ctx context.Context, taskID, sessionID string, prom s.rememberTurnPrompt(sessionID, prompt, model, planMode, attachments) session, rollback, err := s.claimPromptDispatch( - ctx, taskID, sessionID, claimEntryID, lifecyclePrompt, - afterClaim, session, foregroundClaim, + ctx, taskID, sessionID, options.claimEntryID, options.lifecyclePrompt, + options.reserveTurnUntilDispatch, options.promptDispatchRecovery, + options.afterClaim, foregroundClaim, options.expectedCurrentTurnID, ) if err != nil { s.rollbackForegroundDispatchOnFailure(ctx, taskID, sessionID, foregroundDispatch) @@ -3610,39 +3689,84 @@ func (s *Service) promptTask(ctx context.Context, taskID, sessionID string, prom s.publishForegroundActivityChanged(ctx, taskID, sessionID) } - // Use context.WithoutCancel to prevent WebSocket request timeout from canceling the prompt. - // Prompts can take a long time (minutes) while the WS request may timeout in 15 seconds. - // We still want to log and respond, but the prompt should continue regardless. - promptCtx := context.WithoutCancel(ctx) - if rollback.turnID != "" { - if setter, ok := s.agentManager.(executor.PromptTurnIDSetter); ok { - if err := setter.SetPromptTurnID(promptCtx, session.AgentExecutionID, rollback.turnID); err != nil { - s.logger.Warn("failed to bind prompt turn before dispatch", - zap.String("session_id", sessionID), - zap.String("turn_id", rollback.turnID), - zap.Error(err)) - } - } - } + // Only bounded-ack callers preserve request context; ordinary prompts can take minutes. + promptCtx := options.executorContext(ctx) + s.bindPromptTurnID(promptCtx, session, rollback.turnID) + dispatchOutcome := &promptDispatchOutcome{} + onDispatched := s.promptDispatchCallback( + promptCtx, taskID, sessionID, rollback.reservedTurn, foregroundDispatch, dispatchOutcome, + ) result, err := s.executor.PromptWithDispatchCallback( promptCtx, taskID, sessionID, effectivePrompt, attachments, dispatchOnly, - func() { - if s.acceptForegroundDispatch(foregroundDispatch) { - s.publishForegroundActivityChanged(promptCtx, taskID, sessionID) - } - }, session, + onDispatched, session, ) + dispatchAccepted, publicationErr := dispatchOutcome.snapshot() if err != nil { - s.rollbackForegroundDispatchOnFailure(ctx, taskID, sessionID, foregroundDispatch) - return s.handlePromptDispatchFailure( - ctx, taskID, sessionID, prompt, planMode, resumedForPrompt, - attachments, rollback, lifecyclePrompt, err, + return s.finishPromptDispatchFailure( + ctx, taskID, sessionID, prompt, planMode, resumedForPrompt, attachments, + rollback, options, err, foregroundDispatch, dispatchAccepted, publicationErr, ) } - return &PromptResult{ - StopReason: result.StopReason, - AgentMessage: result.AgentMessage, - }, nil + if publicationErr != nil { + return nil, &acceptedPromptDispatchError{err: publicationErr} + } + return &PromptResult{StopReason: result.StopReason, AgentMessage: result.AgentMessage}, nil +} + +func (s *Service) finishPromptDispatchFailure( + ctx context.Context, + taskID, sessionID, prompt string, + planMode, resumedForPrompt bool, + attachments []v1.MessageAttachment, + rollback promptClaimRollback, + options promptTaskOptions, + promptErr error, + foregroundDispatch *foregroundDispatch, + dispatchAccepted bool, + publicationErr error, +) (*PromptResult, error) { + failureCtx, cancel := options.failureContext(ctx) + defer cancel() + s.rollbackForegroundDispatchOnFailure(failureCtx, taskID, sessionID, foregroundDispatch) + if dispatchAccepted && rollback.reservedTurn != nil { + // Agentctl acceptance made the turn authoritative. A later completion + // error may reconcile session state, but must not delete this turn. + rollback.reservedTurnAccepted = true + } + failureResult, failureErr := s.handlePromptDispatchFailure( + failureCtx, taskID, sessionID, prompt, planMode, resumedForPrompt, + attachments, rollback, options.lifecyclePrompt, dispatchAccepted, promptErr, + ) + return failureResult, wrapAcceptedPromptDispatchFailure( + dispatchAccepted, + failureErr, + publicationErr, + ) +} + +func (s *Service) reloadPromptSession( + ctx context.Context, + sessionID string, + fallback *models.TaskSession, +) *models.TaskSession { + reloaded, err := s.repo.GetTaskSession(ctx, sessionID) + if err != nil || reloaded == nil { + return fallback + } + return reloaded +} + +func (s *Service) bindPromptTurnID(ctx context.Context, session *models.TaskSession, turnID string) { + setter, ok := s.agentManager.(executor.PromptTurnIDSetter) + if turnID == "" || session == nil || !ok { + return + } + if err := setter.SetPromptTurnID(ctx, session.AgentExecutionID, turnID); err != nil { + s.logger.Warn("failed to bind prompt turn before dispatch", + zap.String("session_id", session.ID), + zap.String("turn_id", turnID), + zap.Error(err)) + } } func (s *Service) validatePromptTaskStart(sessionID string) error { @@ -3718,6 +3842,45 @@ func (s *Service) rollbackForegroundDispatchOnFailure( } } +func (s *Service) promptDispatchCallback( + ctx context.Context, + taskID, sessionID string, + reservedTurn *models.Turn, + dispatch *foregroundDispatch, + outcome *promptDispatchOutcome, +) func() { + return func() { + var publicationErr error + if reservedTurn != nil && s.turnService != nil { + // Agentctl has already accepted the prompt. Publication must not inherit + // an expiring HTTP context or the caller could receive success while the + // durable turn still looks unpublished at restart. + publishCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), promptFailureCleanupTimeout) + publicationErr = s.turnService.PublishReservedTurn(publishCtx, reservedTurn) + cancel() + if publicationErr != nil { + s.logger.Error("failed to persist or publish accepted prompt turn", + zap.String("session_id", sessionID), + zap.String("turn_id", reservedTurn.ID), + zap.Error(publicationErr)) + } + // The pre-dispatch attempt marker makes restart recovery and rollback + // fail closed even if this post-acceptance publication fails. Do not + // restore an active-turn cache entry when cancellation already made the + // reservation terminal or publication could not find it. + if reservedTurn.CompletedAt == nil && !errors.Is(publicationErr, sql.ErrNoRows) { + s.activeTurns.Store(sessionID, reservedTurn.ID) + s.bindAcceptedDispatchTurn(sessionID, reservedTurn.ID) + } + s.resolveReservedPromptTurn(sessionID, reservedTurn.ID, true) + } + outcome.recordAccepted(publicationErr) + if s.acceptForegroundDispatch(dispatch) { + s.publishForegroundActivityChanged(ctx, taskID, sessionID) + } + } +} + // trySwitchModelForPrompt keeps foreground admission consistent when a model // switch either dispatches the prompt itself or fails before reaching the agent. func (s *Service) trySwitchModelForPrompt(ctx context.Context, taskID, sessionID, model, prompt string, session *models.TaskSession, dispatch *foregroundDispatch) (*PromptResult, bool, error) { @@ -3742,29 +3905,38 @@ type promptClaimRollback struct { taskStateClaimed bool turnID string createdTurn bool + reservedTurn *models.Turn + reservedTurnAccepted bool } func (s *Service) claimPromptDispatch( ctx context.Context, taskID, sessionID, claimEntryID string, lifecyclePrompt bool, + reserveTurnUntilDispatch bool, + promptDispatchRecovery *models.PromptDispatchRecovery, afterClaim func() error, - session *models.TaskSession, foregroundClaim *foregroundClaim, + expectedCurrentTurnID string, ) (*models.TaskSession, promptClaimRollback, error) { if lifecyclePrompt { return s.claimLifecyclePromptDispatch( ctx, taskID, sessionID, claimEntryID, afterClaim, ) } - rollback := promptClaimRollback{previousSessionState: session.State} - claimed, err := s.claimSessionRunningForPrompt( - ctx, taskID, sessionID, claimEntryID, session, foregroundClaim, + claimed, previousState, turnID, createdTurn, reservedTurn, err := s.claimSessionRunningForPrompt( + ctx, taskID, sessionID, claimEntryID, reserveTurnUntilDispatch, + promptDispatchRecovery, foregroundClaim, expectedCurrentTurnID, ) if err != nil { return nil, promptClaimRollback{}, err } - rollback.turnID, rollback.createdTurn = s.startTurnForSessionWithOwnership(ctx, sessionID) + rollback := promptClaimRollback{ + previousSessionState: previousState, + turnID: turnID, + createdTurn: createdTurn, + reservedTurn: reservedTurn, + } return claimed, rollback, nil } @@ -3786,7 +3958,13 @@ func (s *Service) claimLifecyclePromptDispatch( if err != nil { return nil, promptClaimRollback{}, err } - rollback.turnID, rollback.createdTurn = s.startTurnForSessionWithOwnership(ctx, sessionID) + rollback.turnID, rollback.createdTurn, _, err = s.startTurnForSessionWithOwnershipChecked( + ctx, sessionID, false, nil, + ) + if err != nil { + s.rollbackPromptClaimAfterAdmissionFailure(ctx, taskID, sessionID, rollback) + return nil, promptClaimRollback{}, fmt.Errorf("persist lifecycle prompt turn: %w", err) + } if err := s.acknowledgePromptClaim(ctx, taskID, sessionID, afterClaim, rollback); err != nil { return nil, promptClaimRollback{}, err } @@ -3812,6 +3990,16 @@ func (s *Service) acknowledgePromptClaim( return nil } +func (s *Service) rollbackPromptClaimAfterAdmissionFailure( + ctx context.Context, + taskID, sessionID string, + rollback promptClaimRollback, +) { + failureCtx, cancel := (promptTaskOptions{}).failureContext(ctx) + defer cancel() + s.rollbackPromptClaim(failureCtx, taskID, sessionID, rollback) +} + func (s *Service) rollbackPromptClaim( ctx context.Context, taskID, sessionID string, @@ -3822,7 +4010,44 @@ func (s *Service) rollbackPromptClaim( s.restoreLifecycleTaskState(ctx, taskID, rollback.previousTaskState) } if rollback.createdTurn { - s.completeTurnIfCurrent(ctx, sessionID, rollback.turnID) + if rollback.reservedTurn != nil { + if !rollback.reservedTurnAccepted { + s.rollbackReservedPromptTurn(ctx, sessionID, rollback.turnID) + } + } else { + s.completeTurnIfCurrent(ctx, sessionID, rollback.turnID) + } + } +} + +func (s *Service) rollbackReservedPromptTurn(ctx context.Context, sessionID, turnID string) { + if s.turnService == nil || turnID == "" { + return + } + // Publication removes the private reservation. A later transport/completion + // error must not delete a turn that agentctl already accepted. + if s.reservedPromptTurnID(sessionID) != turnID { + return + } + rolledBack, err := s.turnService.RollbackReservedTurn(ctx, sessionID, turnID) + if err != nil { + // The database outcome can be ambiguous (for example, a commit error). + // Keep the live reservation unresolved so ready handling waits and later + // prompts remain blocked until restart recovery reconciles durable state. + s.logger.Error("failed to roll back reserved prompt turn; session remains quarantined", + zap.String("session_id", sessionID), + zap.String("turn_id", turnID), + zap.Error(err)) + return + } + s.resolveReservedPromptTurn(sessionID, turnID, false) + if rolledBack { + s.activeTurns.CompareAndDelete(sessionID, turnID) + return + } + active, lookupErr := s.turnService.GetActiveTurn(ctx, sessionID) + if lookupErr == nil && active != nil && active.ID == turnID { + s.activeTurns.Store(sessionID, turnID) } } @@ -3930,9 +4155,11 @@ func (s *Service) handlePromptDispatchFailure( attachments []v1.MessageAttachment, rollback promptClaimRollback, lifecyclePrompt bool, + dispatchAccepted bool, promptErr error, ) (*PromptResult, error) { - if resumedForPrompt && errors.Is(promptErr, executor.ErrExecutionNotFound) { + if resumedForPrompt && !dispatchAccepted && !rollback.reservedTurnAccepted && rollback.reservedTurn == nil && + errors.Is(promptErr, executor.ErrExecutionNotFound) { s.logger.Warn("prompt after lazy resume hit missing execution; falling back to fresh launch", zap.String("task_id", taskID), zap.String("session_id", sessionID)) @@ -3948,6 +4175,9 @@ func (s *Service) handlePromptDispatchFailure( s.rollbackPromptClaim(ctx, taskID, sessionID, rollback) return nil, promptErr } + if rollback.createdTurn && rollback.reservedTurn != nil && !rollback.reservedTurnAccepted { + s.rollbackReservedPromptTurn(ctx, sessionID, rollback.turnID) + } return nil, s.handlePromptError(ctx, taskID, sessionID, rollback.previousSessionState, promptErr) } @@ -4008,48 +4238,87 @@ func (e *lifecyclePromptReselectedError) Error() string { func (s *Service) claimSessionRunningForPrompt( ctx context.Context, taskID, sessionID, claimEntryID string, - session *models.TaskSession, + reserveTurnUntilDispatch bool, + promptDispatchRecovery *models.PromptDispatchRecovery, foregroundClaim *foregroundClaim, -) (*models.TaskSession, error) { + expectedCurrentTurnID string, +) (*models.TaskSession, models.TaskSessionState, string, bool, *models.Turn, error) { lock, release := s.acquireCancelInFlightGuard(sessionID) defer release() lock.Lock() defer lock.Unlock() if err := s.waitForCancellationWithGuard(ctx, sessionID, lock.Unlock, lock.Lock); err != nil { - return nil, err + return nil, "", "", false, nil, err + } + if expectedCurrentTurnID != "" { + if s.turnService == nil { + return nil, "", "", false, nil, errors.New("cannot verify expected prompt turn without turn service") + } + activeTurn, err := s.turnService.GetActiveTurn(ctx, sessionID) + if err != nil { + return nil, "", "", false, nil, fmt.Errorf("verify expected prompt turn: %w", err) + } + if activeTurn == nil || activeTurn.ID != expectedCurrentTurnID { + return nil, "", "", false, nil, fmt.Errorf( + "clarification turn %s is no longer current", + expectedCurrentTurnID, + ) + } } if claimEntryID != "" && !s.isCurrentQueuedDispatch(sessionID, claimEntryID) { - return nil, errQueuedDispatchSuperseded + return nil, "", "", false, nil, errQueuedDispatchSuperseded } freshSession, sessErr := s.repo.GetTaskSession(ctx, sessionID) if sessErr != nil { - return nil, fmt.Errorf("reload session before claiming queued dispatch: %w", sessErr) + return nil, "", "", false, nil, fmt.Errorf("reload session before claiming queued dispatch: %w", sessErr) } if freshSession == nil { - return nil, errQueuedDispatchSuperseded + return nil, "", "", false, nil, errQueuedDispatchSuperseded } if promptErr := s.recheckPromptableWithForegroundClaim( taskID, sessionID, freshSession.State, foregroundClaim, ); promptErr != nil { - return nil, promptErr + return nil, "", "", false, nil, promptErr } + previousState := freshSession.State s.setSessionRunning(ctx, taskID, sessionID, freshSession) // setSessionRunning refreshes freshSession in place when its guarded write // loses, so a cancellation landing after the promptability check is visible // here as a terminal state. if isTerminalSessionState(freshSession.State) && freshSession.State != models.TaskSessionStateCompleted { - return nil, &executor.SessionStateSupersededError{ + return nil, "", "", false, nil, &executor.SessionStateSupersededError{ SessionID: freshSession.ID, State: freshSession.State, } } + turnID, createdTurn, reservedTurn, err := s.startTurnForSessionWithOwnershipChecked( + ctx, sessionID, reserveTurnUntilDispatch, promptDispatchRecovery, + ) + if err != nil { + s.rollbackPromptClaimAfterAdmissionFailure(ctx, taskID, sessionID, promptClaimRollback{ + previousSessionState: previousState, + }) + return nil, "", "", false, nil, fmt.Errorf("persist prompt turn: %w", err) + } + if reservedTurn != nil && s.turnService != nil { + if err := s.turnService.MarkReservedTurnDispatchAttempted(ctx, reservedTurn); err != nil { + rollback := promptClaimRollback{ + previousSessionState: previousState, + turnID: turnID, + createdTurn: createdTurn, + reservedTurn: reservedTurn, + } + s.rollbackPromptClaimAfterAdmissionFailure(ctx, taskID, sessionID, rollback) + return nil, "", "", false, nil, fmt.Errorf("persist prompt dispatch attempt: %w", err) + } + } reservation := s.queuedDispatchReservationForEntry(sessionID, claimEntryID) if reservation != nil && reservation.liveEligible.Load() { // A Send Now reservation becomes replaceable only after this guarded - // session claim. Start/adopt its turn under the same guard so the phase - // and turn ownership cannot be observed half-way through the handoff. - s.startTurnForSessionWithOwnership(ctx, sessionID) + // session claim. The turn was started/adopted under the same guard above, + // so the phase and turn ownership cannot be observed half-way through the + // handoff. s.markAcceptedDispatchLiveLocked(sessionID, reservation) } // Remove only the pre-acceptance marker here. The accepted ownership record @@ -4061,7 +4330,7 @@ func (s *Service) claimSessionRunningForPrompt( s.queuedDispatchReservationForEntry(sessionID, claimEntryID), ) } - return freshSession, nil + return freshSession, previousState, turnID, createdTurn, reservedTurn, nil } func (s *Service) claimLifecycleSessionRunning( @@ -4528,6 +4797,45 @@ func (s *Service) acquireCancelInFlightGuard(sessionID string) (*sync.Mutex, fun return &guard.mu, release } +// lockedCancelInFlightGuard keeps the yield/reacquire protocol in one place +// for operations that must release the session guard around lifecycle I/O. +type lockedCancelInFlightGuard struct { + mutex *sync.Mutex + releaseRef func() + locked bool +} + +func (s *Service) lockCancelInFlightGuard(sessionID string) *lockedCancelInFlightGuard { + mutex, release := s.acquireCancelInFlightGuard(sessionID) + mutex.Lock() + return &lockedCancelInFlightGuard{mutex: mutex, releaseRef: release, locked: true} +} + +func (g *lockedCancelInFlightGuard) unlock() { + if g == nil || !g.locked { + return + } + g.mutex.Unlock() + g.locked = false +} + +func (g *lockedCancelInFlightGuard) relock() { + if g == nil || g.locked { + return + } + g.mutex.Lock() + g.locked = true +} + +func (g *lockedCancelInFlightGuard) release() { + if g == nil || g.releaseRef == nil { + return + } + g.unlock() + g.releaseRef() + g.releaseRef = nil +} + // claimCancellation establishes the single owner for every cancellation source // targeting one session. The operation stays registered until its owner has // finished lifecycle cancellation and source-specific reconciliation. diff --git a/apps/backend/internal/orchestrator/task_operations_passthrough_test.go b/apps/backend/internal/orchestrator/task_operations_passthrough_test.go index b1010dde7a..c07fec5ecf 100644 --- a/apps/backend/internal/orchestrator/task_operations_passthrough_test.go +++ b/apps/backend/internal/orchestrator/task_operations_passthrough_test.go @@ -195,3 +195,35 @@ func TestPromptTask_PassthroughWriteFailureRevertsSession(t *testing.T) { t.Errorf("MarkPassthroughRunning should fire once before the write; got %d call(s)", markCount) } } + +func TestPromptTask_PassthroughWriteFailureRevertsSessionAfterCallerCancellation(t *testing.T) { + repo := setupTestRepo(t) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + agentMgr := &mockAgentManager{ + isPassthrough: true, + isAgentRunning: true, + passthroughStdinFunc: func(context.Context, string, string) error { + cancel() + return errors.New("interactive runner disconnected") + }, + } + svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr) + svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{}) + + seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateWaitingForInput) + seedExecutorRunning(t, repo, "session1", "task1", "exec-1") + + _, err := svc.PromptTask(ctx, "task1", "session1", "anything", "", false, nil, false) + if err == nil { + t.Fatal("expected error from PromptTask when PTY write fails") + } + + updated, getErr := repo.GetTaskSession(context.Background(), "session1") + if getErr != nil { + t.Fatalf("failed to reload session: %v", getErr) + } + if updated.State != models.TaskSessionStateWaitingForInput { + t.Errorf("expected session reverted to WAITING_FOR_INPUT, got %q", updated.State) + } +} diff --git a/apps/backend/internal/orchestrator/task_operations_test.go b/apps/backend/internal/orchestrator/task_operations_test.go index ac11045381..089f500202 100644 --- a/apps/backend/internal/orchestrator/task_operations_test.go +++ b/apps/backend/internal/orchestrator/task_operations_test.go @@ -7,6 +7,7 @@ import ( "fmt" "strings" "sync" + "sync/atomic" "testing" "time" @@ -685,6 +686,91 @@ func TestPromptTask_ExecutionNotFoundRevertsStateAndBroadcasts(t *testing.T) { } } +func TestAcceptedReservedPromptFailureDoesNotStartFreshExecution(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateWaitingForInput) + session, err := repo.GetTaskSession(ctx, "session1") + require.NoError(t, err) + session.AgentProfileID = "profile1" + require.NoError(t, repo.UpdateTaskSession(ctx, session)) + + var launchCalls atomic.Int32 + agentMgr := &mockAgentManager{ + launchAgentFunc: func(context.Context, *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) { + launchCalls.Add(1) + return nil, errors.New("unexpected fresh launch") + }, + } + taskRepo := newMockTaskRepo() + taskRepo.tasks["task1"] = &v1.Task{ID: "task1", Title: "Task", State: v1.TaskStateInProgress} + svc := createTestServiceWithScheduler(repo, newMockStepGetter(), taskRepo, agentMgr) + + _, err = svc.finishPromptDispatchFailure( + ctx, + "task1", + "session1", + "answer", + false, + true, + nil, + promptClaimRollback{ + previousSessionState: models.TaskSessionStateWaitingForInput, + turnID: "turn-reserved", + createdTurn: true, + reservedTurn: &models.Turn{ + ID: "turn-reserved", TaskID: "task1", TaskSessionID: "session1", + }, + }, + promptTaskOptions{}, + fmt.Errorf("accepted transport failure: %w", executor.ErrExecutionNotFound), + nil, + true, + nil, + ) + require.ErrorIs(t, err, executor.ErrExecutionNotFound) + require.Zero(t, launchCalls.Load(), "accepted prompt must not start duplicate execution") +} + +func TestAcceptedOrdinaryPromptFailureDoesNotStartFreshExecution(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateWaitingForInput) + session, err := repo.GetTaskSession(ctx, "session1") + require.NoError(t, err) + session.AgentProfileID = "profile1" + require.NoError(t, repo.UpdateTaskSession(ctx, session)) + + var launchCalls atomic.Int32 + agentMgr := &mockAgentManager{ + launchAgentFunc: func(context.Context, *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) { + launchCalls.Add(1) + return nil, errors.New("unexpected fresh launch") + }, + } + taskRepo := newMockTaskRepo() + taskRepo.tasks["task1"] = &v1.Task{ID: "task1", Title: "Task", State: v1.TaskStateInProgress} + svc := createTestServiceWithScheduler(repo, newMockStepGetter(), taskRepo, agentMgr) + + _, err = svc.finishPromptDispatchFailure( + ctx, + "task1", + "session1", + "answer", + false, + true, + nil, + promptClaimRollback{previousSessionState: models.TaskSessionStateWaitingForInput}, + promptTaskOptions{}, + fmt.Errorf("accepted transport failure: %w", executor.ErrExecutionNotFound), + nil, + true, + nil, + ) + require.ErrorIs(t, err, executor.ErrExecutionNotFound) + require.Zero(t, launchCalls.Load(), "accepted ordinary prompt must not start duplicate execution") +} + func TestPromptTask_PlanModeInjectsPrefix(t *testing.T) { repo := setupTestRepo(t) taskRepo := newMockTaskRepo() @@ -2968,7 +3054,7 @@ func TestQueueAndInterruptForPeerMessage_RacesClarificationTimeoutRecovery(t *te snapshotTaken: make(chan struct{}), } svc.turnService = turnSync - _, err := turnSync.StartTurn(ctx, "session1") + clarificationTurn, err := turnSync.StartTurn(ctx, "session1") require.NoError(t, err) // Clarification-timeout recovery claims the guard first and blocks @@ -2977,7 +3063,7 @@ func TestQueueAndInterruptForPeerMessage_RacesClarificationTimeoutRecovery(t *te var recovered bool go func() { recovered = svc.retryClarificationAfterCancel(ctx, clarificationAnsweredData{ - TaskID: "task1", SessionID: "session1", + TaskID: "task1", SessionID: "session1", ClarificationTurnID: clarificationTurn.ID, }, "the clarification answer", fmt.Errorf("wrap: %w", ErrAgentPromptInProgress)) close(recoveryDone) }() @@ -3088,13 +3174,13 @@ func TestClarificationRecovery_ReleasesGuardAfterRetryDispatch(t *testing.T) { snapshotTaken: make(chan struct{}), } svc.turnService = turnSync - _, err := turnSync.StartTurn(ctx, "session1") + clarificationTurn, err := turnSync.StartTurn(ctx, "session1") require.NoError(t, err) recoveryDone := make(chan bool, 1) go func() { recoveryDone <- svc.retryClarificationAfterCancel(ctx, clarificationAnsweredData{ - TaskID: "task1", SessionID: "session1", + TaskID: "task1", SessionID: "session1", ClarificationTurnID: clarificationTurn.ID, }, "clarification answer", fmt.Errorf("wrap: %w", ErrAgentPromptInProgress)) }() <-retryAccepted diff --git a/apps/backend/internal/orchestrator/turn_lifecycle_test.go b/apps/backend/internal/orchestrator/turn_lifecycle_test.go index 9246208c2d..22cf8e6070 100644 --- a/apps/backend/internal/orchestrator/turn_lifecycle_test.go +++ b/apps/backend/internal/orchestrator/turn_lifecycle_test.go @@ -4,13 +4,16 @@ import ( "context" "database/sql" "errors" + "sync" "testing" "time" "github.com/google/uuid" + "github.com/kandev/kandev/internal/orchestrator/watcher" "github.com/kandev/kandev/internal/task/models" sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" + wfmodels "github.com/kandev/kandev/internal/workflow/models" ) // repoTurnService is a minimal TurnService used by lifecycle tests. It mirrors @@ -21,6 +24,78 @@ type repoTurnService struct { repo *sqliterepo.Repository } +type failingReservedTurnPublisher struct { + TurnService + err error +} + +type completedReservedTurnPublisher struct { + TurnService +} + +type blockingReservedTurnMarker struct { + TurnService + entered chan struct{} + release chan struct{} +} + +type failingPromptTurnReconciler struct { + TurnService + err error +} + +type failingReservedTurnRollback struct { + TurnService + err error +} + +type failingActiveTurnLookup struct { + TurnService + err error + turn *models.Turn + startCalls int +} + +func (s failingReservedTurnPublisher) PublishReservedTurn(context.Context, *models.Turn) error { + return s.err +} + +func (s completedReservedTurnPublisher) PublishReservedTurn(_ context.Context, turn *models.Turn) error { + completedAt := time.Now().UTC() + turn.CompletedAt = &completedAt + return nil +} + +func (s blockingReservedTurnMarker) MarkReservedTurnDispatchAttempted( + ctx context.Context, + turn *models.Turn, +) error { + close(s.entered) + <-s.release + return s.TurnService.MarkReservedTurnDispatchAttempted(ctx, turn) +} + +func (s failingPromptTurnReconciler) ReconcileUnpublishedPromptTurns(context.Context) (int, error) { + return 0, s.err +} + +func (s failingReservedTurnRollback) RollbackReservedTurn(context.Context, string, string) (bool, error) { + return false, s.err +} + +func (s *failingActiveTurnLookup) GetActiveTurn(context.Context, string) (*models.Turn, error) { + return s.turn, s.err +} + +func (s *failingActiveTurnLookup) StartTurn(ctx context.Context, sessionID string) (*models.Turn, error) { + s.startCalls++ + return s.TurnService.StartTurn(ctx, sessionID) +} + +func (failingReservedTurnPublisher) GetActiveTurn(context.Context, string) (*models.Turn, error) { + return nil, nil +} + func (a *repoTurnService) StartTurn(ctx context.Context, sessionID string) (*models.Turn, error) { now := time.Now().UTC() turn := &models.Turn{ @@ -37,6 +112,31 @@ func (a *repoTurnService) StartTurn(ctx context.Context, sessionID string) (*mod return turn, nil } +func (a *repoTurnService) ReserveTurn( + ctx context.Context, + sessionID string, + _ *models.PromptDispatchRecovery, +) (*models.Turn, error) { + return a.StartTurn(ctx, sessionID) +} + +func (a *repoTurnService) PublishReservedTurn(context.Context, *models.Turn) error { return nil } + +func (a *repoTurnService) MarkReservedTurnDispatchAttempted(context.Context, *models.Turn) error { + return nil +} + +func (a *repoTurnService) RollbackReservedTurn( + ctx context.Context, + sessionID, turnID string, +) (bool, error) { + return a.repo.DeleteTurnIfUnreferenced(ctx, sessionID, turnID) +} + +func (a *repoTurnService) ReconcileUnpublishedPromptTurns(ctx context.Context) (int, error) { + return a.repo.ReconcileUnpublishedPromptTurns(ctx) +} + func (a *repoTurnService) CompleteTurn(ctx context.Context, turnID string) error { return a.repo.CompleteTurn(ctx, turnID) } @@ -57,6 +157,18 @@ func (a *repoTurnService) UpdateTurn(ctx context.Context, turn *models.Turn) err return a.repo.UpdateTurn(ctx, turn) } +func (a *repoTurnService) PatchTurnMetadata( + ctx context.Context, + sessionID, turnID string, + updates map[string]interface{}, +) error { + updated, _, err := a.repo.PatchTurnMetadata(ctx, sessionID, turnID, updates) + if err == nil && !updated { + return sql.ErrNoRows + } + return err +} + func (a *repoTurnService) AbandonOpenTurns(ctx context.Context, sessionID string) error { for { turn, err := a.GetActiveTurn(ctx, sessionID) @@ -93,6 +205,108 @@ func newTurnLifecycleTestService(t *testing.T) (*Service, *sqliterepo.Repository return svc, repo } +func TestStartTurnDropsPromptAfterActiveTurnLookupFailure(t *testing.T) { + svc, repo := newTurnLifecycleTestService(t) + lookupErr := errors.New("active turn read failed") + failing := &failingActiveTurnLookup{ + TurnService: svc.turnService, + err: lookupErr, + turn: &models.Turn{ID: "partial-active-turn"}, + } + svc.turnService = failing + + turnID, created := svc.startTurnForSessionWithOwnership(context.Background(), "session1") + if turnID != "" || created { + t.Fatalf("start after lookup failure = (%q, %v), want empty drop", turnID, created) + } + if failing.startCalls != 0 { + t.Fatalf("StartTurn calls = %d, want 0 after lookup failure", failing.startCalls) + } + turns, err := repo.ListTurnsBySession(context.Background(), "session1") + if err != nil { + t.Fatalf("ListTurnsBySession: %v", err) + } + if len(turns) != 0 { + t.Fatalf("turns after lookup failure = %#v, want none", turns) + } +} + +func TestReservedPromptCallbackOwnerCancelsAndDrains(t *testing.T) { + svc, _ := newTurnLifecycleTestService(t) + svc.resetReservedPromptCallbacks() + t.Cleanup(svc.stopReservedPromptCallbacks) + reservation := newReservedPromptTurn("turn-callback-owner") + entered := make(chan struct{}) + finished := make(chan struct{}) + if !svc.deferReservedPromptCallback(reservation, func(ctx context.Context) { + close(entered) + <-ctx.Done() + close(finished) + }) { + t.Fatal("failed to defer reservation callback") + } + svc.reservedPromptTurns.Store("session1", reservation) + if !svc.resolveReservedPromptTurn("session1", reservation.id, true) { + t.Fatal("failed to resolve reservation") + } + select { + case <-entered: + case <-time.After(time.Second): + t.Fatal("reserved callback did not start") + } + + stopDone := make(chan struct{}) + go func() { + svc.stopReservedPromptCallbacks() + close(stopDone) + }() + select { + case <-finished: + case <-time.After(time.Second): + t.Fatal("reserved callback did not observe owner cancellation") + } + select { + case <-stopDone: + case <-time.After(time.Second): + t.Fatal("reserved callback owner did not drain before returning") + } +} + +func TestAgentReadyDetachedContextPreservesReservedCallbackShutdown(t *testing.T) { + ownerCtx, cancelOwner := context.WithCancel(context.Background()) + callbackCtx, cancelCallback := reservedPromptCallbackContext(ownerCtx, context.Background()) + t.Cleanup(cancelCallback) + detached := agentReadyDetachedContext(callbackCtx) + cancelOwner() + select { + case <-detached.Done(): + case <-time.After(time.Second): + t.Fatal("agent-ready detached context discarded callback-owner cancellation") + } +} + +func TestReservedPromptCallbackContextCanBeReattachedAfterRetry(t *testing.T) { + ownerCtx, cancelOwner := context.WithCancel(context.Background()) + firstCtx, cancelFirst := reservedPromptCallbackContext(ownerCtx, context.Background()) + retryCtx := context.WithoutCancel(firstCtx) + cancelFirst() + + secondCtx, cancelSecond := reservedPromptCallbackContext(ownerCtx, retryCtx) + t.Cleanup(cancelSecond) + select { + case <-secondCtx.Done(): + t.Fatal("reattached callback inherited the prior invocation's cancellation") + default: + } + + cancelOwner() + select { + case <-secondCtx.Done(): + case <-time.After(time.Second): + t.Fatal("reattached callback did not preserve owner cancellation") + } +} + // TestStartTurnAdoptsExistingDBTurn covers the dual-creation leak that left // zombie turns whenever service.CreateMessage lazily started a turn for an // inbound user message and the orchestrator's PromptTask then started another. @@ -148,6 +362,498 @@ func TestStartTurnIsIdempotentInMemory(t *testing.T) { } } +func TestReservedTurnCannotBeAdoptedBeforePublication(t *testing.T) { + svc, repo := newTurnLifecycleTestService(t) + ctx := context.Background() + + turnID, created, reserved, err := svc.startTurnForSessionWithOwnershipChecked(ctx, "session1", true, nil) + if err != nil || !created || reserved == nil || turnID == "" { + t.Fatalf("reserve turn: id=%q created=%v reserved=%v err=%v", turnID, created, reserved, err) + } + if cached, ok := svc.activeTurns.Load("session1"); ok { + t.Fatalf("unpublished reserved turn entered active cache: %v", cached) + } + + adoptedID, _, _, err := svc.startTurnForSessionWithOwnershipChecked(ctx, "session1", false, nil) + if !errors.Is(err, ErrAgentPromptInProgress) || adoptedID != "" { + t.Fatalf("second prompt adopted unpublished turn: id=%q err=%v", adoptedID, err) + } + if got := svc.getActiveTurnID("session1"); got != turnID { + t.Fatalf("message turn ID = %q, want reserved turn %q", got, turnID) + } + if open := openTurnCount(t, repo, "session1"); open != 1 { + t.Fatalf("open turns before publication = %d, want 1", open) + } + + svc.promptDispatchCallback(ctx, "task1", "session1", reserved, nil, &promptDispatchOutcome{})() + cached, ok := svc.activeTurns.Load("session1") + if !ok || cached != turnID { + t.Fatalf("published turn cache = %v, %v; want %q", cached, ok, turnID) + } +} + +func TestAcceptedReservationStaysActiveWhenPublicationWriteFails(t *testing.T) { + svc, _ := newTurnLifecycleTestService(t) + reserved := &models.Turn{ID: "turn-accepted", TaskSessionID: "session1", TaskID: "task1"} + svc.turnService = failingReservedTurnPublisher{ + TurnService: svc.turnService, + err: errors.New("turn metadata write failed"), + } + svc.reservedPromptTurns.Store("session1", newReservedPromptTurn(reserved.ID)) + + svc.promptDispatchCallback( + context.Background(), "task1", "session1", reserved, nil, &promptDispatchOutcome{}, + )() + + active, ok := svc.activeTurns.Load("session1") + if !ok || active != reserved.ID { + t.Fatalf("accepted reservation cache = %v, %v; want %q", active, ok, reserved.ID) + } + if pending := svc.reservedPromptTurnID("session1"); pending != "" { + t.Fatalf("private reservation cache = %q, want cleared after agentctl acceptance", pending) + } +} + +func TestAcceptedReservationDoesNotRestoreTerminalOrMissingCache(t *testing.T) { + for _, tc := range []struct { + name string + publisher func(TurnService) TurnService + }{ + { + name: "terminal", + publisher: func(delegate TurnService) TurnService { + return completedReservedTurnPublisher{TurnService: delegate} + }, + }, + { + name: "missing", + publisher: func(delegate TurnService) TurnService { + return failingReservedTurnPublisher{TurnService: delegate, err: sql.ErrNoRows} + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + svc, _ := newTurnLifecycleTestService(t) + reserved := &models.Turn{ID: "turn-accepted", TaskSessionID: "session1", TaskID: "task1"} + svc.turnService = tc.publisher(svc.turnService) + svc.reservedPromptTurns.Store("session1", newReservedPromptTurn(reserved.ID)) + + svc.promptDispatchCallback( + context.Background(), "task1", "session1", reserved, nil, &promptDispatchOutcome{}, + )() + + if active, ok := svc.activeTurns.Load("session1"); ok { + t.Fatalf("terminal or missing reservation restored active cache: %v", active) + } + if pending := svc.reservedPromptTurnID("session1"); pending != "" { + t.Fatalf("private reservation cache = %q, want cleared", pending) + } + }) + } +} + +func TestReservedTurnAttemptMarkingHoldsCancellationGuard(t *testing.T) { + svc, repo := newTurnLifecycleTestService(t) + ctx := context.Background() + if err := repo.UpdateTaskSessionState( + ctx, + "session1", + models.TaskSessionStateWaitingForInput, + "", + ); err != nil { + t.Fatalf("set session waiting: %v", err) + } + marker := blockingReservedTurnMarker{ + TurnService: svc.turnService, + entered: make(chan struct{}), + release: make(chan struct{}), + } + var releaseOnce sync.Once + releaseMarker := func() { releaseOnce.Do(func() { close(marker.release) }) } + t.Cleanup(releaseMarker) + svc.turnService = marker + + claimDone := make(chan error, 1) + go func() { + _, _, err := svc.claimPromptDispatch( + ctx, + "task1", + "session1", + "", + false, + true, + &models.PromptDispatchRecovery{}, + nil, + nil, + "", + ) + claimDone <- err + }() + select { + case <-marker.entered: + case <-time.After(time.Second): + t.Fatal("timed out waiting for reserved-turn attempt marker") + } + + guardAcquired := make(chan struct{}) + go func() { + guard, release := svc.acquireCancelInFlightGuard("session1") + defer release() + guard.Lock() + close(guardAcquired) + guard.Unlock() + }() + select { + case <-guardAcquired: + t.Fatal("cancellation guard was released before attempt marking completed") + case <-time.After(100 * time.Millisecond): + } + + releaseMarker() + select { + case err := <-claimDone: + if err != nil { + t.Fatalf("claimPromptDispatch: %v", err) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for prompt claim") + } + select { + case <-guardAcquired: + case <-time.After(time.Second): + t.Fatal("cancellation guard remained blocked after attempt marking") + } +} + +func TestAgentReadyWaitsForReservedPromptGeneration(t *testing.T) { + svc, repo := newTurnLifecycleTestService(t) + ctx := context.Background() + agentMgr := svc.agentManager.(*mockAgentManager) + agentMgr.currentPromptExecutionID = "exec1" + agentMgr.currentPromptGeneration.Store(1) + + turnID, _, reserved, err := svc.startTurnForSessionWithOwnershipChecked(ctx, "session1", true, nil) + t.Cleanup(func() { svc.resolveReservedPromptTurn("session1", turnID, false) }) + if err != nil { + t.Fatalf("reserve successor turn: %v", err) + } + reserved.Metadata = map[string]interface{}{ + models.TurnMetaKeyPromptDispatchPending: true, + models.TurnMetaKeyPromptDispatchAttempted: true, + } + if err := repo.UpdateTurn(ctx, reserved); err != nil { + t.Fatalf("mark successor dispatch attempted: %v", err) + } + + readyDone := make(chan struct{}) + go func() { + svc.handleAgentReady(ctx, watcher.AgentEventData{ + TaskID: "task1", SessionID: "session1", + AgentExecutionID: "exec1", PromptGeneration: 1, + }) + close(readyDone) + }() + select { + case <-readyDone: + t.Fatal("predecessor ready completed the reserved successor before dispatch resolution") + case <-time.After(200 * time.Millisecond): + } + + agentMgr.currentPromptGeneration.Store(2) + svc.promptDispatchCallback(ctx, "task1", "session1", reserved, nil, &promptDispatchOutcome{})() + select { + case <-readyDone: + case <-time.After(time.Second): + t.Fatal("predecessor ready did not resume after dispatch resolution") + } + turn, err := repo.GetTurn(ctx, turnID) + if err != nil { + t.Fatalf("load successor after predecessor ready: %v", err) + } + if turn.CompletedAt != nil { + t.Fatalf("predecessor ready completed reserved successor at %v", turn.CompletedAt) + } +} + +func TestAgentReadyRevalidatesAfterReservedPromptRollback(t *testing.T) { + svc, repo := newTurnLifecycleTestService(t) + ctx := context.Background() + stepGetter := svc.workflowStepGetter.(*mockStepGetter) + stepGetter.steps["step1"] = &wfmodels.WorkflowStep{ID: "step1"} + agentMgr := svc.agentManager.(*mockAgentManager) + agentMgr.currentPromptExecutionID = "exec1" + agentMgr.currentPromptGeneration.Store(1) + + turnID, _, _, err := svc.startTurnForSessionWithOwnershipChecked(ctx, "session1", true, nil) + t.Cleanup(func() { svc.resolveReservedPromptTurn("session1", turnID, false) }) + if err != nil { + t.Fatalf("reserve successor turn: %v", err) + } + readyDone := make(chan struct{}) + go func() { + svc.handleAgentReady(ctx, watcher.AgentEventData{ + TaskID: "task1", SessionID: "session1", + AgentExecutionID: "exec1", PromptGeneration: 1, + }) + close(readyDone) + }() + select { + case <-readyDone: + t.Fatal("predecessor ready returned before reservation resolution") + case <-time.After(200 * time.Millisecond): + } + + svc.rollbackReservedPromptTurn(ctx, "session1", turnID) + select { + case <-readyDone: + case <-time.After(time.Second): + t.Fatal("predecessor ready did not resume after reservation rollback") + } + session, err := repo.GetTaskSession(ctx, "session1") + if err != nil { + t.Fatalf("load session after ready: %v", err) + } + if session.State == models.TaskSessionStateRunning || session.State == models.TaskSessionStateStarting { + t.Fatalf("rolled-back reservation stranded predecessor in %q", session.State) + } +} + +func TestAgentReadyDetachesDeliveryCancellationAfterReservedPromptRollback(t *testing.T) { + svc, repo := newTurnLifecycleTestService(t) + stepGetter := svc.workflowStepGetter.(*mockStepGetter) + stepGetter.steps["step1"] = &wfmodels.WorkflowStep{ID: "step1"} + agentMgr := svc.agentManager.(*mockAgentManager) + agentMgr.currentPromptExecutionID = "exec1" + agentMgr.currentPromptGeneration.Store(1) + + turnID, _, _, err := svc.startTurnForSessionWithOwnershipChecked(context.Background(), "session1", true, nil) + t.Cleanup(func() { svc.resolveReservedPromptTurn("session1", turnID, false) }) + if err != nil { + t.Fatalf("reserve successor turn: %v", err) + } + deliveryCtx, cancelDelivery := context.WithCancel(context.Background()) + t.Cleanup(cancelDelivery) + readyDone := make(chan struct{}) + go func() { + svc.handleAgentReady(deliveryCtx, watcher.AgentEventData{ + TaskID: "task1", SessionID: "session1", + AgentExecutionID: "exec1", PromptGeneration: 1, + }) + close(readyDone) + }() + select { + case <-readyDone: + t.Fatal("predecessor ready returned before reservation resolution") + case <-time.After(200 * time.Millisecond): + } + + cancelDelivery() + svc.rollbackReservedPromptTurn(context.Background(), "session1", turnID) + select { + case <-readyDone: + case <-time.After(time.Second): + t.Fatal("predecessor ready did not resume after reservation rollback") + } + session, err := repo.GetTaskSession(context.Background(), "session1") + if err != nil { + t.Fatalf("load session after ready: %v", err) + } + if session.State == models.TaskSessionStateRunning || session.State == models.TaskSessionStateStarting { + t.Fatalf("canceled delivery context stranded predecessor in %q", session.State) + } +} + +func TestAgentReadyReconcilesWhenReservedPromptRollbackFinishesAfterWaitTimeout(t *testing.T) { + svc, repo := newTurnLifecycleTestService(t) + svc.agentReadyReservationWaitTimeout = 20 * time.Millisecond + ctx := context.Background() + stepGetter := svc.workflowStepGetter.(*mockStepGetter) + stepGetter.steps["step1"] = &wfmodels.WorkflowStep{ID: "step1"} + agentMgr := svc.agentManager.(*mockAgentManager) + agentMgr.currentPromptExecutionID = "exec1" + agentMgr.currentPromptGeneration.Store(1) + + turnID, _, _, err := svc.startTurnForSessionWithOwnershipChecked(ctx, "session1", true, nil) + t.Cleanup(func() { svc.resolveReservedPromptTurn("session1", turnID, false) }) + if err != nil { + t.Fatalf("reserve successor turn: %v", err) + } + readyDone := make(chan struct{}) + go func() { + svc.handleAgentReady(ctx, watcher.AgentEventData{ + TaskID: "task1", SessionID: "session1", + AgentExecutionID: "exec1", PromptGeneration: 1, + }) + close(readyDone) + }() + select { + case <-readyDone: + case <-time.After(time.Second): + t.Fatal("ready handler did not return after reservation wait timeout") + } + + svc.rollbackReservedPromptTurn(ctx, "session1", turnID) + deadline := time.NewTimer(time.Second) + defer deadline.Stop() + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { + session, loadErr := repo.GetTaskSession(ctx, "session1") + if loadErr != nil { + t.Fatalf("load session after late rollback: %v", loadErr) + } + if session.State != models.TaskSessionStateRunning && session.State != models.TaskSessionStateStarting { + break + } + select { + case <-deadline.C: + t.Fatalf("late rollback stranded predecessor in %q", session.State) + case <-ticker.C: + } + } +} + +func TestAgentReadyWaitsForReservedTurnThenDropsGenerationlessEventOnRollback(t *testing.T) { + svc, repo := newTurnLifecycleTestService(t) + ctx := context.Background() + agentMgr := svc.agentManager.(*mockAgentManager) + agentMgr.currentPromptExecutionID = "exec1" + + turnID, _, _, err := svc.startTurnForSessionWithOwnershipChecked(ctx, "session1", true, nil) + t.Cleanup(func() { svc.resolveReservedPromptTurn("session1", turnID, false) }) + if err != nil { + t.Fatalf("reserve successor turn: %v", err) + } + readyDone := make(chan struct{}) + go func() { + svc.handleAgentReady(ctx, watcher.AgentEventData{ + TaskID: "task1", SessionID: "session1", + AgentExecutionID: "exec1", PromptGeneration: 0, + }) + close(readyDone) + }() + select { + case <-readyDone: + t.Fatal("generationless ready returned before reservation resolution") + case <-time.After(200 * time.Millisecond): + } + + svc.rollbackReservedPromptTurn(ctx, "session1", turnID) + select { + case <-readyDone: + case <-time.After(time.Second): + t.Fatal("generationless ready did not resume after reservation rollback") + } + if _, err := repo.GetTurn(ctx, turnID); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("rolled-back reserved turn remains: %v", err) + } + session, err := repo.GetTaskSession(ctx, "session1") + if err != nil { + t.Fatalf("load session after generationless ready: %v", err) + } + if session.State != models.TaskSessionStateRunning { + t.Fatalf("generationless ready changed session state to %q", session.State) + } +} + +func TestStartFailsClosedWhenPromptTurnRecoveryFails(t *testing.T) { + svc, _ := newTurnLifecycleTestService(t) + recoveryErr := errors.New("recover unpublished prompt turn") + svc.turnService = failingPromptTurnReconciler{TurnService: svc.turnService, err: recoveryErr} + + err := svc.Start(context.Background()) + if !errors.Is(err, recoveryErr) { + t.Fatalf("Start error = %v, want %v", err, recoveryErr) + } + if svc.running { + t.Fatal("service remained running after prompt-turn recovery failure") + } +} + +func TestStartFailsClosedWithoutTurnService(t *testing.T) { + svc, _ := newTurnLifecycleTestService(t) + svc.turnService = nil + + err := svc.Start(context.Background()) + if err == nil { + _ = svc.Stop() + t.Fatal("Start error = nil, want missing turn service failure") + } + if svc.running { + t.Fatal("service remained running without prompt-turn recovery") + } +} + +func TestPublishedReservedTurnCannotBeRolledBack(t *testing.T) { + svc, repo := newTurnLifecycleTestService(t) + ctx := context.Background() + turnID, _, reserved, err := svc.startTurnForSessionWithOwnershipChecked(ctx, "session1", true, nil) + if err != nil { + t.Fatalf("reserve turn: %v", err) + } + + svc.promptDispatchCallback(ctx, "task1", "session1", reserved, nil, &promptDispatchOutcome{})() + svc.rollbackReservedPromptTurn(ctx, "session1", turnID) + + turn, err := repo.GetTurn(ctx, turnID) + if err != nil { + t.Fatalf("load accepted turn after late rollback: %v", err) + } + if turn == nil { + t.Fatal("late prompt failure deleted an already-published turn") + } +} + +func TestRejectedReservedTurnClearsPrivateCache(t *testing.T) { + svc, repo := newTurnLifecycleTestService(t) + ctx := context.Background() + + turnID, _, _, err := svc.startTurnForSessionWithOwnershipChecked(ctx, "session1", true, nil) + if err != nil { + t.Fatalf("reserve turn: %v", err) + } + svc.rollbackReservedPromptTurn(ctx, "session1", turnID) + if reserved := svc.reservedPromptTurnID("session1"); reserved != "" { + t.Fatalf("reserved cache after rollback = %q, want empty", reserved) + } + if _, ok := svc.activeTurns.Load("session1"); ok { + t.Fatal("rolled-back reservation entered active cache") + } + if open := openTurnCount(t, repo, "session1"); open != 0 { + t.Fatalf("open turns after rollback = %d, want 0", open) + } + + successorID, _, _, err := svc.startTurnForSessionWithOwnershipChecked(ctx, "session1", false, nil) + if err != nil || successorID == "" || successorID == turnID { + t.Fatalf("successor after rollback: id=%q rejected=%q err=%v", successorID, turnID, err) + } +} + +func TestFailedReservedTurnRollbackKeepsSessionQuarantined(t *testing.T) { + svc, _ := newTurnLifecycleTestService(t) + ctx := context.Background() + turnID, _, _, err := svc.startTurnForSessionWithOwnershipChecked(ctx, "session1", true, nil) + if err != nil { + t.Fatalf("reserve turn: %v", err) + } + reservation := svc.reservedPromptTurn("session1") + rollbackErr := errors.New("ambiguous rollback commit") + svc.turnService = failingReservedTurnRollback{TurnService: svc.turnService, err: rollbackErr} + + svc.rollbackReservedPromptTurn(ctx, "session1", turnID) + if pending := svc.reservedPromptTurnID("session1"); pending != turnID { + t.Fatalf("reservation after failed rollback = %q, want quarantine %q", pending, turnID) + } + select { + case <-reservation.done: + t.Fatal("failed rollback resolved the reservation waiter") + default: + } + if _, _, _, err := svc.startTurnForSessionWithOwnershipChecked(ctx, "session1", false, nil); !errors.Is(err, ErrAgentPromptInProgress) { + t.Fatalf("new prompt after failed rollback error = %v, want %v", err, ErrAgentPromptInProgress) + } +} + // TestCompleteTurnClosesUntrackedDBTurn covers the user-cancel zombie path: // completeTurnForSession previously bailed when activeTurns was empty, leaving // the DB row open (e.g. after a backend restart wiped activeTurns, or after @@ -352,6 +1058,59 @@ func TestReconcileSessionsOnStartupAbandonsOpenTurns(t *testing.T) { } } +func TestReconcileSessionsOnStartupRestoresUnpublishedClarificationDispatch(t *testing.T) { + svc, repo := newTurnLifecycleTestService(t) + ctx := context.Background() + startedAt := time.Now().UTC().Add(-time.Minute) + completedAt := startedAt.Add(time.Second) + + if err := repo.CreateTurn(ctx, &models.Turn{ + ID: "turn-clarification", TaskSessionID: "session1", TaskID: "task1", + StartedAt: startedAt, CompletedAt: &completedAt, + }); err != nil { + t.Fatalf("create clarification turn: %v", err) + } + if err := repo.CreateMessage(ctx, &models.Message{ + ID: "message-clarification", TaskSessionID: "session1", TaskID: "task1", + TurnID: "turn-clarification", AuthorType: models.MessageAuthorAgent, + Type: models.MessageTypeClarificationRequest, Content: "Continue?", + Metadata: map[string]interface{}{ + "pending_id": "pending-restart", "question_id": "q1", + "status": "answered", "response": map[string]interface{}{"custom_text": "yes"}, + }, + CreatedAt: startedAt, + }); err != nil { + t.Fatalf("create terminal clarification: %v", err) + } + if err := repo.CreateTurn(ctx, &models.Turn{ + ID: "turn-unpublished", TaskSessionID: "session1", TaskID: "task1", + StartedAt: startedAt.Add(time.Minute), + Metadata: map[string]interface{}{ + "prompt_dispatch_pending": true, + "prompt_dispatch_clarification_pending_id": "pending-restart", + "prompt_dispatch_clarification_turn_id": "turn-clarification", + "prompt_dispatch_clarification_message_ids": []string{"message-clarification"}, + }, + }); err != nil { + t.Fatalf("create unpublished reservation: %v", err) + } + + // No executors_running row exists. Recovery must still run before the + // ordinary startup reconciler takes its current early-return path. + svc.reconcileSessionsOnStartup(ctx) + + if _, err := repo.GetTurn(ctx, "turn-unpublished"); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("unpublished turn error = %v, want deleted reservation", err) + } + message, err := repo.GetMessage(ctx, "message-clarification") + if err != nil { + t.Fatalf("GetMessage: %v", err) + } + if message.Metadata["status"] != "pending" || message.Metadata["response"] != nil { + t.Fatalf("recovered metadata = %#v, want pending without response", message.Metadata) + } +} + func TestReconcileTerminalSessionOnStartupAbandonsOpenTurns(t *testing.T) { svc, repo := newTurnLifecycleTestService(t) ctx := context.Background() diff --git a/apps/backend/internal/task/dto/converters_test.go b/apps/backend/internal/task/dto/converters_test.go index a7c2a33e45..c72c0d9a23 100644 --- a/apps/backend/internal/task/dto/converters_test.go +++ b/apps/backend/internal/task/dto/converters_test.go @@ -200,3 +200,34 @@ func TestTaskToAPI_DerivesInterruptedFromMetadata(t *testing.T) { t.Fatal("ToAPI Interrupted = true, want false when interrupted_at metadata is absent") } } + +func TestFromTurnPreservesSubsecondOrderingPrecision(t *testing.T) { + base := time.Date(2026, time.August, 15, 12, 0, 0, 100000000, time.UTC) + completedAt := base.Add(3 * time.Nanosecond) + got := FromTurn(&models.Turn{ + ID: "turn-nano", + StartedAt: base, + CompletedAt: &completedAt, + CreatedAt: base.Add(time.Nanosecond), + UpdatedAt: base.Add(2 * time.Nanosecond), + }) + + if got.StartedAt != "2026-08-15T12:00:00.100000000Z" { + t.Fatalf("StartedAt = %q, want nanosecond precision", got.StartedAt) + } + if got.CreatedAt != "2026-08-15T12:00:00.100000001Z" { + t.Fatalf("CreatedAt = %q, want nanosecond precision", got.CreatedAt) + } + if got.UpdatedAt != "2026-08-15T12:00:00.100000002Z" { + t.Fatalf("UpdatedAt = %q, want nanosecond precision", got.UpdatedAt) + } + if got.CompletedAt == nil || *got.CompletedAt != "2026-08-15T12:00:00.100000003Z" { + t.Fatalf("CompletedAt = %v, want nanosecond precision", got.CompletedAt) + } + if got.StartedAt >= got.CreatedAt || got.CreatedAt >= got.UpdatedAt || got.UpdatedAt >= *got.CompletedAt { + t.Fatalf("turn timestamps are not lexically chronological: %+v", got) + } + if _, err := time.Parse(time.RFC3339, got.StartedAt); err != nil { + t.Fatalf("time.Parse(RFC3339, StartedAt): %v", err) + } +} diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index eaa00b6cc1..c172136ed2 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -233,6 +233,10 @@ type TaskDTO struct { // It is loaded in batches and is absent when no projection exists yet; the // existing coarse fields above remain the compatibility fallback. StatusSummary *statussummary.TaskStatusSummary `json:"status_summary,omitempty"` + // StatusSummaryInvalidated distinguishes a known-stale summary from an + // ordinarily omitted partial projection so clients clear their cache and + // expose the coarse compatibility fallback. + StatusSummaryInvalidated bool `json:"status_summary_invalidated,omitempty"` } type TaskRepositoryDTO struct { @@ -321,8 +325,9 @@ type TaskSessionDTO struct { SupportsSteering bool `json:"supports_steering,omitempty"` // PendingAction is the compact per-session projection used when the // session transcript is not loaded in the client. - PendingAction *string `json:"pending_action,omitempty"` - ActiveSubagentCount int `json:"active_subagent_count"` + PendingAction *string `json:"pending_action"` + PendingActionRevision *models.PendingActionRevision `json:"pending_action_revision,omitempty"` + ActiveSubagentCount int `json:"active_subagent_count"` // LastReadMessageID is the session's Slack-style read cursor — the id of // the newest message the frontend has marked as read. Used by the // transcript to position the unread ("New") divider. @@ -379,9 +384,10 @@ type TaskSessionSummaryDTO struct { SupportsSteering bool `json:"supports_steering,omitempty"` // PendingAction is the compact per-session projection used when the // session transcript is not loaded in the client. - PendingAction *string `json:"pending_action"` - ActiveSubagentCount int `json:"active_subagent_count"` - LastReadMessageID string `json:"last_read_message_id,omitempty"` + PendingAction *string `json:"pending_action"` + PendingActionRevision *models.PendingActionRevision `json:"pending_action_revision,omitempty"` + ActiveSubagentCount int `json:"active_subagent_count"` + LastReadMessageID string `json:"last_read_message_id,omitempty"` // CommandCount is the number of tool_call messages on this session, // surfaced inline in the timeline entry header ("ran N commands"). // Populated by ListTaskSessions; defaults to 0 for callers that don't @@ -1099,11 +1105,13 @@ func TaskPlanRevisionMetaFromModel(rev *models.TaskPlanRevision) *TaskPlanRevisi return meta } +const turnTimestampLayout = "2006-01-02T15:04:05.000000000Z07:00" + // FromTurn converts a Turn model to a TurnDTO. func FromTurn(turn *models.Turn) TurnDTO { var completedAt *string if turn.CompletedAt != nil { - formatted := turn.CompletedAt.UTC().Format(time.RFC3339) + formatted := turn.CompletedAt.UTC().Format(turnTimestampLayout) completedAt = &formatted } @@ -1111,10 +1119,10 @@ func FromTurn(turn *models.Turn) TurnDTO { ID: turn.ID, SessionID: turn.TaskSessionID, TaskID: turn.TaskID, - StartedAt: turn.StartedAt.UTC().Format(time.RFC3339), + StartedAt: turn.StartedAt.UTC().Format(turnTimestampLayout), CompletedAt: completedAt, Metadata: turn.Metadata, - CreatedAt: turn.CreatedAt.UTC().Format(time.RFC3339), - UpdatedAt: turn.UpdatedAt.UTC().Format(time.RFC3339), + CreatedAt: turn.CreatedAt.UTC().Format(turnTimestampLayout), + UpdatedAt: turn.UpdatedAt.UTC().Format(turnTimestampLayout), } } diff --git a/apps/backend/internal/task/dto/status_summary.go b/apps/backend/internal/task/dto/status_summary.go new file mode 100644 index 0000000000..d79b719efc --- /dev/null +++ b/apps/backend/internal/task/dto/status_summary.go @@ -0,0 +1,18 @@ +package dto + +import "github.com/kandev/kandev/internal/task/statussummary" + +// EnrichTaskStatusSummary carries a summary or an explicit invalidation. A +// missing map entry remains ordinary omission for partial-response safety. +func EnrichTaskStatusSummary( + task *TaskDTO, + taskID string, + summaries map[string]*statussummary.TaskStatusSummary, +) { + if task == nil { + return + } + summary, known := summaries[taskID] + task.StatusSummary = summary + task.StatusSummaryInvalidated = known && summary == nil +} diff --git a/apps/backend/internal/task/dto/status_summary_test.go b/apps/backend/internal/task/dto/status_summary_test.go index d770841c8f..26e49f7dda 100644 --- a/apps/backend/internal/task/dto/status_summary_test.go +++ b/apps/backend/internal/task/dto/status_summary_test.go @@ -43,3 +43,46 @@ func TestTaskDTOStatusSummaryIsBoundedAndOptional(t *testing.T) { t.Fatal("task status summary must not carry transcript data") } } + +func TestTaskDTOStatusSummaryInvalidationIsExplicit(t *testing.T) { + var task TaskDTO + if err := json.Unmarshal([]byte(`{"id":"task-1","status_summary_invalidated":true}`), &task); err != nil { + t.Fatalf("decode task invalidation: %v", err) + } + encoded, err := json.Marshal(task) + if err != nil { + t.Fatalf("encode task invalidation: %v", err) + } + var decoded map[string]interface{} + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatalf("decode task JSON: %v", err) + } + if invalidated, _ := decoded["status_summary_invalidated"].(bool); !invalidated { + t.Fatalf("task JSON missing explicit status-summary invalidation: %s", encoded) + } +} + +func TestEnrichTaskStatusSummaryDistinguishesInvalidationFromOmission(t *testing.T) { + tests := []struct { + name string + summaries map[string]*statussummary.TaskStatusSummary + wantSummary bool + wantInvalid bool + }{ + {name: "omitted", summaries: map[string]*statussummary.TaskStatusSummary{}}, + {name: "invalidated", summaries: map[string]*statussummary.TaskStatusSummary{"task-1": nil}, wantInvalid: true}, + {name: "carried", summaries: map[string]*statussummary.TaskStatusSummary{ + "task-1": {Revision: 3}, + }, wantSummary: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + task := TaskDTO{ID: "task-1"} + EnrichTaskStatusSummary(&task, task.ID, test.summaries) + if (task.StatusSummary != nil) != test.wantSummary || + task.StatusSummaryInvalidated != test.wantInvalid { + t.Fatalf("summary = %+v, invalidated = %v", task.StatusSummary, task.StatusSummaryInvalidated) + } + }) + } +} diff --git a/apps/backend/internal/task/handlers/cancellation_pending_test.go b/apps/backend/internal/task/handlers/cancellation_pending_test.go index 67a05fccf1..d37f7a23c8 100644 --- a/apps/backend/internal/task/handlers/cancellation_pending_test.go +++ b/apps/backend/internal/task/handlers/cancellation_pending_test.go @@ -3,6 +3,7 @@ package handlers import ( "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "testing" @@ -38,6 +39,8 @@ type messageOrchestratorWithCancellation struct { type cancellationListRepo struct { mockRepository sessionsByTask []*models.TaskSession + pendingActions map[string]models.TaskPendingAction + pendingErr error } func (r *cancellationListRepo) ListTaskSessions(context.Context, string) ([]*models.TaskSession, error) { @@ -48,6 +51,13 @@ func (r *cancellationListRepo) CountToolCallMessagesBySession(context.Context, [ return nil, nil } +func (r *cancellationListRepo) GetPendingActionsBySessionIDs( + context.Context, + []string, +) (map[string]models.TaskPendingAction, error) { + return r.pendingActions, r.pendingErr +} + func (o *orchestratorWithCancellation) CancellationPending(string) bool { return o.pending } @@ -93,6 +103,8 @@ func TestHTTPGetTaskSession_StampsCancellationPending(t *testing.T) { resp := doGetTaskSession(t, h, "sess-cancel") require.True(t, resp.Session.CancellationPending) + require.NotNil(t, resp.Session.PendingActionRevision) + require.NotEmpty(t, resp.Session.PendingActionRevision.Epoch) } func TestHTTPListTaskSessions_StampsCancellationPending(t *testing.T) { @@ -125,6 +137,81 @@ func TestHTTPListTaskSessions_StampsCancellationPending(t *testing.T) { require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &response)) require.Len(t, response.Sessions, 1) require.True(t, response.Sessions[0].CancellationPending) + require.NotNil(t, response.Sessions[0].PendingActionRevision) + require.NotEmpty(t, response.Sessions[0].PendingActionRevision.Epoch) +} + +func TestListTaskSessionsFailsWhenPendingProjectionFails(t *testing.T) { + gin.SetMode(gin.TestMode) + session := &models.TaskSession{ + ID: "sess-pending", TaskID: "task-1", State: models.TaskSessionStateWaitingForInput, + } + repo := &cancellationListRepo{ + mockRepository: mockRepository{sessions: map[string]*models.TaskSession{session.ID: session}}, + sessionsByTask: []*models.TaskSession{session}, + pendingErr: errors.New("pending projection unavailable"), + } + svc := service.NewService(service.Repos{ + Workspaces: repo, Tasks: repo, TaskRepos: repo, Workflows: repo, + Messages: repo, Turns: repo, Sessions: repo, GitSnapshots: repo, + RepoEntities: repo, Executors: repo, Environments: repo, + TaskEnvironments: repo, Reviews: repo, + }, nil, newTestLogger(t), service.RepositoryDiscoveryConfig{}) + h := &TaskHandlers{service: svc, repo: repo, logger: newTestLogger(t)} + + t.Run("http", func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/tasks/task-1/sessions", nil) + c.Params = gin.Params{{Key: "id", Value: "task-1"}} + h.httpListTaskSessions(c) + require.Equal(t, http.StatusInternalServerError, rec.Code, rec.Body.String()) + }) + + t.Run("websocket", func(t *testing.T) { + request, err := ws.NewRequest("list", ws.ActionTaskSessionList, map[string]string{"task_id": "task-1"}) + require.NoError(t, err) + response, err := h.doListTaskSessions(context.Background(), request, "task-1") + require.NoError(t, err) + var body ws.ErrorPayload + require.NoError(t, response.ParsePayload(&body)) + require.Equal(t, string(ws.ErrorCodeInternalError), body.Code) + }) +} + +func TestTaskSessionSummariesWithPendingActionsCentralizesInputGating(t *testing.T) { + sessions := []*models.TaskSession{ + {ID: "waiting", TaskID: "task-1", State: models.TaskSessionStateWaitingForInput}, + {ID: "running", TaskID: "task-1", State: models.TaskSessionStateRunning}, + {ID: "completed", TaskID: "task-1", State: models.TaskSessionStateCompleted}, + } + repo := &cancellationListRepo{ + mockRepository: mockRepository{sessions: map[string]*models.TaskSession{}}, + sessionsByTask: sessions, + pendingActions: map[string]models.TaskPendingAction{ + "waiting": models.TaskPendingActionClarification, + "running": models.TaskPendingActionPermission, + "completed": models.TaskPendingActionClarification, + }, + } + svc := service.NewService(service.Repos{ + Workspaces: repo, Tasks: repo, TaskRepos: repo, Workflows: repo, + Messages: repo, Turns: repo, Sessions: repo, GitSnapshots: repo, + RepoEntities: repo, Executors: repo, Environments: repo, + TaskEnvironments: repo, Reviews: repo, + }, nil, newTestLogger(t), service.RepositoryDiscoveryConfig{}) + h := &TaskHandlers{service: svc, logger: newTestLogger(t)} + + summaries, err := h.taskSessionSummariesWithPendingActions(context.Background(), sessions) + require.NoError(t, err) + require.Len(t, summaries, 3) + require.Equal(t, string(models.TaskPendingActionClarification), *summaries[0].PendingAction) + require.Equal(t, string(models.TaskPendingActionPermission), *summaries[1].PendingAction) + require.Nil(t, summaries[2].PendingAction) + for _, summary := range summaries { + require.NotNil(t, summary.PendingActionRevision) + require.NotEmpty(t, summary.PendingActionRevision.Epoch) + } } func TestWSListTaskSessions_StampsCancellationPending(t *testing.T) { @@ -153,6 +240,8 @@ func TestWSListTaskSessions_StampsCancellationPending(t *testing.T) { require.NoError(t, response.ParsePayload(&body)) require.Len(t, body.Sessions, 1) require.True(t, body.Sessions[0].CancellationPending) + require.NotNil(t, body.Sessions[0].PendingActionRevision) + require.NotEmpty(t, body.Sessions[0].PendingActionRevision.Epoch) } func newMessageCancellationService(t *testing.T, repo *messageAddSwitchRepo) *service.Service { diff --git a/apps/backend/internal/task/handlers/process_handlers_test.go b/apps/backend/internal/task/handlers/process_handlers_test.go index 705b35a9d7..58ca3c0b3b 100644 --- a/apps/backend/internal/task/handlers/process_handlers_test.go +++ b/apps/backend/internal/task/handlers/process_handlers_test.go @@ -33,6 +33,17 @@ type mockRepository struct { executors map[string]*models.Executor } +func (m *mockRepository) DeleteTurnIfUnreferenced(context.Context, string, string) (bool, error) { + return false, nil +} + +func (m *mockRepository) ReconcileUnpublishedPromptTurns(context.Context) (int, error) { + return 0, nil +} +func (m *mockRepository) ListTurnsPendingStartEvent(context.Context) ([]*models.Turn, error) { + return nil, nil +} + func (m *mockRepository) CreateWorkspace(ctx context.Context, workspace *models.Workspace) error { return nil } @@ -227,12 +238,36 @@ func (m *mockRepository) FindMessagesByPendingID(ctx context.Context, pendingID func (m *mockRepository) FindMessageByPendingIDAndQuestion(ctx context.Context, sessionID, pendingID, questionID string) (*models.Message, error) { return nil, nil } -func (m *mockRepository) FindPendingClarificationMessagesBySessionID(ctx context.Context, sessionID string) ([]*models.Message, error) { +func (m *mockRepository) FindActiveClarificationMessagesBySessionID(ctx context.Context, sessionID string) ([]*models.Message, error) { return nil, nil } func (m *mockRepository) GetPendingActionsBySessionIDs(ctx context.Context, sessionIDs []string) (map[string]models.TaskPendingAction, error) { return make(map[string]models.TaskPendingAction), nil } +func (m *mockRepository) CompleteActiveClarificationBundle( + context.Context, + string, + string, + map[string]interface{}, +) ([]*models.Message, bool, error) { + return nil, false, nil +} +func (m *mockRepository) FinalizeClarificationResponseDelivery( + context.Context, + string, + string, + []*models.Message, +) ([]*models.Message, bool, error) { + return nil, false, nil +} +func (m *mockRepository) RestoreActiveClarificationBundle( + context.Context, + string, + string, + []*models.Message, +) ([]*models.Message, bool, error) { + return nil, false, nil +} func (m *mockRepository) UpdateMessage(ctx context.Context, message *models.Message) error { return nil } @@ -269,6 +304,30 @@ func (m *mockRepository) GetActiveTurnBySessionID(ctx context.Context, sessionID func (m *mockRepository) UpdateTurn(ctx context.Context, turn *models.Turn) error { return nil } +func (m *mockRepository) PatchTurnMetadata( + context.Context, + string, + string, + map[string]interface{}, +) (bool, time.Time, error) { + return false, time.Time{}, nil +} +func (m *mockRepository) UpdateActiveTurnMetadata( + context.Context, + string, + string, + map[string]interface{}, + []string, +) (bool, map[string]interface{}, time.Time, error) { + return false, nil, time.Time{}, nil +} +func (m *mockRepository) ClearTurnPromptDispatchMetadata( + context.Context, + string, + string, +) (bool, map[string]interface{}, time.Time, error) { + return false, nil, time.Time{}, nil +} func (m *mockRepository) CompleteTurn(ctx context.Context, id string) error { return nil } diff --git a/apps/backend/internal/task/handlers/task_http_handlers.go b/apps/backend/internal/task/handlers/task_http_handlers.go index 938d53eff8..285d82df06 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers.go +++ b/apps/backend/internal/task/handlers/task_http_handlers.go @@ -233,11 +233,12 @@ func buildTaskDTOsWithSessionInfo( statusSummaries = map[string]*statussummary.TaskStatusSummary{} } if summaryErr == nil && pendingErr == nil { - statusSummaries, err = svc.HydrateMissingTaskStatusSummaries( + reconciledSummaries, reconcileErr := svc.ReconcileTaskStatusSummaries( ctx, tasks, sessionsByTask, pendingActionsBySession, statusSummaries, ) - if err != nil { - log.Warn("failed to repair missing task status summaries", zap.Error(err)) + statusSummaries = reconciledSummaries + if reconcileErr != nil { + log.Warn("failed to reconcile task status summaries", zap.Error(reconcileErr)) } } // Stamp the authoritative per-task queued prompt count onto every summary. @@ -286,7 +287,7 @@ func buildTaskDTOsWithSessionInfo( taskDTO.TaskPendingAction = taskPendingActionPtr(sessions, pendingActionsBySession) dto.EnrichTaskForegroundActivity(&taskDTO, sessions, activityProvider) dto.EnrichTaskDependencies(&taskDTO, dependencyProjection(dependencyViews[task.ID]), task) - taskDTO.StatusSummary = statusSummaries[task.ID] + dto.EnrichTaskStatusSummary(&taskDTO, task.ID, statusSummaries) if taskDTO.StatusSummary != nil { switch { case queuedErr != nil: @@ -415,19 +416,33 @@ func pendingActionPtr( return &value } +func pendingActionRevisionPtr( + sessionID string, + revisionsBySession map[string]models.PendingActionRevision, +) *models.PendingActionRevision { + revision, ok := revisionsBySession[sessionID] + if !ok { + return nil + } + return &revision +} + func (h *TaskHandlers) taskSessionDTO(ctx context.Context, session *models.TaskSession) dto.TaskSessionDTO { result := dto.FromTaskSession(session) dto.EnrichCancellationPending(&result, h.cancellationPending) - if !isInputCapableSession(session) { - return result - } - actions, err := h.service.GetPendingActionsForSessions(ctx, []string{session.ID}) + actions, revisions, err := h.service.GetPendingActionProjectionsForSessions( + ctx, + []string{session.ID}, + ) if err != nil { h.logger.Warn("get task session pending action failed", zap.String("session_id", session.ID), zap.Error(err)) return result } - result.PendingAction = pendingActionPtr(&session.ID, actions) + if isInputCapableSession(session) { + result.PendingAction = pendingActionPtr(&session.ID, actions) + } + result.PendingActionRevision = pendingActionRevisionPtr(session.ID, revisions) return result } @@ -457,24 +472,15 @@ func (h *TaskHandlers) httpListTaskSessions(c *gin.Context) { handleNotFound(c, h.logger, err, "task sessions not found") return } - pendingActionsBySession, pendingErr := pendingActionsForInputCapableSessions( - ctx, - h.service, - map[string][]*models.TaskSession{c.Param("id"): sessions}, - ) - if pendingErr != nil { - h.logger.Warn("get task session pending actions failed", zap.Error(pendingErr)) - pendingActionsBySession = map[string]models.TaskPendingAction{} + sessionDTOs, projectionErr := h.taskSessionSummariesWithPendingActions(ctx, sessions) + if projectionErr != nil { + h.logger.Error("get task session pending actions failed", zap.Error(projectionErr)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load task session pending actions"}) + return } - sessionDTOs := make([]dto.TaskSessionSummaryDTO, 0, len(sessions)) - ids := make([]string, 0, len(sessions)) - for _, session := range sessions { - summary := dto.FromTaskSessionSummary(session) - dto.EnrichForegroundActivitySummary(&summary, h.foregroundActivity) - dto.EnrichCancellationPendingSummary(&summary, h.cancellationPending) - summary.PendingAction = pendingActionPtr(&session.ID, pendingActionsBySession) - sessionDTOs = append(sessionDTOs, summary) - ids = append(ids, session.ID) + ids := make([]string, 0, len(sessionDTOs)) + for _, summary := range sessionDTOs { + ids = append(ids, summary.ID) } // Resolve the per-session tool_call counts so the frontend can render // the "ran N commands" segment without fetching every session's full diff --git a/apps/backend/internal/task/handlers/task_http_queued_test.go b/apps/backend/internal/task/handlers/task_http_queued_test.go index 9dc5b9361f..2ca0522a78 100644 --- a/apps/backend/internal/task/handlers/task_http_queued_test.go +++ b/apps/backend/internal/task/handlers/task_http_queued_test.go @@ -151,3 +151,29 @@ func TestTaskDTOBuilderPreservesProjectedCountWhenCounterUnwired(t *testing.T) { assert.Equal(t, 5, result[0].StatusSummary.QueuedPromptCount, "an unwired counter must preserve the projected count, not zero it") } + +func TestTaskDTOBuilderReconcilesExistingPendingSummary(t *testing.T) { + svc, h, repo := newQueuedTaskDTOBuilder(t) + ctx := context.Background() + task := createQueuedTestTask(t, svc, repo, fakeQueuedPromptCounter{byTask: map[string]int{}}) + changed, err := repo.CompareAndUpdateTaskStatusSummary(ctx, &statussummary.StoredTaskStatusSummary{ + TaskID: task.ID, + WorkspaceID: task.WorkspaceID, + Summary: statussummary.TaskStatusSummary{ + Revision: 5, + PendingAction: string(models.TaskPendingActionClarification), + Git: &statussummary.GitSummary{ChangedFiles: 2}, + }, + }) + require.NoError(t, err) + require.True(t, changed) + + result, err := h.toTaskDTOsWithSessionInfo(ctx, []*models.Task{task}) + require.NoError(t, err) + require.Len(t, result, 1) + require.NotNil(t, result[0].StatusSummary) + assert.Equal(t, uint64(6), result[0].StatusSummary.Revision) + assert.Empty(t, result[0].StatusSummary.PendingAction) + require.NotNil(t, result[0].StatusSummary.Git) + assert.Equal(t, 2, result[0].StatusSummary.Git.ChangedFiles) +} diff --git a/apps/backend/internal/task/handlers/task_session_summary_projection.go b/apps/backend/internal/task/handlers/task_session_summary_projection.go new file mode 100644 index 0000000000..9afb663fe4 --- /dev/null +++ b/apps/backend/internal/task/handlers/task_session_summary_projection.go @@ -0,0 +1,35 @@ +package handlers + +import ( + "context" + "fmt" + + "github.com/kandev/kandev/internal/task/dto" + "github.com/kandev/kandev/internal/task/models" +) + +func (h *TaskHandlers) taskSessionSummariesWithPendingActions( + ctx context.Context, + sessions []*models.TaskSession, +) ([]dto.TaskSessionSummaryDTO, error) { + sessionIDs := make([]string, 0, len(sessions)) + for _, session := range sessions { + sessionIDs = append(sessionIDs, session.ID) + } + actions, revisions, err := h.service.GetPendingActionProjectionsForSessions(ctx, sessionIDs) + if err != nil { + return nil, fmt.Errorf("get task session pending actions: %w", err) + } + summaries := make([]dto.TaskSessionSummaryDTO, 0, len(sessions)) + for _, session := range sessions { + summary := dto.FromTaskSessionSummary(session) + dto.EnrichForegroundActivitySummary(&summary, h.foregroundActivity) + dto.EnrichCancellationPendingSummary(&summary, h.cancellationPending) + if isInputCapableSession(session) { + summary.PendingAction = pendingActionPtr(&session.ID, actions) + } + summary.PendingActionRevision = pendingActionRevisionPtr(session.ID, revisions) + summaries = append(summaries, summary) + } + return summaries, nil +} diff --git a/apps/backend/internal/task/handlers/task_ws_handlers.go b/apps/backend/internal/task/handlers/task_ws_handlers.go index f623ee9db4..acfc566554 100644 --- a/apps/backend/internal/task/handlers/task_ws_handlers.go +++ b/apps/backend/internal/task/handlers/task_ws_handlers.go @@ -36,22 +36,16 @@ func (h *TaskHandlers) doListTaskSessions(ctx context.Context, msg *ws.Message, h.logger.Error("failed to list task sessions", zap.Error(err)) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list task sessions", nil) } - pendingActionsBySession, pendingErr := pendingActionsForInputCapableSessions( - ctx, - h.service, - map[string][]*models.TaskSession{taskID: sessions}, - ) - if pendingErr != nil { - h.logger.Warn("get task session pending actions failed", zap.Error(pendingErr)) - pendingActionsBySession = map[string]models.TaskPendingAction{} - } - sessionDTOs := make([]dto.TaskSessionSummaryDTO, 0, len(sessions)) - for _, session := range sessions { - summary := dto.FromTaskSessionSummary(session) - dto.EnrichForegroundActivitySummary(&summary, h.foregroundActivity) - dto.EnrichCancellationPendingSummary(&summary, h.cancellationPending) - summary.PendingAction = pendingActionPtr(&session.ID, pendingActionsBySession) - sessionDTOs = append(sessionDTOs, summary) + sessionDTOs, projectionErr := h.taskSessionSummariesWithPendingActions(ctx, sessions) + if projectionErr != nil { + h.logger.Error("get task session pending actions failed", zap.Error(projectionErr)) + return ws.NewError( + msg.ID, + msg.Action, + ws.ErrorCodeInternalError, + "Failed to load task session pending actions", + nil, + ) } resp := dto.ListTaskSessionSummariesResponse{ Sessions: sessionDTOs, diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index 4b097b1ed8..6fad99eb36 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -262,6 +262,48 @@ const TurnMetaKeyRuntimeConfigSnapshot = "runtime_config_snapshot" // was in when the turn started. Absent when the task held no step. const TurnMetaKeyWorkflowStepIDAtStart = "workflow_step_id_at_start" +// TurnMetaKeyPromptDispatchPending marks a successor created before agentctl +// acknowledges its prompt. Empty marked turns are not current-turn authority +// unless dispatch ambiguity was recorded; publication clears the marker, while +// a referencing message also proves ambiguous acceptance after a crash. +const TurnMetaKeyPromptDispatchPending = "prompt_dispatch_pending" + +const ( + TurnMetaKeyPromptDispatchAttempted = "prompt_dispatch_attempted" + TurnMetaKeyPromptDispatchClarificationPendingID = "prompt_dispatch_clarification_pending_id" + TurnMetaKeyPromptDispatchClarificationTurnID = "prompt_dispatch_clarification_turn_id" + TurnMetaKeyPromptDispatchClarificationMessageIDs = "prompt_dispatch_clarification_message_ids" + // TurnMetaKeyPromptDispatchStartEventPending is a durable outbox marker. + // Startup recovery sets it after accepting an ambiguous reservation and + // clears it only after the task service replays the public turn events. + TurnMetaKeyPromptDispatchStartEventPending = "prompt_dispatch_start_event_pending" +) + +var promptDispatchMetadataKeys = [...]string{ + TurnMetaKeyPromptDispatchPending, + TurnMetaKeyPromptDispatchAttempted, + TurnMetaKeyPromptDispatchClarificationPendingID, + TurnMetaKeyPromptDispatchClarificationTurnID, + TurnMetaKeyPromptDispatchClarificationMessageIDs, + TurnMetaKeyPromptDispatchStartEventPending, +} + +// ClearPromptDispatchMetadata removes every reservation-only field after live +// publication or successful startup event replay. +func ClearPromptDispatchMetadata(metadata map[string]interface{}) { + for _, key := range promptDispatchMetadataKeys { + delete(metadata, key) + } +} + +// PromptDispatchRecovery identifies the exact clarification claim that an +// unpublished successor reservation must restore after a backend restart. +type PromptDispatchRecovery struct { + PendingID string + TurnID string + MessageIDs []string +} + // SessionRuntimeConfig is persisted as provider state or explicit overrides. // On resume, explicit values take precedence over the latest provider snapshot // so delayed provider events cannot replace user intent. @@ -1061,6 +1103,14 @@ const ( // on user input. type TaskPendingAction string +// PendingActionRevision is a logical clock shared by REST and WebSocket +// pending-action projections. Epoch is a durable, monotonically allocated +// backend generation; Sequence orders snapshots within that generation. +type PendingActionRevision struct { + Epoch string `json:"epoch"` + Sequence uint64 `json:"sequence"` +} + const ( TaskPendingActionClarification TaskPendingAction = "clarification" TaskPendingActionPermission TaskPendingAction = "permission" diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index 7d615fb8be..2da98f7ce1 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -200,8 +200,11 @@ type MessageRepository interface { FindMessageByPendingID(ctx context.Context, pendingID string) (*models.Message, error) FindMessagesByPendingID(ctx context.Context, pendingID string) ([]*models.Message, error) FindMessageByPendingIDAndQuestion(ctx context.Context, sessionID, pendingID, questionID string) (*models.Message, error) - FindPendingClarificationMessagesBySessionID(ctx context.Context, sessionID string) ([]*models.Message, error) + FindActiveClarificationMessagesBySessionID(ctx context.Context, sessionID string) ([]*models.Message, error) GetPendingActionsBySessionIDs(ctx context.Context, sessionIDs []string) (map[string]models.TaskPendingAction, error) + CompleteActiveClarificationBundle(ctx context.Context, pendingID, status string, responses map[string]interface{}) ([]*models.Message, bool, error) + FinalizeClarificationResponseDelivery(ctx context.Context, pendingID, terminalStatus string, claimedMessages []*models.Message) ([]*models.Message, bool, error) + RestoreActiveClarificationBundle(ctx context.Context, pendingID, terminalStatus string, claimedMessages []*models.Message) ([]*models.Message, bool, error) UpdateMessage(ctx context.Context, message *models.Message) error ListMessages(ctx context.Context, sessionID string) ([]*models.Message, error) ListMessagesByTurnID(ctx context.Context, turnID string) ([]*models.Message, error) @@ -228,6 +231,17 @@ type AttachmentRepository interface { // TurnRepository handles conversation turn persistence. type TurnRepository interface { CreateTurn(ctx context.Context, turn *models.Turn) error + DeleteTurnIfUnreferenced(ctx context.Context, sessionID, turnID string) (bool, error) + // ReconcileUnpublishedPromptTurns repairs or accepts durable prompt + // reservations and response-delivery claims before startup admits new work. + // Accepted reservations retain a durable start-event outbox marker for the + // service to replay before it clears recovery metadata. + // Every production turn store must provide this recovery boundary; callers + // fail rather than skip it. + ReconcileUnpublishedPromptTurns(ctx context.Context) (int, error) + // ListTurnsPendingStartEvent returns accepted reservations whose durable + // start-event outbox marker still needs replay before startup admits work. + ListTurnsPendingStartEvent(ctx context.Context) ([]*models.Turn, error) // CreateTurnWithStepStamp creates turn atomically with the // workflow-step-at-start stamp: it reads the task's current step and // inserts the turn row in the same transaction, taking the same lock @@ -240,6 +254,29 @@ type TurnRepository interface { GetTurn(ctx context.Context, id string) (*models.Turn, error) GetActiveTurnBySessionID(ctx context.Context, sessionID string) (*models.Turn, error) UpdateTurn(ctx context.Context, turn *models.Turn) error + // PatchTurnMetadata merges fields into an active or completed turn while + // preserving unrelated metadata under the session's turn-write authority. + PatchTurnMetadata( + ctx context.Context, + sessionID, turnID string, + updates map[string]interface{}, + ) (bool, time.Time, error) + // UpdateActiveTurnMetadata merges updates and removes named keys only while + // the turn is active and belongs to sessionID. Implementations serialize + // this authority change with other current-turn decisions for the session. + UpdateActiveTurnMetadata( + ctx context.Context, + sessionID, turnID string, + updates map[string]interface{}, + removeKeys []string, + ) (bool, map[string]interface{}, time.Time, error) + // ClearTurnPromptDispatchMetadata removes reservation-only metadata from + // an active or completed turn after its start event has been accepted by + // the event bus. It preserves unrelated concurrent metadata. + ClearTurnPromptDispatchMetadata( + ctx context.Context, + sessionID, turnID string, + ) (bool, map[string]interface{}, time.Time, error) CompleteTurn(ctx context.Context, id string) error AbandonTurn(ctx context.Context, id string) error CompletePendingToolCallsForTurn(ctx context.Context, turnID string) (int64, error) diff --git a/apps/backend/internal/task/repository/sqlite/base.go b/apps/backend/internal/task/repository/sqlite/base.go index b790b8e512..ada9d13b8b 100644 --- a/apps/backend/internal/task/repository/sqlite/base.go +++ b/apps/backend/internal/task/repository/sqlite/base.go @@ -6,6 +6,7 @@ import ( "database/sql" "fmt" "sync" + "time" "github.com/jmoiron/sqlx" @@ -23,12 +24,22 @@ type Repository struct { queuePurgeMu sync.RWMutex queuePurger func(context.Context, string) queuePurgeNotify func(context.Context, string) + // clockNow is a test-only clock seam. Set it before any concurrent + // repository call; it carries no synchronization. + clockNow func() time.Time // failCutoverAfter is a test-only failpoint for the worktree ownership // cutover: when set to a cutover step name, the migration aborts at that // step so tests can prove rollback restores the pre-upgrade state. failCutoverAfter string } +func (r *Repository) nowUTC() time.Time { + if r.clockNow != nil { + return r.clockNow().UTC() + } + return time.Now().UTC() +} + // SetTaskQueuePurger registers the orchestrator-owned queue cleanup for // in-memory queues. SQLite queues are also purged in the task mutation // transaction; this callback keeps the explicitly ephemeral queue equivalent. diff --git a/apps/backend/internal/task/repository/sqlite/message.go b/apps/backend/internal/task/repository/sqlite/message.go index 0ae8a8bf86..5c78e2d164 100644 --- a/apps/backend/internal/task/repository/sqlite/message.go +++ b/apps/backend/internal/task/repository/sqlite/message.go @@ -50,14 +50,53 @@ func (r *Repository) CreateMessage(ctx context.Context, message *models.Message) metadataJSON = string(metadataBytes) } - _, err := r.db.ExecContext(ctx, r.db.Rebind(` + return r.insertMessageWithSessionLock(ctx, message, requestsInput, messageType, metadataJSON) +} + +func (r *Repository) insertMessageRow( + ctx context.Context, + execer taskSessionExecutor, + message *models.Message, + requestsInput int, + messageType, metadataJSON string, +) error { + _, err := execer.ExecContext(ctx, r.db.Rebind(` INSERT INTO task_session_messages (id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), message.ID, message.TaskSessionID, message.TaskID, message.TurnID, message.AuthorType, message.AuthorID, message.Content, requestsInput, messageType, metadataJSON, message.CreatedAt, message.UpdatedAt) - return err } +// insertMessageWithSessionLock serializes message insertion with rollback of +// the message's turn on PostgreSQL. SQLite's writer pool is configured with a +// single connection in db.OpenSQLite, which provides equivalent serialization. +// If SQLite ever allows concurrent writers, add the equivalent session lock. +func (r *Repository) insertMessageWithSessionLock( + ctx context.Context, + message *models.Message, + requestsInput int, + messageType, metadataJSON string, +) error { + if !dialect.IsPostgres(r.db.DriverName()) { + return r.insertMessageRow(ctx, r.db, message, requestsInput, messageType, metadataJSON) + } + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return fmt.Errorf("begin message creation: %w", err) + } + defer func() { _ = tx.Rollback() }() + if err := lockSessionTurnWrites(ctx, tx, r.db.DriverName(), message.TaskSessionID); err != nil { + return err + } + if err := r.insertMessageRow(ctx, tx, message, requestsInput, messageType, metadataJSON); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit message creation: %w", err) + } + return nil +} + // GetMessage retrieves a message by ID func (r *Repository) GetMessage(ctx context.Context, id string) (*models.Message, error) { message := &models.Message{} @@ -425,19 +464,34 @@ func (r *Repository) FindMessagesByPendingID(ctx context.Context, pendingID stri return result, err } -// FindPendingClarificationMessagesBySessionID returns every clarification_request -// message for the session whose metadata.status is still "pending". Used by the -// canceller as a fallback when the in-memory store entry has already been drained -// by a racing timeout path. -func (r *Repository) FindPendingClarificationMessagesBySessionID(ctx context.Context, sessionID string) ([]*models.Message, error) { +// FindActiveClarificationMessagesBySessionID returns pending clarification rows +// owned by the session's newest durable turn. Older pending rows remain history. +// The canceller still drains live legacy waiters before this lookup. Persisted +// rows without their schema-required turn are malformed and need explicit data +// cleanup; they never become current input authority. +func (r *Repository) FindActiveClarificationMessagesBySessionID(ctx context.Context, sessionID string) ([]*models.Message, error) { drv := r.ro.DriverName() query := fmt.Sprintf(` - SELECT id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at, updated_at - FROM task_session_messages - WHERE task_session_id = ? AND type = 'clarification_request' AND %s = 'pending' - ORDER BY created_at ASC - `, dialect.JSONExtract(drv, "metadata", "status")) - rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), sessionID) + WITH current_turn AS ( + SELECT turn_row.id + FROM task_session_turns turn_row + WHERE turn_row.task_session_id = ? + AND %s + ORDER BY turn_row.started_at DESC, turn_row.created_at DESC, turn_row.id DESC + LIMIT 1 + ) + SELECT m.id, m.task_session_id, m.task_id, m.turn_id, m.author_type, m.author_id, + m.content, m.requests_input, m.type, m.metadata, m.created_at, m.updated_at + FROM task_session_messages m + JOIN current_turn current ON current.id = m.turn_id + WHERE m.task_session_id = ? + AND m.type = 'clarification_request' + AND COALESCE(%s, '') IN ('', 'pending') + ORDER BY m.created_at ASC, m.id ASC + `, turnAuthorityPredicate(drv, "turn_row"), dialect.JSONExtract(drv, "m.metadata", "status")) + // First sessionID selects current-turn authority; second scopes messages so + // a malformed cross-session turn reference cannot leak another session's row. + rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), sessionID, sessionID) if err != nil { return nil, err } @@ -454,14 +508,11 @@ func (r *Repository) GetPendingActionsBySessionIDs(ctx context.Context, sessionI return result, nil } placeholders := make([]string, len(sessionIDs)) - args := make([]interface{}, 0, len(sessionIDs)*2) + args := make([]interface{}, 0, len(sessionIDs)) for i, id := range sessionIDs { placeholders[i] = "?" args = append(args, id) } - for _, id := range sessionIDs { - args = append(args, id) - } query := pendingActionsBySessionQuery(r.ro.DriverName(), placeholders) rows, err := r.ro.QueryxContext(ctx, r.ro.Rebind(query), args...) if err != nil { @@ -473,6 +524,9 @@ func (r *Repository) GetPendingActionsBySessionIDs(ctx context.Context, sessionI if err := rows.Scan(&sessionID, &action); err != nil { return nil, err } + // Clarification is the explicit per-session priority when malformed or + // transitional history contains both pending action kinds. Enforce it + // here so correctness never depends on UNION ALL row order. if action == string(models.TaskPendingActionClarification) { result[sessionID] = models.TaskPendingActionClarification continue @@ -490,31 +544,36 @@ func (r *Repository) GetPendingActionsBySessionIDs(ctx context.Context, sessionI func pendingActionsBySessionQuery(driverName string, placeholders []string) string { placeholderList := strings.Join(placeholders, ",") statusExpr := dialect.JSONExtract(driverName, "m.metadata", "status") - latestOrderExpr := pendingActionMessageOrder(driverName, "") permissionOrderExpr := pendingActionMessageOrder(driverName, "m") + // Durable turns are authoritative. A message without a matching turn is + // malformed under the schema foreign key and must not reactivate old input. + // Terminal sessions quarantine pending history even when best-effort expiry + // persistence failed. Both message CTEs inherit the requested-session + // boundary through their task_session_id and turn_id joins to current_turn. return fmt.Sprintf(` - WITH latest_message AS ( - SELECT task_session_id, turn_id + WITH current_turn AS ( + SELECT task_session_id, id AS turn_id FROM ( SELECT task_session_id, - turn_id, + id, ROW_NUMBER() OVER ( PARTITION BY task_session_id - ORDER BY created_at DESC, %s DESC + ORDER BY started_at DESC, created_at DESC, id DESC ) AS rn - FROM task_session_messages - WHERE task_session_id IN (%s) + FROM task_session_turns turn_row + WHERE turn_row.task_session_id IN (%s) + AND %s + AND %s ) ranked WHERE rn = 1 ), pending_clarifications AS ( SELECT DISTINCT m.task_session_id, 'clarification' AS action FROM task_session_messages m - JOIN latest_message latest - ON latest.task_session_id = m.task_session_id - AND latest.turn_id = m.turn_id - WHERE m.task_session_id IN (%s) - AND m.type = 'clarification_request' + JOIN current_turn current + ON current.task_session_id = m.task_session_id + AND current.turn_id = m.turn_id + WHERE m.type = 'clarification_request' AND COALESCE(%s, '') IN ('', 'pending') ), latest_permissions AS ( @@ -525,9 +584,9 @@ func pendingActionsBySessionQuery(driverName string, placeholders []string) stri ORDER BY m.created_at DESC, %s DESC ) AS rn FROM task_session_messages m - JOIN latest_message latest - ON latest.task_session_id = m.task_session_id - AND latest.turn_id = m.turn_id + JOIN current_turn current + ON current.task_session_id = m.task_session_id + AND current.turn_id = m.turn_id WHERE m.type = 'permission_request' ) SELECT task_session_id, action @@ -536,7 +595,8 @@ func pendingActionsBySessionQuery(driverName string, placeholders []string) stri SELECT task_session_id, 'permission' AS action FROM latest_permissions WHERE rn = 1 AND status IN ('', 'pending') - `, latestOrderExpr, placeholderList, placeholderList, statusExpr, statusExpr, permissionOrderExpr) + `, placeholderList, turnAuthorityPredicate(driverName, "turn_row"), + nonTerminalSessionPredicate("turn_row"), statusExpr, statusExpr, permissionOrderExpr) } func pendingActionMessageOrder(driverName string, qualifier string) string { diff --git a/apps/backend/internal/task/repository/sqlite/message_active_clarification_test.go b/apps/backend/internal/task/repository/sqlite/message_active_clarification_test.go new file mode 100644 index 0000000000..e33220e967 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/message_active_clarification_test.go @@ -0,0 +1,801 @@ +package sqlite + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/kandev/kandev/internal/task/models" +) + +func createClarificationBundleMessage( + t *testing.T, + repo *Repository, + id, taskID, sessionID, turnID, pendingID, questionID string, + createdAt time.Time, +) { + t.Helper() + if err := repo.CreateMessage(context.Background(), &models.Message{ + ID: id, + TaskSessionID: sessionID, + TaskID: taskID, + TurnID: turnID, + AuthorType: models.MessageAuthorAgent, + Type: models.MessageTypeClarificationRequest, + Metadata: map[string]interface{}{ + "pending_id": pendingID, + "question_id": questionID, + "status": "pending", + }, + CreatedAt: createdAt, + }); err != nil { + t.Fatalf("CreateMessage(%s): %v", id, err) + } +} + +func seedPendingActionSession(t *testing.T, repo *Repository, taskID, sessionID string) { + t.Helper() + ctx := context.Background() + if err := repo.CreateTask(ctx, &models.Task{ID: taskID, Title: taskID}); err != nil { + t.Fatalf("CreateTask(%s): %v", taskID, err) + } + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: sessionID, TaskID: taskID, State: models.TaskSessionStateWaitingForInput, + }); err != nil { + t.Fatalf("CreateTaskSession(%s): %v", sessionID, err) + } +} + +func createPendingActionTurn( + t *testing.T, + repo *Repository, + taskID, sessionID, turnID string, + startedAt, createdAt time.Time, +) { + t.Helper() + if err := repo.CreateTurn(context.Background(), &models.Turn{ + ID: turnID, TaskSessionID: sessionID, TaskID: taskID, + StartedAt: startedAt, CreatedAt: createdAt, + }); err != nil { + t.Fatalf("CreateTurn(%s): %v", turnID, err) + } +} + +func TestFindActiveClarificationMessagesBySessionIDUsesNewestDurableTurn(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + base := time.Date(2026, time.August, 14, 12, 0, 0, 0, time.UTC) + seedPendingActionSession(t, repo, "task-active", "session-active") + createPendingActionTurn(t, repo, "task-active", "session-active", "turn-older", base, base) + createPendingActionMessage(t, repo, "clarification-older", "task-active", "session-active", "turn-older", models.MessageTypeClarificationRequest, "pending", base) + createPendingActionTurn(t, repo, "task-active", "session-active", "turn-newer", base.Add(time.Minute), base.Add(time.Minute)) + createPendingActionMessage(t, repo, "ordinary-newer", "task-active", "session-active", "turn-newer", models.MessageTypeMessage, "", base.Add(time.Minute)) + + got, err := repo.FindActiveClarificationMessagesBySessionID(ctx, "session-active") + if err != nil { + t.Fatalf("FindActiveClarificationMessagesBySessionID: %v", err) + } + if len(got) != 0 { + t.Fatalf("active clarifications = %v, want none from older turn", messageIDs(got)) + } + actions, err := repo.GetPendingActionsBySessionIDs(ctx, []string{"session-active"}) + if err != nil { + t.Fatalf("GetPendingActionsBySessionIDs: %v", err) + } + if _, ok := actions["session-active"]; ok { + t.Fatalf("pending action reactivated from older turn: %#v", actions) + } + + if err := repo.DeleteMessage(ctx, "ordinary-newer"); err != nil { + t.Fatalf("DeleteMessage(newer): %v", err) + } + got, err = repo.FindActiveClarificationMessagesBySessionID(ctx, "session-active") + if err != nil { + t.Fatalf("FindActiveClarificationMessagesBySessionID after delete: %v", err) + } + if len(got) != 0 { + t.Fatalf("message deletion reactivated older clarification: %v", messageIDs(got)) + } + actions, err = repo.GetPendingActionsBySessionIDs(ctx, []string{"session-active"}) + if err != nil { + t.Fatalf("GetPendingActionsBySessionIDs after delete: %v", err) + } + if _, ok := actions["session-active"]; ok { + t.Fatalf("message deletion reactivated older pending action: %#v", actions) + } +} + +func TestPendingClarificationIgnoresEmptyUnpublishedSuccessor(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + base := time.Date(2026, time.August, 15, 18, 0, 0, 0, time.UTC) + const taskID = "task-unpublished" + const sessionID = "session-unpublished" + seedPendingActionSession(t, repo, taskID, sessionID) + createPendingActionTurn(t, repo, taskID, sessionID, "turn-clarification", base, base) + createPendingActionMessage(t, repo, "clarification-current", taskID, sessionID, "turn-clarification", models.MessageTypeClarificationRequest, "pending", base) + if err := repo.CreateTurn(ctx, &models.Turn{ + ID: "turn-unpublished", TaskSessionID: sessionID, TaskID: taskID, + StartedAt: base.Add(time.Minute), CreatedAt: base.Add(time.Minute), + Metadata: map[string]interface{}{models.TurnMetaKeyPromptDispatchPending: true}, + }); err != nil { + t.Fatalf("CreateTurn(unpublished): %v", err) + } + + active, err := repo.FindActiveClarificationMessagesBySessionID(ctx, sessionID) + if err != nil { + t.Fatalf("FindActiveClarificationMessagesBySessionID: %v", err) + } + if ids := messageIDs(active); len(ids) != 1 || ids[0] != "clarification-current" { + t.Fatalf("active clarification IDs = %v, want predecessor bundle", ids) + } + actions, err := repo.GetPendingActionsBySessionIDs(ctx, []string{sessionID}) + if err != nil { + t.Fatalf("GetPendingActionsBySessionIDs: %v", err) + } + if actions[sessionID] != models.TaskPendingActionClarification { + t.Fatalf("pending action = %q, want clarification", actions[sessionID]) + } + + createPendingActionMessage(t, repo, "successor-output", taskID, sessionID, "turn-unpublished", models.MessageTypeMessage, "", base.Add(2*time.Minute)) + actions, err = repo.GetPendingActionsBySessionIDs(ctx, []string{sessionID}) + if err != nil { + t.Fatalf("GetPendingActionsBySessionIDs after output: %v", err) + } + if _, ok := actions[sessionID]; ok { + t.Fatalf("message-backed successor did not supersede clarification: %#v", actions) + } +} + +func TestAmbiguousEmptySuccessorSupersedesClarification(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + base := time.Date(2026, time.August, 15, 18, 30, 0, 0, time.UTC) + const taskID = "task-accepted-successor" + const sessionID = "session-accepted-successor" + seedPendingActionSession(t, repo, taskID, sessionID) + createPendingActionTurn(t, repo, taskID, sessionID, "turn-clarification", base, base) + createPendingActionMessage( + t, repo, "clarification-predecessor", taskID, sessionID, "turn-clarification", + models.MessageTypeClarificationRequest, "pending", base, + ) + if err := repo.CreateTurn(ctx, &models.Turn{ + ID: "turn-accepted-empty", TaskSessionID: sessionID, TaskID: taskID, + StartedAt: base.Add(time.Minute), CreatedAt: base.Add(time.Minute), + Metadata: map[string]interface{}{ + models.TurnMetaKeyPromptDispatchPending: true, + models.TurnMetaKeyPromptDispatchAttempted: true, + }, + }); err != nil { + t.Fatalf("CreateTurn(accepted empty): %v", err) + } + + active, err := repo.FindActiveClarificationMessagesBySessionID(ctx, sessionID) + if err != nil { + t.Fatalf("FindActiveClarificationMessagesBySessionID: %v", err) + } + if len(active) != 0 { + t.Fatalf("accepted successor left predecessor active: %v", messageIDs(active)) + } + actions, err := repo.GetPendingActionsBySessionIDs(ctx, []string{sessionID}) + if err != nil { + t.Fatalf("GetPendingActionsBySessionIDs: %v", err) + } + if _, ok := actions[sessionID]; ok { + t.Fatalf("accepted successor left predecessor pending: %#v", actions) + } +} + +func TestFindActiveClarificationMessagesSupportsMissingStatusInCurrentTurn(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + base := time.Date(2026, time.August, 14, 13, 0, 0, 0, time.UTC) + seedPendingActionSession(t, repo, "task-status", "session-status") + createPendingActionTurn(t, repo, "task-status", "session-status", "turn-status", base, base) + createPendingActionMessage(t, repo, "clarification-missing", "task-status", "session-status", "turn-status", models.MessageTypeClarificationRequest, "", base) + createPendingActionMessage(t, repo, "clarification-pending", "task-status", "session-status", "turn-status", models.MessageTypeClarificationRequest, "pending", base.Add(time.Second)) + createPendingActionMessage(t, repo, "clarification-answered", "task-status", "session-status", "turn-status", models.MessageTypeClarificationRequest, "answered", base.Add(2*time.Second)) + + got, err := repo.FindActiveClarificationMessagesBySessionID(ctx, "session-status") + if err != nil { + t.Fatalf("FindActiveClarificationMessagesBySessionID: %v", err) + } + if ids := messageIDs(got); len(ids) != 2 || ids[0] != "clarification-missing" || ids[1] != "clarification-pending" { + t.Fatalf("active clarification IDs = %v, want missing and pending current-turn rows", ids) + } +} + +func TestGetPendingActionsIgnoresMessagesWithoutDurableTurn(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + seedPendingActionSession(t, repo, "task-orphan", "session-orphan") + if _, err := repo.db.ExecContext(ctx, "PRAGMA foreign_keys=OFF"); err != nil { + t.Fatalf("disable foreign keys for malformed-history fixture: %v", err) + } + t.Cleanup(func() { _, _ = repo.db.Exec("PRAGMA foreign_keys=ON") }) + createPendingActionMessage( + t, + repo, + "clarification-orphan", + "task-orphan", + "session-orphan", + "missing-turn", + models.MessageTypeClarificationRequest, + "pending", + time.Date(2026, time.August, 15, 14, 30, 0, 0, time.UTC), + ) + + actions, err := repo.GetPendingActionsBySessionIDs(ctx, []string{"session-orphan"}) + if err != nil { + t.Fatalf("GetPendingActionsBySessionIDs: %v", err) + } + if _, ok := actions["session-orphan"]; ok { + t.Fatalf("orphan message became authoritative without a durable turn: %#v", actions) + } +} + +func TestFindActiveClarificationMessagesUsesDeterministicTurnTieBreak(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + stamp := time.Date(2026, time.August, 14, 14, 0, 0, 0, time.UTC) + seedPendingActionSession(t, repo, "task-tie", "session-tie") + createPendingActionTurn(t, repo, "task-tie", "session-tie", "turn-a", stamp, stamp) + createPendingActionMessage(t, repo, "clarification-tie-old", "task-tie", "session-tie", "turn-a", models.MessageTypeClarificationRequest, "pending", stamp) + createPendingActionTurn(t, repo, "task-tie", "session-tie", "turn-z", stamp, stamp) + + got, err := repo.FindActiveClarificationMessagesBySessionID(ctx, "session-tie") + if err != nil { + t.Fatalf("FindActiveClarificationMessagesBySessionID: %v", err) + } + if len(got) != 0 { + t.Fatalf("turn id descending tie-break did not select turn-z: %v", messageIDs(got)) + } +} + +func TestCompleteActiveClarificationBundleClaimsExactlyOnce(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + base := time.Date(2026, time.August, 15, 12, 0, 0, 0, time.UTC) + seedPendingActionSession(t, repo, "task-claim", "session-claim") + createPendingActionTurn(t, repo, "task-claim", "session-claim", "turn-claim", base, base) + createClarificationBundleMessage(t, repo, "message-q1", "task-claim", "session-claim", "turn-claim", "pending-claim", "q1", base) + createClarificationBundleMessage(t, repo, "message-q2", "task-claim", "session-claim", "turn-claim", "pending-claim", "q2", base.Add(time.Nanosecond)) + + responses := map[string]interface{}{ + "q1": map[string]interface{}{"question_id": "q1", "custom_text": "first"}, + "q2": map[string]interface{}{"question_id": "q2", "custom_text": "second"}, + } + start := make(chan struct{}) + results := make(chan bool, 2) + errs := make(chan error, 2) + var wg sync.WaitGroup + for range 2 { + wg.Add(1) + go func() { + defer wg.Done() + <-start + _, claimed, err := repo.CompleteActiveClarificationBundle(ctx, "pending-claim", "answered", responses) + results <- claimed + errs <- err + }() + } + close(start) + wg.Wait() + close(results) + close(errs) + + claims := 0 + for claimed := range results { + if claimed { + claims++ + } + } + if claims != 1 { + t.Fatalf("successful claims = %d, want 1", claims) + } + for err := range errs { + if err != nil { + t.Fatalf("CompleteActiveClarificationBundle: %v", err) + } + } + for _, id := range []string{"message-q1", "message-q2"} { + message, err := repo.GetMessage(ctx, id) + if err != nil { + t.Fatalf("GetMessage(%s): %v", id, err) + } + if message.Metadata["status"] != "answered" { + t.Fatalf("%s status = %v, want answered", id, message.Metadata["status"]) + } + if message.Metadata["response"] == nil { + t.Fatalf("%s response was not persisted", id) + } + } +} + +func TestCompleteActiveClarificationBundleRejectsSupersededTurn(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + base := time.Date(2026, time.August, 15, 13, 0, 0, 0, time.UTC) + seedPendingActionSession(t, repo, "task-superseded", "session-superseded") + createPendingActionTurn(t, repo, "task-superseded", "session-superseded", "turn-old", base, base) + createClarificationBundleMessage(t, repo, "message-old", "task-superseded", "session-superseded", "turn-old", "pending-old", "q1", base) + createPendingActionTurn(t, repo, "task-superseded", "session-superseded", "turn-new", base.Add(time.Second), base.Add(time.Second)) + + _, claimed, err := repo.CompleteActiveClarificationBundle(ctx, "pending-old", "answered", map[string]interface{}{ + "q1": map[string]interface{}{"question_id": "q1"}, + }) + if err != nil { + t.Fatalf("CompleteActiveClarificationBundle: %v", err) + } + if claimed { + t.Fatal("superseded clarification bundle was claimed") + } + message, err := repo.GetMessage(ctx, "message-old") + if err != nil { + t.Fatalf("GetMessage: %v", err) + } + if message.Metadata["status"] != "pending" { + t.Fatalf("status = %v, want pending", message.Metadata["status"]) + } +} + +func TestCompleteActiveClarificationBundleRejectsTerminalSession(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + base := time.Date(2026, time.August, 15, 13, 30, 0, 0, time.UTC) + seedPendingActionSession(t, repo, "task-terminal", "session-terminal") + createPendingActionTurn(t, repo, "task-terminal", "session-terminal", "turn-terminal", base, base) + createClarificationBundleMessage( + t, repo, "message-terminal", "task-terminal", "session-terminal", "turn-terminal", + "pending-terminal", "q1", base, + ) + if err := repo.UpdateTaskSessionState( + ctx, "session-terminal", models.TaskSessionStateCancelled, "cancelled", + ); err != nil { + t.Fatalf("cancel session: %v", err) + } + + _, claimed, err := repo.CompleteActiveClarificationBundle( + ctx, + "pending-terminal", + "answered", + map[string]interface{}{"q1": map[string]interface{}{"question_id": "q1"}}, + ) + if err != nil { + t.Fatalf("CompleteActiveClarificationBundle: %v", err) + } + if claimed { + t.Fatal("terminal session clarification bundle was claimed") + } + message, err := repo.GetMessage(ctx, "message-terminal") + if err != nil { + t.Fatalf("GetMessage: %v", err) + } + if message.Metadata["status"] != "pending" { + t.Fatalf("status = %v, want pending quarantine", message.Metadata["status"]) + } + actions, err := repo.GetPendingActionsBySessionIDs(ctx, []string{"session-terminal"}) + if err != nil { + t.Fatalf("GetPendingActionsBySessionIDs: %v", err) + } + if _, ok := actions["session-terminal"]; ok { + t.Fatalf("terminal session retained actionable projection: %#v", actions) + } +} + +func TestCompleteActiveClarificationBundleClaimsDetachedCurrentTurn(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + base := time.Date(2026, time.August, 15, 13, 40, 0, 0, time.UTC) + seedPendingActionSession(t, repo, "task-detached-claim", "session-detached-claim") + createPendingActionTurn( + t, repo, "task-detached-claim", "session-detached-claim", "turn-detached-claim", base, base, + ) + createClarificationBundleMessage( + t, repo, "message-detached-claim", "task-detached-claim", "session-detached-claim", + "turn-detached-claim", "pending-detached-claim", "q1", base, + ) + detached, err := repo.DetachActiveClarificationMessagesBySessionID(ctx, "session-detached-claim") + if err != nil || len(detached) != 1 { + t.Fatalf("DetachActiveClarificationMessagesBySessionID = %d rows, %v; want 1, nil", len(detached), err) + } + + claimedMessages, claimed, err := repo.CompleteActiveClarificationBundle( + ctx, + "pending-detached-claim", + clarificationStatusAnswered, + map[string]interface{}{"q1": map[string]interface{}{"question_id": "q1"}}, + ) + if err != nil { + t.Fatalf("CompleteActiveClarificationBundle: %v", err) + } + if !claimed { + t.Fatal("detached current-turn clarification bundle was not claimed") + } + if ids := messageIDs(claimedMessages); len(ids) != 1 || ids[0] != "message-detached-claim" { + t.Fatalf("claimed messages = %v, want detached current-turn row", ids) + } + message, err := repo.GetMessage(ctx, "message-detached-claim") + if err != nil { + t.Fatalf("GetMessage: %v", err) + } + if message.Metadata["status"] != clarificationStatusAnswered || message.Metadata["agent_disconnected"] != true { + t.Fatalf("detached message metadata = %#v, want answered and disconnected", message.Metadata) + } +} + +func TestLoadClaimedClarificationBundleUsesCurrentTurn(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + base := time.Date(2026, time.August, 15, 14, 30, 0, 0, time.UTC) + seedPendingActionSession(t, repo, "task-load-claim", "session-load-claim") + createPendingActionTurn(t, repo, "task-load-claim", "session-load-claim", "turn-load-old", base, base) + createClarificationBundleMessage( + t, repo, "message-load-old", "task-load-claim", "session-load-claim", "turn-load-old", + "pending-load", "q-old", base, + ) + setClarificationMessageMetadata(t, repo, "message-load-old", func(metadata map[string]interface{}) { + metadata["status"] = clarificationStatusResponding + }) + createPendingActionTurn( + t, repo, "task-load-claim", "session-load-claim", "turn-load-current", + base.Add(time.Second), base.Add(time.Second), + ) + createClarificationBundleMessage( + t, repo, "message-load-current", "task-load-claim", "session-load-claim", "turn-load-current", + "pending-load", "q-current", base.Add(time.Second), + ) + setClarificationMessageMetadata(t, repo, "message-load-current", func(metadata map[string]interface{}) { + metadata["status"] = clarificationStatusResponding + }) + + tx, err := repo.db.BeginTxx(ctx, nil) + if err != nil { + t.Fatalf("BeginTxx: %v", err) + } + defer func() { _ = tx.Rollback() }() + loaded, err := repo.loadClaimedClarificationBundle( + ctx, + tx, + repo.db.DriverName(), + "pending-load", + ) + if err != nil { + t.Fatalf("loadClaimedClarificationBundle: %v", err) + } + if ids := messageIDs(loaded); len(ids) != 1 || ids[0] != "message-load-current" { + t.Fatalf("claimed messages = %v, want only current-turn row", ids) + } +} + +func TestCompleteActiveClarificationBundleRecoversMixedStatusBundle(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + base := time.Date(2026, time.August, 15, 15, 0, 0, 0, time.UTC) + seedPendingActionSession(t, repo, "task-mixed", "session-mixed") + createPendingActionTurn(t, repo, "task-mixed", "session-mixed", "turn-mixed", base, base) + createClarificationBundleMessage( + t, repo, "message-mixed-terminal", "task-mixed", "session-mixed", "turn-mixed", + "pending-mixed", "q1", base, + ) + createClarificationBundleMessage( + t, repo, "message-mixed-pending", "task-mixed", "session-mixed", "turn-mixed", + "pending-mixed", "q2", base.Add(time.Nanosecond), + ) + terminal, err := repo.GetMessage(ctx, "message-mixed-terminal") + if err != nil { + t.Fatalf("GetMessage terminal sibling: %v", err) + } + terminal.Metadata["status"] = "rejected" + if err := repo.UpdateMessage(ctx, terminal); err != nil { + t.Fatalf("UpdateMessage terminal sibling: %v", err) + } + + claimedMessages, claimed, err := repo.CompleteActiveClarificationBundle( + ctx, + "pending-mixed", + "answered", + map[string]interface{}{"q2": map[string]interface{}{"question_id": "q2"}}, + ) + if err != nil || !claimed { + t.Fatalf("complete mixed bundle: claimed=%v err=%v", claimed, err) + } + if ids := messageIDs(claimedMessages); len(ids) != 1 || ids[0] != "message-mixed-pending" { + t.Fatalf("claimed messages = %v, want only pending sibling", ids) + } + terminal, err = repo.GetMessage(ctx, "message-mixed-terminal") + if err != nil { + t.Fatalf("GetMessage preserved sibling: %v", err) + } + if terminal.Metadata["status"] != "rejected" { + t.Fatalf("terminal sibling status = %v, want rejected", terminal.Metadata["status"]) + } + completed, err := repo.GetMessage(ctx, "message-mixed-pending") + if err != nil { + t.Fatalf("GetMessage completed sibling: %v", err) + } + if completed.Metadata["status"] != "answered" { + t.Fatalf("pending sibling status = %v, want answered", completed.Metadata["status"]) + } + _, restored, err := repo.RestoreActiveClarificationBundle( + ctx, + "pending-mixed", + "answered", + claimedMessages, + ) + if err != nil || !restored { + t.Fatalf("restore claimed mixed rows: restored=%v err=%v", restored, err) + } + terminal, err = repo.GetMessage(ctx, "message-mixed-terminal") + if err != nil { + t.Fatalf("GetMessage terminal sibling after restore: %v", err) + } + if terminal.Metadata["status"] != "rejected" { + t.Fatalf("terminal sibling changed during restore: %#v", terminal.Metadata) + } + completed, err = repo.GetMessage(ctx, "message-mixed-pending") + if err != nil { + t.Fatalf("GetMessage restored sibling: %v", err) + } + if completed.Metadata["status"] != "pending" || completed.Metadata["response"] != nil { + t.Fatalf("restored sibling metadata = %#v, want pending without response", completed.Metadata) + } +} + +func TestRestoreActiveClarificationBundleAllowsRetryAfterDeliveryFailure(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + base := time.Date(2026, time.August, 15, 13, 30, 0, 0, time.UTC) + seedPendingActionSession(t, repo, "task-restore", "session-restore") + createPendingActionTurn(t, repo, "task-restore", "session-restore", "turn-restore", base, base) + createClarificationBundleMessage( + t, repo, "message-restore", "task-restore", "session-restore", "turn-restore", + "pending-restore", "q1", base, + ) + responses := map[string]interface{}{ + "q1": map[string]interface{}{"question_id": "q1", "custom_text": "continue"}, + } + + claimedMessages, claimed, err := repo.CompleteActiveClarificationBundle( + ctx, "pending-restore", "answered", responses, + ) + if err != nil || !claimed { + t.Fatalf("complete before restore: claimed=%v err=%v", claimed, err) + } + restoredMessages, restored, err := repo.RestoreActiveClarificationBundle( + ctx, + "pending-restore", + "answered", + claimedMessages, + ) + if err != nil || !restored { + t.Fatalf("restore: restored=%v err=%v", restored, err) + } + if len(restoredMessages) != 1 || restoredMessages[0].Metadata["status"] != "pending" { + t.Fatalf("returned restored messages = %#v, want one pending row", restoredMessages) + } + message, err := repo.GetMessage(ctx, "message-restore") + if err != nil { + t.Fatalf("GetMessage after restore: %v", err) + } + if message.Metadata["status"] != "pending" || message.Metadata["response"] != nil { + t.Fatalf("restored metadata = %#v, want pending without response", message.Metadata) + } + + _, claimed, err = repo.CompleteActiveClarificationBundle( + ctx, "pending-restore", "answered", responses, + ) + if err != nil || !claimed { + t.Fatalf("retry completion: claimed=%v err=%v", claimed, err) + } +} + +func TestRestoreDetachedClarificationClaimAfterTerminalTransitionStaysTerminal(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + base := time.Date(2026, time.August, 15, 13, 45, 0, 0, time.UTC) + seedPendingActionSession(t, repo, "task-terminal-restore", "session-terminal-restore") + createPendingActionTurn( + t, repo, "task-terminal-restore", "session-terminal-restore", "turn-terminal-restore", base, base, + ) + createClarificationBundleMessage( + t, repo, "message-terminal-restore", "task-terminal-restore", "session-terminal-restore", + "turn-terminal-restore", "pending-terminal-restore", "q1", base, + ) + detached, err := repo.DetachActiveClarificationMessagesBySessionID(ctx, "session-terminal-restore") + if err != nil || len(detached) != 1 { + t.Fatalf("detach before claim: detached=%d err=%v", len(detached), err) + } + claimedMessages, claimed, err := repo.CompleteActiveClarificationBundle( + ctx, + "pending-terminal-restore", + "answered", + map[string]interface{}{"q1": map[string]interface{}{"question_id": "q1"}}, + ) + if err != nil || !claimed { + t.Fatalf("complete before terminal transition: claimed=%v err=%v", claimed, err) + } + if err := repo.UpdateTaskSessionState( + ctx, "session-terminal-restore", models.TaskSessionStateCancelled, "cancelled", + ); err != nil { + t.Fatalf("cancel session: %v", err) + } + + _, restored, err := repo.RestoreActiveClarificationBundle( + ctx, "pending-terminal-restore", "answered", claimedMessages, + ) + if err != nil { + t.Fatalf("RestoreActiveClarificationBundle: %v", err) + } + if restored { + t.Fatal("terminal session clarification bundle was restored to pending") + } + message, err := repo.GetMessage(ctx, "message-terminal-restore") + if err != nil { + t.Fatalf("GetMessage: %v", err) + } + if message.Metadata["status"] != "answered" || message.Metadata["agent_disconnected"] != true { + t.Fatalf("metadata = %#v, want answered disconnected quarantine", message.Metadata) + } +} + +func TestRestoreClarificationMessagesLeavesInputUnchangedOnWriteFailure(t *testing.T) { + repo := newRepoForSessionTests(t) + tx, err := repo.db.BeginTxx(context.Background(), nil) + if err != nil { + t.Fatalf("BeginTxx: %v", err) + } + defer func() { _ = tx.Rollback() }() + message := &models.Message{ + ID: "missing-message", + Metadata: map[string]interface{}{ + "status": "answered", + "response": map[string]interface{}{"custom_text": "continue"}, + }, + } + + err = repo.restoreClarificationMessages( + context.Background(), + tx, + repo.db.DriverName(), + []*models.Message{message}, + "answered", + ) + if err == nil { + t.Fatal("restoreClarificationMessages succeeded for a missing row") + } + if message.Metadata["status"] != "answered" || message.Metadata["response"] == nil { + t.Fatalf("failed restore mutated input metadata: %#v", message.Metadata) + } +} + +func TestCompleteClaimedClarificationMessagesLeavesInputUnchangedOnWriteFailure(t *testing.T) { + repo := newRepoForSessionTests(t) + base := time.Date(2026, time.August, 15, 22, 0, 0, 0, time.UTC) + seedPendingActionSession(t, repo, "task-complete-input", "session-complete-input") + createPendingActionTurn( + t, repo, "task-complete-input", "session-complete-input", "turn-complete-input", base, base, + ) + createClarificationBundleMessage( + t, repo, "existing-message", "task-complete-input", "session-complete-input", + "turn-complete-input", "pending-complete-input", "question-1", base, + ) + setClarificationMessageMetadata(t, repo, "existing-message", func(metadata map[string]interface{}) { + metadata["status"] = clarificationStatusResponding + }) + tx, err := repo.db.BeginTxx(context.Background(), nil) + if err != nil { + t.Fatalf("BeginTxx: %v", err) + } + defer func() { _ = tx.Rollback() }() + existingMessage := &models.Message{ + ID: "existing-message", + Metadata: map[string]interface{}{ + "question_id": "question-1", + "status": clarificationStatusResponding, + }, + } + missingMessage := &models.Message{ + ID: "missing-message", + Metadata: map[string]interface{}{ + "question_id": "question-2", + "status": clarificationStatusResponding, + }, + } + + err = repo.completeClaimedClarificationMessages( + context.Background(), + tx, + repo.db.DriverName(), + []*models.Message{existingMessage, missingMessage}, + clarificationStatusAnswered, + map[string]interface{}{"question-1": "continue", "question-2": "continue"}, + ) + if err == nil { + t.Fatal("completeClaimedClarificationMessages succeeded for a missing row") + } + for _, message := range []*models.Message{existingMessage, missingMessage} { + if message.Metadata["status"] != clarificationStatusResponding || message.Metadata["response"] != nil { + t.Fatalf("failed completion mutated input metadata: %#v", message.Metadata) + } + } +} + +func TestClaimActiveClarificationBundleUsesRepositoryClock(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + base := time.Date(2026, time.August, 15, 22, 30, 0, 0, time.UTC) + seedPendingActionSession(t, repo, "task-claim-clock", "session-claim-clock") + createPendingActionTurn( + t, repo, "task-claim-clock", "session-claim-clock", "turn-claim-clock", base, base, + ) + createClarificationBundleMessage( + t, repo, "message-claim-clock", "task-claim-clock", "session-claim-clock", + "turn-claim-clock", "pending-claim-clock", "question-clock", base, + ) + claimAt := time.Date(2037, time.March, 4, 5, 6, 7, 0, time.UTC) + repo.clockNow = func() time.Time { return claimAt } + + tx, err := repo.db.BeginTxx(ctx, nil) + if err != nil { + t.Fatalf("BeginTxx: %v", err) + } + defer func() { _ = tx.Rollback() }() + claimed, err := repo.claimActiveClarificationBundle( + ctx, tx, repo.db.DriverName(), "pending-claim-clock", + ) + if err != nil || claimed != 1 { + t.Fatalf("claimActiveClarificationBundle = %d, %v; want 1, nil", claimed, err) + } + var updatedAt time.Time + if err := tx.GetContext( + ctx, + &updatedAt, + repo.db.Rebind(`SELECT updated_at FROM task_session_messages WHERE id = ?`), + "message-claim-clock", + ); err != nil { + t.Fatalf("load claimed timestamp: %v", err) + } + if !updatedAt.Equal(claimAt) { + t.Fatalf("claimed updated_at = %v, want repository time %v", updatedAt, claimAt) + } +} + +func TestRestoreActiveClarificationBundleDoesNotReactivateSupersededTurn(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + base := time.Date(2026, time.August, 15, 13, 45, 0, 0, time.UTC) + seedPendingActionSession(t, repo, "task-restore-old", "session-restore-old") + createPendingActionTurn(t, repo, "task-restore-old", "session-restore-old", "turn-restore-old", base, base) + createClarificationBundleMessage( + t, repo, "message-restore-old", "task-restore-old", "session-restore-old", "turn-restore-old", + "pending-restore-old", "q1", base, + ) + claimedMessages, claimed, err := repo.CompleteActiveClarificationBundle( + ctx, + "pending-restore-old", + "answered", + map[string]interface{}{"q1": map[string]interface{}{"question_id": "q1"}}, + ) + if err != nil || !claimed { + t.Fatalf("complete before supersession: claimed=%v err=%v", claimed, err) + } + createPendingActionTurn( + t, repo, "task-restore-old", "session-restore-old", "turn-restore-new", + base.Add(time.Second), base.Add(time.Second), + ) + + _, restored, err := repo.RestoreActiveClarificationBundle( + ctx, + "pending-restore-old", + "answered", + claimedMessages, + ) + if err != nil { + t.Fatalf("RestoreActiveClarificationBundle: %v", err) + } + if restored { + t.Fatal("superseded terminal bundle was restored") + } +} diff --git a/apps/backend/internal/task/repository/sqlite/message_clarification_delivery.go b/apps/backend/internal/task/repository/sqlite/message_clarification_delivery.go new file mode 100644 index 0000000000..f92ede5f0b --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/message_clarification_delivery.go @@ -0,0 +1,272 @@ +package sqlite + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "maps" + + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/db/dialect" + "github.com/kandev/kandev/internal/task/models" +) + +// FinalizeClarificationResponseDelivery clears the recovery intent from an +// exact terminal claim after its response has reached either the live waiter +// or the durable detached-resume boundary. +func (r *Repository) FinalizeClarificationResponseDelivery( + ctx context.Context, + pendingID, terminalStatus string, + claimedMessages []*models.Message, +) ([]*models.Message, bool, error) { + if terminalStatus != clarificationStatusAnswered && terminalStatus != clarificationStatusRejected { + return nil, false, fmt.Errorf("invalid clarification terminal status %q", terminalStatus) + } + claimIDs, err := clarificationClaimIDs(claimedMessages) + if err != nil { + return nil, false, err + } + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return nil, false, err + } + defer func() { _ = tx.Rollback() }() + + drv := r.db.DriverName() + if err := r.lockClarificationBundleTurnWrites(ctx, tx, drv, pendingID); err != nil { + return nil, false, err + } + messages, err := r.loadPendingClarificationResponseDeliveryBundle(ctx, tx, drv, pendingID) + if err != nil { + return nil, false, err + } + if len(messages) != len(claimIDs) { + return nil, false, nil + } + for _, message := range messages { + if _, claimed := claimIDs[message.ID]; !claimed { + return nil, false, nil + } + if status, _ := message.Metadata["status"].(string); status != terminalStatus { + return nil, false, nil + } + } + if err := r.clearClarificationResponseDeliveryMarkers(ctx, tx, drv, messages, terminalStatus); err != nil { + return nil, false, err + } + if err := tx.Commit(); err != nil { + return nil, false, fmt.Errorf("commit clarification response delivery: %w", err) + } + return messages, true, nil +} + +func (r *Repository) reconcilePendingClarificationResponseDeliveries(ctx context.Context) (int, error) { + pendingIDs, err := r.listPendingClarificationResponseDeliveries(ctx) + if err != nil { + return 0, err + } + reconciled := 0 + var reconcileErrs []error + for _, pendingID := range pendingIDs { + changed, reconcileErr := r.reconcilePendingClarificationResponseDelivery(ctx, pendingID) + if reconcileErr != nil { + reconcileErrs = append(reconcileErrs, fmt.Errorf( + "reconcile pending clarification response delivery %s: %w", + pendingID, + reconcileErr, + )) + continue + } + if changed { + reconciled++ + } + } + return reconciled, errors.Join(reconcileErrs...) +} + +func (r *Repository) listPendingClarificationResponseDeliveries(ctx context.Context) ([]string, error) { + drv := r.ro.DriverName() + pendingIDExpr := dialect.JSONExtract(drv, "m.metadata", "pending_id") + reservedPendingIDExpr := dialect.JSONExtract( + drv, + "turn_row.metadata", + models.TurnMetaKeyPromptDispatchClarificationPendingID, + ) + query := fmt.Sprintf(` + SELECT DISTINCT %s + FROM task_session_messages m + WHERE m.type = 'clarification_request' + AND %s + AND NOT EXISTS ( + SELECT 1 + FROM task_session_turns turn_row + WHERE %s + AND %s = %s + ) + ORDER BY %s + `, pendingIDExpr, + clarificationResponseDeliveryPendingPredicate(drv, "m"), + turnDispatchPendingPredicate(drv, "turn_row"), + reservedPendingIDExpr, + pendingIDExpr, + pendingIDExpr, + ) + var pendingIDs []string + if err := r.ro.SelectContext(ctx, &pendingIDs, r.ro.Rebind(query)); err != nil { + return nil, fmt.Errorf("list pending clarification response deliveries: %w", err) + } + for _, pendingID := range pendingIDs { + if pendingID == "" { + return nil, errors.New("pending clarification response delivery is missing pending_id") + } + } + return pendingIDs, nil +} + +func (r *Repository) reconcilePendingClarificationResponseDelivery( + ctx context.Context, + pendingID string, +) (bool, error) { + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return false, fmt.Errorf("begin clarification response delivery recovery: %w", err) + } + defer func() { _ = tx.Rollback() }() + drv := r.db.DriverName() + if err := r.lockClarificationBundleTurnWrites(ctx, tx, drv, pendingID); err != nil { + return false, err + } + messages, err := r.loadPendingClarificationResponseDeliveryBundle(ctx, tx, drv, pendingID) + if err != nil { + return false, err + } + if len(messages) == 0 { + return false, nil + } + terminalStatus, err := clarificationDeliveryTerminalStatus(messages) + if err != nil { + return false, err + } + + restorableMessages, err := r.loadRestorableClarificationBundle(ctx, tx, drv, pendingID) + if err != nil { + return false, err + } + canRestore := clarificationDeliveryCanRestore(messages, restorableMessages) + if canRestore { + err = r.restoreClarificationMessages(ctx, tx, drv, messages, terminalStatus) + } else { + // A terminal session or a newer durable turn makes the old response + // historical. Preserve its terminal status but retire the stale intent. + err = r.clearClarificationResponseDeliveryMarkers(ctx, tx, drv, messages, terminalStatus) + } + if err != nil { + return false, err + } + if err := tx.Commit(); err != nil { + return false, fmt.Errorf("commit clarification response delivery recovery: %w", err) + } + return true, nil +} + +func clarificationDeliveryTerminalStatus(messages []*models.Message) (string, error) { + terminalStatus, _ := messages[0].Metadata["status"].(string) + if terminalStatus != clarificationStatusAnswered && terminalStatus != clarificationStatusRejected { + return "", fmt.Errorf("delivery claim has invalid terminal status %q", terminalStatus) + } + for _, message := range messages[1:] { + if status, _ := message.Metadata["status"].(string); status != terminalStatus { + return "", fmt.Errorf("delivery claim mixes terminal statuses %q and %q", terminalStatus, status) + } + } + return terminalStatus, nil +} + +func clarificationDeliveryCanRestore( + deliveryMessages, restorableMessages []*models.Message, +) bool { + if len(restorableMessages) == 0 { + return false + } + restorableByID := make(map[string]struct{}, len(restorableMessages)) + for _, message := range restorableMessages { + restorableByID[message.ID] = struct{}{} + } + for _, message := range deliveryMessages { + if _, ok := restorableByID[message.ID]; !ok { + return false + } + } + return true +} + +func (r *Repository) loadPendingClarificationResponseDeliveryBundle( + ctx context.Context, + tx *sqlx.Tx, + drv, pendingID string, +) ([]*models.Message, error) { + pendingIDExpr := dialect.JSONExtract(drv, "m.metadata", "pending_id") + query := fmt.Sprintf(` + SELECT m.id, m.task_session_id, m.task_id, m.turn_id, m.author_type, m.author_id, + m.content, m.requests_input, m.type, m.metadata, m.created_at, m.updated_at + FROM task_session_messages m + WHERE m.type = 'clarification_request' + AND %s = ? + AND %s + ORDER BY m.created_at ASC, m.id ASC + `, pendingIDExpr, clarificationResponseDeliveryPendingPredicate(drv, "m")) + rows, err := tx.QueryxContext(ctx, r.db.Rebind(query), pendingID) + if err != nil { + return nil, fmt.Errorf("load pending clarification response delivery: %w", err) + } + defer func() { _ = rows.Close() }() + messages, _, scanErr := scanMessageRows(rows, 0) + if scanErr != nil { + return nil, fmt.Errorf("scan pending clarification response delivery: %w", scanErr) + } + return messages, nil +} + +func (r *Repository) clearClarificationResponseDeliveryMarkers( + ctx context.Context, + tx *sqlx.Tx, + drv string, + messages []*models.Message, + terminalStatus string, +) error { + updatedAt := r.nowUTC() + statusExpr := dialect.JSONExtract(drv, "metadata", "status") + query := r.db.Rebind(fmt.Sprintf(` + UPDATE task_session_messages + SET metadata = ?, updated_at = ? + WHERE id = ? AND %s = ? AND %s + `, statusExpr, clarificationResponseDeliveryPendingPredicate(drv, "task_session_messages"))) + finalizedMetadata := make([]map[string]interface{}, len(messages)) + for i, message := range messages { + metadata := maps.Clone(message.Metadata) + delete(metadata, clarificationResponseDeliveryPendingKey) + raw, marshalErr := json.Marshal(metadata) + if marshalErr != nil { + return fmt.Errorf("marshal clarification message %s for delivery finalization: %w", message.ID, marshalErr) + } + result, updateErr := tx.ExecContext(ctx, query, string(raw), updatedAt, message.ID, terminalStatus) + if updateErr != nil { + return fmt.Errorf("finalize clarification response delivery for message %s: %w", message.ID, updateErr) + } + rows, rowsErr := result.RowsAffected() + if rowsErr != nil { + return fmt.Errorf("count finalized clarification response delivery for message %s: %w", message.ID, rowsErr) + } + if rows != 1 { + return fmt.Errorf("clarification message %s lost its delivery intent", message.ID) + } + finalizedMetadata[i] = metadata + } + for i, message := range messages { + message.Metadata = finalizedMetadata[i] + message.UpdatedAt = updatedAt + } + return nil +} diff --git a/apps/backend/internal/task/repository/sqlite/message_clarification_delivery_test.go b/apps/backend/internal/task/repository/sqlite/message_clarification_delivery_test.go new file mode 100644 index 0000000000..b3aebc6de7 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/message_clarification_delivery_test.go @@ -0,0 +1,194 @@ +package sqlite + +import ( + "context" + "testing" + "time" +) + +func TestCompleteActiveClarificationBundlePersistsRecoverableDeliveryIntent(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + base := time.Date(2026, time.August, 16, 16, 20, 0, 0, time.UTC) + seedPendingActionSession(t, repo, "task-delivery-intent", "session-delivery-intent") + createPendingActionTurn( + t, repo, "task-delivery-intent", "session-delivery-intent", "turn-delivery-intent", base, base, + ) + createClarificationBundleMessage( + t, repo, "message-delivery-intent", "task-delivery-intent", "session-delivery-intent", + "turn-delivery-intent", "pending-delivery-intent", "q1", base, + ) + + claimedMessages, claimed, err := repo.CompleteActiveClarificationBundle( + ctx, + "pending-delivery-intent", + clarificationStatusAnswered, + map[string]interface{}{"q1": map[string]interface{}{"question_id": "q1", "custom_text": "continue"}}, + ) + if err != nil || !claimed { + t.Fatalf("CompleteActiveClarificationBundle = claimed %v, %v; want true, nil", claimed, err) + } + if got := claimedMessages[0].Metadata[clarificationResponseDeliveryPendingKey]; got != true { + t.Fatalf("delivery intent = %v, want true", got) + } + + reconciled, err := repo.ReconcileUnpublishedPromptTurns(ctx) + if err != nil || reconciled != 1 { + t.Fatalf("ReconcileUnpublishedPromptTurns = %d, %v; want 1, nil", reconciled, err) + } + message, err := repo.GetMessage(ctx, "message-delivery-intent") + if err != nil { + t.Fatalf("GetMessage: %v", err) + } + if message.Metadata["status"] != clarificationStatusPending || message.Metadata["response"] != nil { + t.Fatalf("recovered metadata = %#v, want pending without response", message.Metadata) + } + if marker := message.Metadata[clarificationResponseDeliveryPendingKey]; marker != nil { + t.Fatalf("recovered delivery marker = %v, want absent", marker) + } +} + +func TestFinalizeClarificationResponseDeliveryPreventsRecovery(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + base := time.Date(2026, time.August, 16, 16, 25, 0, 0, time.UTC) + seedPendingActionSession(t, repo, "task-delivery-finalize", "session-delivery-finalize") + createPendingActionTurn( + t, repo, "task-delivery-finalize", "session-delivery-finalize", "turn-delivery-finalize", base, base, + ) + createClarificationBundleMessage( + t, repo, "message-delivery-finalize", "task-delivery-finalize", "session-delivery-finalize", + "turn-delivery-finalize", "pending-delivery-finalize", "q1", base, + ) + + claimedMessages, claimed, err := repo.CompleteActiveClarificationBundle( + ctx, + "pending-delivery-finalize", + clarificationStatusAnswered, + map[string]interface{}{"q1": map[string]interface{}{"question_id": "q1", "custom_text": "continue"}}, + ) + if err != nil || !claimed { + t.Fatalf("CompleteActiveClarificationBundle = claimed %v, %v; want true, nil", claimed, err) + } + finalizedMessages, finalized, err := repo.FinalizeClarificationResponseDelivery( + ctx, + "pending-delivery-finalize", + clarificationStatusAnswered, + claimedMessages, + ) + if err != nil || !finalized { + t.Fatalf("FinalizeClarificationResponseDelivery = finalized %v, %v; want true, nil", finalized, err) + } + if marker := finalizedMessages[0].Metadata[clarificationResponseDeliveryPendingKey]; marker != nil { + t.Fatalf("finalized delivery marker = %v, want absent", marker) + } + _, restored, err := repo.RestoreActiveClarificationBundle( + ctx, + "pending-delivery-finalize", + clarificationStatusAnswered, + claimedMessages, + ) + if err != nil || restored { + t.Fatalf("RestoreActiveClarificationBundle after finalize = restored %v, %v; want false, nil", restored, err) + } + + reconciled, err := repo.ReconcileUnpublishedPromptTurns(ctx) + if err != nil || reconciled != 0 { + t.Fatalf("ReconcileUnpublishedPromptTurns = %d, %v; want 0, nil", reconciled, err) + } + message, err := repo.GetMessage(ctx, "message-delivery-finalize") + if err != nil { + t.Fatalf("GetMessage: %v", err) + } + if message.Metadata["status"] != clarificationStatusAnswered || message.Metadata["response"] == nil { + t.Fatalf("finalized metadata = %#v, want answered with response", message.Metadata) + } +} + +func TestClarificationDeliveryRecoveryRestoresDetachedBundle(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + base := time.Date(2026, time.August, 16, 17, 0, 0, 0, time.UTC) + seedPendingActionSession(t, repo, "task-detached-recovery", "session-detached-recovery") + createPendingActionTurn( + t, repo, "task-detached-recovery", "session-detached-recovery", + "turn-detached-recovery", base, base, + ) + createClarificationBundleMessage( + t, repo, "message-detached-recovery", "task-detached-recovery", + "session-detached-recovery", "turn-detached-recovery", + "pending-detached-recovery", "q1", base, + ) + setClarificationMessageMetadata(t, repo, "message-detached-recovery", func(metadata map[string]interface{}) { + metadata["agent_disconnected"] = true + }) + _, claimed, err := repo.CompleteActiveClarificationBundle( + ctx, + "pending-detached-recovery", + clarificationStatusRejected, + nil, + ) + if err != nil || !claimed { + t.Fatalf("claim = %v, %v; want true, nil", claimed, err) + } + + reconciled, err := repo.ReconcileUnpublishedPromptTurns(ctx) + if err != nil || reconciled != 1 { + t.Fatalf("ReconcileUnpublishedPromptTurns = %d, %v; want 1, nil", reconciled, err) + } + message, err := repo.GetMessage(ctx, "message-detached-recovery") + if err != nil { + t.Fatalf("GetMessage: %v", err) + } + if message.Metadata["status"] != clarificationStatusPending { + t.Fatalf("recovered status = %v, want pending", message.Metadata["status"]) + } + if disconnected, _ := message.Metadata["agent_disconnected"].(bool); !disconnected { + t.Fatalf("agent_disconnected = %v, want true after restore", message.Metadata["agent_disconnected"]) + } + if _, ok := message.Metadata[clarificationResponseDeliveryPendingKey]; ok { + t.Fatal("delivery marker present after restore") + } +} + +func TestClarificationDeliveryRecoveryDoesNotReactivateSupersededTurn(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + base := time.Date(2026, time.August, 16, 16, 30, 0, 0, time.UTC) + seedPendingActionSession(t, repo, "task-delivery-superseded", "session-delivery-superseded") + createPendingActionTurn( + t, repo, "task-delivery-superseded", "session-delivery-superseded", "turn-delivery-old", base, base, + ) + createClarificationBundleMessage( + t, repo, "message-delivery-superseded", "task-delivery-superseded", + "session-delivery-superseded", "turn-delivery-old", "pending-delivery-superseded", "q1", base, + ) + _, claimed, err := repo.CompleteActiveClarificationBundle( + ctx, + "pending-delivery-superseded", + clarificationStatusAnswered, + map[string]interface{}{"q1": map[string]interface{}{"question_id": "q1", "custom_text": "continue"}}, + ) + if err != nil || !claimed { + t.Fatalf("CompleteActiveClarificationBundle = claimed %v, %v; want true, nil", claimed, err) + } + createPendingActionTurn( + t, repo, "task-delivery-superseded", "session-delivery-superseded", "turn-delivery-new", + base.Add(time.Minute), base.Add(time.Minute), + ) + + reconciled, err := repo.ReconcileUnpublishedPromptTurns(ctx) + if err != nil || reconciled != 1 { + t.Fatalf("ReconcileUnpublishedPromptTurns = %d, %v; want 1, nil", reconciled, err) + } + message, err := repo.GetMessage(ctx, "message-delivery-superseded") + if err != nil { + t.Fatalf("GetMessage: %v", err) + } + if message.Metadata["status"] != clarificationStatusAnswered || message.Metadata["response"] == nil { + t.Fatalf("superseded delivery metadata = %#v, want terminal answer", message.Metadata) + } + if marker := message.Metadata[clarificationResponseDeliveryPendingKey]; marker != nil { + t.Fatalf("superseded delivery marker = %v, want absent", marker) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/message_clarification_race_test.go b/apps/backend/internal/task/repository/sqlite/message_clarification_race_test.go new file mode 100644 index 0000000000..d6aba87c46 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/message_clarification_race_test.go @@ -0,0 +1,282 @@ +package sqlite + +import ( + "context" + "testing" + "time" +) + +func TestDetachActiveClarificationMessagesClaimsOnlyCurrentPendingRows(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + base := time.Date(2026, time.August, 15, 15, 30, 0, 0, time.UTC) + seedPendingActionSession(t, repo, "task-detach", "session-detach") + createPendingActionTurn(t, repo, "task-detach", "session-detach", "turn-old", base, base) + createClarificationBundleMessage( + t, repo, "message-old", "task-detach", "session-detach", "turn-old", + "pending-old", "q-old", base, + ) + createPendingActionTurn( + t, repo, "task-detach", "session-detach", "turn-current", + base.Add(time.Minute), base.Add(time.Minute), + ) + createClarificationBundleMessage( + t, repo, "message-current", "task-detach", "session-detach", "turn-current", + "pending-current", "q-current", base.Add(time.Minute), + ) + createClarificationBundleMessage( + t, repo, "message-terminal", "task-detach", "session-detach", "turn-current", + "pending-terminal", "q-terminal", base.Add(time.Minute+time.Second), + ) + setClarificationMessageMetadata(t, repo, "message-terminal", func(metadata map[string]interface{}) { + metadata["status"] = "answered" + }) + createClarificationBundleMessage( + t, repo, "message-detached", "task-detach", "session-detach", "turn-current", + "pending-detached", "q-detached", base.Add(time.Minute+2*time.Second), + ) + setClarificationMessageMetadata(t, repo, "message-detached", func(metadata map[string]interface{}) { + metadata["agent_disconnected"] = true + }) + mutationTime := base.Add(2*time.Hour + 123*time.Millisecond) + repo.clockNow = func() time.Time { return mutationTime } + previousUpdatedAt := setMessageUpdatedAtBeforeMutation(t, repo, "message-current", mutationTime) + + updated, err := repo.DetachActiveClarificationMessagesBySessionID(ctx, "session-detach") + if err != nil { + t.Fatalf("DetachActiveClarificationMessagesBySessionID: %v", err) + } + if ids := messageIDs(updated); len(ids) != 1 || ids[0] != "message-current" { + t.Fatalf("detached message IDs = %v, want only current pending row", ids) + } + if !updated[0].UpdatedAt.After(previousUpdatedAt) { + t.Fatalf("detached updated_at = %v, want after prior Go timestamp %v", updated[0].UpdatedAt, previousUpdatedAt) + } + if !updated[0].UpdatedAt.Equal(mutationTime) { + t.Fatalf("detached updated_at = %v, want injected mutation time %v", updated[0].UpdatedAt, mutationTime) + } + current, err := repo.GetMessage(ctx, "message-current") + if err != nil { + t.Fatalf("GetMessage(current): %v", err) + } + if detached, _ := current.Metadata["agent_disconnected"].(bool); !detached { + t.Fatalf("current message metadata = %#v, want agent_disconnected=true", current.Metadata) + } + old, err := repo.GetMessage(ctx, "message-old") + if err != nil { + t.Fatalf("GetMessage(old): %v", err) + } + if _, detached := old.Metadata["agent_disconnected"]; detached { + t.Fatalf("superseded message was detached: %#v", old.Metadata) + } + repeated, err := repo.DetachActiveClarificationMessagesBySessionID(ctx, "session-detach") + if err != nil { + t.Fatalf("repeated detach: %v", err) + } + if len(repeated) != 0 { + t.Fatalf("repeated detach changed rows: %v", messageIDs(repeated)) + } +} + +func TestDetachActiveClarificationMessagesTreatsTruthyStringsAsDetached(t *testing.T) { + for _, flag := range []string{"true", "1"} { + t.Run(flag, func(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + createdAt := time.Date(2026, time.August, 15, 15, 40, 0, 0, time.UTC) + seedPendingActionSession(t, repo, "task-string-detach", "session-string-detach") + createPendingActionTurn( + t, repo, "task-string-detach", "session-string-detach", "turn-string-detach", + createdAt, createdAt, + ) + createClarificationBundleMessage( + t, repo, "message-string-detach", "task-string-detach", "session-string-detach", + "turn-string-detach", "pending-string-detach", "q-string-detach", createdAt, + ) + setClarificationMessageMetadata(t, repo, "message-string-detach", func(metadata map[string]interface{}) { + metadata["agent_disconnected"] = flag + }) + + updated, err := repo.DetachActiveClarificationMessagesBySessionID( + ctx, + "session-string-detach", + ) + if err != nil { + t.Fatalf("DetachActiveClarificationMessagesBySessionID: %v", err) + } + if len(updated) != 0 { + t.Fatalf("detached message IDs = %v, want none", messageIDs(updated)) + } + }) + } +} + +func TestExpireActiveClarificationMessagesClaimsOnlyCurrentPendingRows(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + base := time.Date(2026, time.August, 15, 15, 45, 0, 0, time.UTC) + seedPendingActionSession(t, repo, "task-expire", "session-expire") + createPendingActionTurn(t, repo, "task-expire", "session-expire", "turn-old", base, base) + createClarificationBundleMessage( + t, repo, "message-old", "task-expire", "session-expire", "turn-old", + "pending-old", "q-old", base, + ) + createPendingActionTurn( + t, repo, "task-expire", "session-expire", "turn-current", + base.Add(time.Minute), base.Add(time.Minute), + ) + createClarificationBundleMessage( + t, repo, "message-current", "task-expire", "session-expire", "turn-current", + "pending-current", "q-current", base.Add(time.Minute), + ) + createClarificationBundleMessage( + t, repo, "message-other-pending", "task-expire", "session-expire", "turn-current", + "pending-other", "q-other", base.Add(time.Minute+500*time.Millisecond), + ) + createClarificationBundleMessage( + t, repo, "message-answered", "task-expire", "session-expire", "turn-current", + "pending-answered", "q-answered", base.Add(time.Minute+time.Second), + ) + setClarificationMessageMetadata(t, repo, "message-answered", func(metadata map[string]interface{}) { + metadata["status"] = "answered" + }) + mutationTime := base.Add(2*time.Hour + 456*time.Millisecond) + repo.clockNow = func() time.Time { return mutationTime } + previousUpdatedAt := setMessageUpdatedAtBeforeMutation(t, repo, "message-current", mutationTime) + + updated, err := repo.ExpireActiveClarificationBundle(ctx, "session-expire", "pending-current") + if err != nil { + t.Fatalf("ExpireActiveClarificationBundle: %v", err) + } + if ids := messageIDs(updated); len(ids) != 1 || ids[0] != "message-current" { + t.Fatalf("expired message IDs = %v, want only current pending row", ids) + } + if !updated[0].UpdatedAt.After(previousUpdatedAt) { + t.Fatalf("expired updated_at = %v, want after prior Go timestamp %v", updated[0].UpdatedAt, previousUpdatedAt) + } + if !updated[0].UpdatedAt.Equal(mutationTime) { + t.Fatalf("expired updated_at = %v, want injected mutation time %v", updated[0].UpdatedAt, mutationTime) + } + current, err := repo.GetMessage(ctx, "message-current") + if err != nil { + t.Fatalf("GetMessage(current): %v", err) + } + if current.Metadata["status"] != "expired" || current.Metadata["agent_disconnected"] != true { + t.Fatalf("current message metadata = %#v, want expired and disconnected", current.Metadata) + } + answered, err := repo.GetMessage(ctx, "message-answered") + if err != nil { + t.Fatalf("GetMessage(answered): %v", err) + } + if answered.Metadata["status"] != "answered" { + t.Fatalf("answered message status = %v, want answered", answered.Metadata["status"]) + } + other, err := repo.GetMessage(ctx, "message-other-pending") + if err != nil { + t.Fatalf("GetMessage(other pending): %v", err) + } + if other.Metadata["status"] != "pending" { + t.Fatalf("other bundle status = %v, want pending", other.Metadata["status"]) + } + old, err := repo.GetMessage(ctx, "message-old") + if err != nil { + t.Fatalf("GetMessage(old): %v", err) + } + if old.Metadata["status"] != "pending" { + t.Fatalf("superseded message status = %v, want pending", old.Metadata["status"]) + } + repeated, err := repo.ExpireActiveClarificationBundle(ctx, "session-expire", "pending-current") + if err != nil { + t.Fatalf("repeated expiry: %v", err) + } + if len(repeated) != 0 { + t.Fatalf("repeated expiry changed rows: %v", messageIDs(repeated)) + } +} + +// setMessageUpdatedAtBeforeMutation seeds the immediate predecessor to a +// controlled repository clock so timestamp ordering needs no wall-clock wait. +func setMessageUpdatedAtBeforeMutation( + t *testing.T, + repo *Repository, + messageID string, + mutationTime time.Time, +) time.Time { + t.Helper() + updatedAt := mutationTime.Add(-time.Nanosecond) + if _, err := repo.db.Exec( + repo.db.Rebind(`UPDATE task_session_messages SET updated_at = ? WHERE id = ?`), + updatedAt, + messageID, + ); err != nil { + t.Fatalf("seed message updated_at: %v", err) + } + return updatedAt +} + +func TestRestoreClarificationMessagesRechecksCurrentTurnAtUpdate(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + base := time.Date(2026, time.August, 15, 16, 0, 0, 0, time.UTC) + seedPendingActionSession(t, repo, "task-restore-race", "session-restore-race") + createPendingActionTurn( + t, repo, "task-restore-race", "session-restore-race", "turn-restore-race", base, base, + ) + createClarificationBundleMessage( + t, repo, "message-restore-race", "task-restore-race", "session-restore-race", + "turn-restore-race", "pending-restore-race", "q1", base, + ) + claimedMessages, claimed, err := repo.CompleteActiveClarificationBundle( + ctx, + "pending-restore-race", + "answered", + map[string]interface{}{"q1": map[string]interface{}{"question_id": "q1"}}, + ) + if err != nil || !claimed { + t.Fatalf("complete before restore race: claimed=%v err=%v", claimed, err) + } + createPendingActionTurn( + t, repo, "task-restore-race", "session-restore-race", "turn-successor", + base.Add(time.Second), base.Add(time.Second), + ) + + tx, err := repo.db.BeginTxx(ctx, nil) + if err != nil { + t.Fatalf("BeginTxx: %v", err) + } + restoreErr := repo.restoreClarificationMessages( + ctx, + tx, + repo.db.DriverName(), + claimedMessages, + "answered", + ) + _ = tx.Rollback() + if restoreErr == nil { + t.Fatal("restore update accepted a bundle after a successor turn became current") + } + message, err := repo.GetMessage(ctx, "message-restore-race") + if err != nil { + t.Fatalf("GetMessage: %v", err) + } + if message.Metadata["status"] != "answered" { + t.Fatalf("superseded terminal message status = %v, want answered", message.Metadata["status"]) + } +} + +func setClarificationMessageMetadata( + t *testing.T, + repo *Repository, + messageID string, + update func(map[string]interface{}), +) { + t.Helper() + message, err := repo.GetMessage(context.Background(), messageID) + if err != nil { + t.Fatalf("GetMessage(%s): %v", messageID, err) + } + update(message.Metadata) + if err := repo.UpdateMessage(context.Background(), message); err != nil { + t.Fatalf("UpdateMessage(%s): %v", messageID, err) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/message_clarification_response.go b/apps/backend/internal/task/repository/sqlite/message_clarification_response.go new file mode 100644 index 0000000000..3c5c65a088 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/message_clarification_response.go @@ -0,0 +1,613 @@ +package sqlite + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "sort" + + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/db/dialect" + "github.com/kandev/kandev/internal/task/models" +) + +const ( + clarificationStatusAnswered = "answered" + clarificationStatusPending = "pending" + clarificationStatusRejected = "rejected" + // clarificationStatusResponding is an intra-transaction claim marker. + // CompleteActiveClarificationBundle never commits it independently. + clarificationStatusResponding = "responding" + + clarificationResponseDeliveryPendingKey = "response_delivery_pending" +) + +func nonTerminalSessionPredicate(messageAlias string) string { + return fmt.Sprintf(`EXISTS ( + SELECT 1 + FROM task_sessions session_row + WHERE session_row.id = %s.task_session_id + AND session_row.state NOT IN ('%s', '%s', '%s') + )`, + messageAlias, + models.TaskSessionStateCompleted, + models.TaskSessionStateFailed, + models.TaskSessionStateCancelled, + ) +} + +// DetachActiveClarificationMessagesBySessionID atomically marks only pending, +// current-turn clarification rows as detached and returns the rows that changed. +func (r *Repository) DetachActiveClarificationMessagesBySessionID( + ctx context.Context, + sessionID string, +) ([]*models.Message, error) { + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("begin clarification detach: %w", err) + } + defer func() { _ = tx.Rollback() }() + drv := r.db.DriverName() + if err := lockSessionTurnWrites(ctx, tx, drv, sessionID); err != nil { + return nil, err + } + updatedAt := r.nowUTC() + query := fmt.Sprintf(` + UPDATE task_session_messages + SET metadata = %s, updated_at = ? + WHERE task_session_id = ? + AND type = 'clarification_request' + AND COALESCE(%s, '') IN ('', 'pending') + AND %s + AND turn_id = ( + SELECT turn_row.id + FROM task_session_turns turn_row + WHERE turn_row.task_session_id = task_session_messages.task_session_id + AND %s + ORDER BY turn_row.started_at DESC, turn_row.created_at DESC, turn_row.id DESC + LIMIT 1 + ) + RETURNING id, task_session_id, task_id, turn_id, author_type, author_id, + content, requests_input, type, metadata, created_at, updated_at + `, clarificationDetachedMetadataExpr(drv), + dialect.JSONExtract(drv, "task_session_messages.metadata", "status"), + clarificationNotDetachedPredicate(drv), + turnAuthorityPredicate(drv, "turn_row")) + rows, err := tx.QueryxContext(ctx, r.db.Rebind(query), updatedAt, sessionID) + if err != nil { + return nil, fmt.Errorf("detach active clarification messages: %w", err) + } + messages, _, err := scanMessageRows(rows, 0) + closeErr := rows.Close() + if err != nil { + return nil, fmt.Errorf("scan detached clarification messages: %w", err) + } + if closeErr != nil { + return nil, fmt.Errorf("close detached clarification rows: %w", closeErr) + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit clarification detach: %w", err) + } + return messages, nil +} + +// ExpireActiveClarificationBundle atomically expires one exact pending bundle +// only while it belongs to the session's current durable turn. The status and +// pending-ID predicates are evaluated by the UPDATE, so a stale expiry can +// never overwrite a concurrent answer or a newer bundle. +func (r *Repository) ExpireActiveClarificationBundle( + ctx context.Context, + sessionID, pendingID string, +) ([]*models.Message, error) { + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("begin clarification expiry: %w", err) + } + defer func() { _ = tx.Rollback() }() + drv := r.db.DriverName() + if err := lockSessionTurnWrites(ctx, tx, drv, sessionID); err != nil { + return nil, err + } + updatedAt := r.nowUTC() + query := fmt.Sprintf(` + UPDATE task_session_messages + SET metadata = %s, updated_at = ? + WHERE task_session_id = ? + AND type = 'clarification_request' + AND COALESCE(%s, '') IN ('', 'pending') + AND %s = ? + AND turn_id = ( + SELECT turn_row.id + FROM task_session_turns turn_row + WHERE turn_row.task_session_id = task_session_messages.task_session_id + AND %s + ORDER BY turn_row.started_at DESC, turn_row.created_at DESC, turn_row.id DESC + LIMIT 1 + ) + RETURNING id, task_session_id, task_id, turn_id, author_type, author_id, + content, requests_input, type, metadata, created_at, updated_at + `, clarificationExpiredMetadataExpr(drv), + dialect.JSONExtract(drv, "task_session_messages.metadata", "status"), + dialect.JSONExtract(drv, "task_session_messages.metadata", "pending_id"), + turnAuthorityPredicate(drv, "turn_row")) + rows, err := tx.QueryxContext(ctx, r.db.Rebind(query), updatedAt, sessionID, pendingID) + if err != nil { + return nil, fmt.Errorf("expire active clarification messages: %w", err) + } + messages, _, err := scanMessageRows(rows, 0) + closeErr := rows.Close() + if err != nil { + return nil, fmt.Errorf("scan expired clarification messages: %w", err) + } + if closeErr != nil { + return nil, fmt.Errorf("close expired clarification rows: %w", closeErr) + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit clarification expiry: %w", err) + } + return messages, nil +} + +func clarificationDetachedMetadataExpr(driverName string) string { + if dialect.IsPostgres(driverName) { + return "jsonb_set(metadata::jsonb, '{agent_disconnected}', 'true'::jsonb)::text" + } + return "json_set(metadata, '$.agent_disconnected', json('true'))" +} + +func clarificationExpiredMetadataExpr(driverName string) string { + if dialect.IsPostgres(driverName) { + return "jsonb_set(jsonb_set(metadata::jsonb, '{agent_disconnected}', 'true'::jsonb), " + + "'{status}', '\"expired\"'::jsonb)::text" + } + return "json_set(metadata, '$.agent_disconnected', json('true'), '$.status', 'expired')" +} + +func clarificationNotDetachedPredicate(driverName string) string { + value := dialect.JSONExtract(driverName, "task_session_messages.metadata", "agent_disconnected") + return fmt.Sprintf("CAST(COALESCE(%s, '') AS TEXT) NOT IN ('true', '1')", value) +} + +func clarificationResponseDeliveryPendingPredicate(driverName, messageAlias string) string { + value := dialect.JSONExtract( + driverName, + messageAlias+".metadata", + clarificationResponseDeliveryPendingKey, + ) + return fmt.Sprintf("CAST(COALESCE(%s, '') AS TEXT) IN ('true', '1')", value) +} + +// CompleteActiveClarificationBundle atomically claims a current-turn pending +// bundle and persists its terminal state. Exactly one concurrent responder can +// transition the rows; superseded or already-terminal bundles return claimed=false. +func (r *Repository) CompleteActiveClarificationBundle( + ctx context.Context, + pendingID, status string, + responses map[string]interface{}, +) ([]*models.Message, bool, error) { + if status != clarificationStatusAnswered && status != clarificationStatusRejected { + return nil, false, fmt.Errorf("invalid clarification terminal status %q", status) + } + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return nil, false, err + } + defer func() { _ = tx.Rollback() }() + + drv := r.db.DriverName() + if err := r.lockClarificationBundleTurnWrites(ctx, tx, drv, pendingID); err != nil { + return nil, false, err + } + claimedRows, err := r.claimActiveClarificationBundle(ctx, tx, drv, pendingID) + if err != nil { + return nil, false, err + } + if claimedRows == 0 { + return nil, false, nil + } + messages, err := r.loadClaimedClarificationBundle(ctx, tx, drv, pendingID) + if err != nil { + return nil, false, err + } + if int64(len(messages)) != claimedRows { + return nil, false, fmt.Errorf("claimed %d clarification rows but loaded %d", claimedRows, len(messages)) + } + if err := r.completeClaimedClarificationMessages(ctx, tx, drv, messages, status, responses); err != nil { + return nil, false, err + } + if err := tx.Commit(); err != nil { + return nil, false, fmt.Errorf("commit clarification bundle completion: %w", err) + } + return messages, true, nil +} + +// RestoreActiveClarificationBundle reopens a current-turn terminal bundle when +// its detached resume event could not be published and returns the committed +// pending rows. The status check makes the rollback idempotent and prevents an +// older turn from becoming active again. +func (r *Repository) RestoreActiveClarificationBundle( + ctx context.Context, + pendingID, terminalStatus string, + claimedMessages []*models.Message, +) ([]*models.Message, bool, error) { + if terminalStatus != clarificationStatusAnswered && terminalStatus != clarificationStatusRejected { + return nil, false, fmt.Errorf("invalid clarification terminal status %q", terminalStatus) + } + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return nil, false, err + } + defer func() { _ = tx.Rollback() }() + + claimIDs, err := clarificationClaimIDs(claimedMessages) + if err != nil { + return nil, false, err + } + drv := r.db.DriverName() + if err := r.lockClarificationBundleTurnWrites(ctx, tx, drv, pendingID); err != nil { + return nil, false, err + } + messages, err := r.loadRestorableClarificationBundle(ctx, tx, drv, pendingID) + if err != nil { + return nil, false, err + } + restorable := make([]*models.Message, 0, len(claimIDs)) + for _, message := range messages { + if _, claimed := claimIDs[message.ID]; !claimed { + continue + } + if status, _ := message.Metadata["status"].(string); status != terminalStatus { + return nil, false, nil + } + restorable = append(restorable, message) + } + if len(restorable) != len(claimIDs) { + return nil, false, nil + } + if err := r.restoreClarificationMessages( + ctx, + tx, + drv, + restorable, + terminalStatus, + ); err != nil { + return nil, false, err + } + if err := tx.Commit(); err != nil { + return nil, false, fmt.Errorf("commit clarification bundle restore: %w", err) + } + return restorable, true, nil +} + +func (r *Repository) lockClarificationBundleTurnWrites( + ctx context.Context, + tx *sqlx.Tx, + driverName, pendingID string, +) error { + if !dialect.IsPostgres(driverName) { + return nil + } + pendingIDExpr := dialect.JSONExtract(driverName, "metadata", "pending_id") + query := fmt.Sprintf(` + SELECT DISTINCT task_session_id + FROM task_session_messages + WHERE type = 'clarification_request' + AND %s = ? + `, pendingIDExpr) + var sessionIDs []string + if err := tx.SelectContext(ctx, &sessionIDs, r.db.Rebind(query), pendingID); err != nil { + return fmt.Errorf("load clarification bundle sessions: %w", err) + } + sort.Strings(sessionIDs) + if err := lockSessionTurnWrites(ctx, tx, driverName, sessionIDs...); err != nil { + return err + } + // Terminal session writers acquire row locks through UPDATE. Locking the + // same rows before evaluating the non-terminal claim predicate forces one + // side to observe the other's committed state. + for _, sessionID := range sessionIDs { + var lockedSessionID string + if err := tx.GetContext( + ctx, + &lockedSessionID, + r.db.Rebind(`SELECT id FROM task_sessions WHERE id = ? FOR UPDATE`), + sessionID, + ); err != nil { + return fmt.Errorf("lock clarification session %q: %w", sessionID, err) + } + } + return nil +} + +func clarificationClaimIDs(messages []*models.Message) (map[string]struct{}, error) { + if len(messages) == 0 { + return nil, fmt.Errorf("clarification restore requires claimed messages") + } + ids := make(map[string]struct{}, len(messages)) + for _, message := range messages { + if message == nil || message.ID == "" { + return nil, fmt.Errorf("clarification restore received an empty message id") + } + if _, duplicate := ids[message.ID]; duplicate { + return nil, fmt.Errorf("clarification restore received duplicate message %s", message.ID) + } + ids[message.ID] = struct{}{} + } + return ids, nil +} + +func (r *Repository) loadRestorableClarificationBundle( + ctx context.Context, + tx *sqlx.Tx, + drv, pendingID string, +) ([]*models.Message, error) { + pendingIDExpr := dialect.JSONExtract(drv, "m.metadata", "pending_id") + bundlePendingIDExpr := dialect.JSONExtract(drv, "bundle.metadata", "pending_id") + // A pending ID spanning message types, sessions, or turns is malformed. The + // NOT EXISTS guard intentionally makes the whole bundle ineligible to restore. + query := fmt.Sprintf(` + SELECT m.id, m.task_session_id, m.task_id, m.turn_id, m.author_type, m.author_id, + m.content, m.requests_input, m.type, m.metadata, m.created_at, m.updated_at + FROM task_session_messages m + WHERE %s = ? + AND m.type = 'clarification_request' + AND %s + AND %s + AND m.turn_id = ( + SELECT turn_row.id + FROM task_session_turns turn_row + WHERE turn_row.task_session_id = m.task_session_id + AND %s + ORDER BY turn_row.started_at DESC, turn_row.created_at DESC, turn_row.id DESC + LIMIT 1 + ) + AND NOT EXISTS ( + SELECT 1 + FROM task_session_messages bundle + WHERE %s = ? + AND ( + bundle.type != 'clarification_request' + OR bundle.task_session_id != m.task_session_id + OR bundle.turn_id != m.turn_id + ) + ) + ORDER BY m.created_at ASC, m.id ASC + `, pendingIDExpr, + nonTerminalSessionPredicate("m"), + clarificationResponseDeliveryPendingPredicate(drv, "m"), + turnAuthorityPredicate(drv, "turn_row"), + bundlePendingIDExpr, + ) + rows, err := tx.QueryxContext( + ctx, + r.db.Rebind(query), + pendingID, + pendingID, + ) + if err != nil { + return nil, fmt.Errorf("load restorable clarification bundle: %w", err) + } + defer func() { _ = rows.Close() }() + messages, _, scanErr := scanMessageRows(rows, 0) + if scanErr != nil { + return nil, fmt.Errorf("scan restorable clarification bundle: %w", scanErr) + } + return messages, nil +} + +func (r *Repository) restoreClarificationMessages( + ctx context.Context, + tx *sqlx.Tx, + drv string, + messages []*models.Message, + terminalStatus string, +) error { + updatedAt := r.nowUTC() + statusExpr := dialect.JSONExtract(drv, "metadata", "status") + updateQuery := r.db.Rebind(fmt.Sprintf(` + UPDATE task_session_messages + SET metadata = ?, updated_at = ? + WHERE id = ? AND %s = ? + AND %s + AND %s + AND turn_id = ( + SELECT turn_row.id + FROM task_session_turns turn_row + WHERE turn_row.task_session_id = task_session_messages.task_session_id + AND %s + ORDER BY turn_row.started_at DESC, turn_row.created_at DESC, turn_row.id DESC + LIMIT 1 + ) + `, + statusExpr, + clarificationResponseDeliveryPendingPredicate(drv, "task_session_messages"), + nonTerminalSessionPredicate("task_session_messages"), + turnAuthorityPredicate(drv, "turn_row"), + )) + restoredMetadataByMessage := make([]map[string]interface{}, len(messages)) + for i, message := range messages { + restoredMetadata := maps.Clone(message.Metadata) + restoredMetadata["status"] = clarificationStatusPending + delete(restoredMetadata, "response") + delete(restoredMetadata, clarificationResponseDeliveryPendingKey) + metadataJSON, marshalErr := json.Marshal(restoredMetadata) + if marshalErr != nil { + return fmt.Errorf("marshal clarification message %s for restore: %w", message.ID, marshalErr) + } + result, updateErr := tx.ExecContext( + ctx, + updateQuery, + string(metadataJSON), + updatedAt, + message.ID, + terminalStatus, + ) + if updateErr != nil { + return fmt.Errorf("restore clarification message %s: %w", message.ID, updateErr) + } + updatedRows, rowsErr := result.RowsAffected() + if rowsErr != nil { + return fmt.Errorf("count restored clarification message %s: %w", message.ID, rowsErr) + } + if updatedRows != 1 { + return fmt.Errorf("clarification message %s lost its terminal claim", message.ID) + } + restoredMetadataByMessage[i] = restoredMetadata + } + for i, message := range messages { + message.Metadata = restoredMetadataByMessage[i] + message.UpdatedAt = updatedAt + } + return nil +} + +func (r *Repository) claimActiveClarificationBundle( + ctx context.Context, + tx *sqlx.Tx, + drv, pendingID string, +) (int64, error) { + pendingIDExpr := dialect.JSONExtract(drv, "task_session_messages.metadata", "pending_id") + statusExpr := dialect.JSONExtract(drv, "task_session_messages.metadata", "status") + bundlePendingIDExpr := dialect.JSONExtract(drv, "bundle.metadata", "pending_id") + // A pending ID spanning message types, sessions, or turns is malformed. The + // NOT EXISTS guard intentionally makes the whole bundle ineligible to claim. + // Detached rows are intentionally eligible: agent_disconnected means the + // live waiter is gone, not that the current-turn question was resolved. + // The responding marker cannot be stranded by a later terminal transition; + // it is replaced with the requested terminal status before this transaction + // commits or is rolled back with the transaction. + claimAt := r.nowUTC() + claimQuery := fmt.Sprintf(` + UPDATE task_session_messages + SET metadata = %s, updated_at = ? + WHERE %s = ? + AND type = 'clarification_request' + AND COALESCE(%s, '') IN ('', 'pending') + AND %s + AND turn_id = ( + SELECT turn_row.id + FROM task_session_turns turn_row + WHERE turn_row.task_session_id = task_session_messages.task_session_id + AND %s + ORDER BY turn_row.started_at DESC, turn_row.created_at DESC, turn_row.id DESC + LIMIT 1 + ) + AND NOT EXISTS ( + SELECT 1 + FROM task_session_messages bundle + WHERE %s = ? + AND ( + bundle.type != 'clarification_request' + OR bundle.task_session_id != task_session_messages.task_session_id + OR bundle.turn_id != task_session_messages.turn_id + ) + ) + `, dialect.JSONSet(drv, "metadata", "status", clarificationStatusResponding), pendingIDExpr, statusExpr, + nonTerminalSessionPredicate("task_session_messages"), + turnAuthorityPredicate(drv, "turn_row"), bundlePendingIDExpr) + // pendingID is bound once for the outer bundle and once for the malformed- + // bundle NOT EXISTS guard. + result, err := tx.ExecContext(ctx, r.db.Rebind(claimQuery), claimAt, pendingID, pendingID) + if err != nil { + return 0, fmt.Errorf("claim active clarification bundle: %w", err) + } + claimedRows, err := result.RowsAffected() + if err != nil { + return 0, fmt.Errorf("count claimed clarification rows: %w", err) + } + return claimedRows, nil +} + +func (r *Repository) loadClaimedClarificationBundle( + ctx context.Context, + tx *sqlx.Tx, + drv, pendingID string, +) ([]*models.Message, error) { + claimedStatusExpr := dialect.JSONExtract(drv, "m.metadata", "status") + rows, err := tx.QueryxContext(ctx, r.db.Rebind(fmt.Sprintf(` + SELECT m.id, m.task_session_id, m.task_id, m.turn_id, m.author_type, m.author_id, + m.content, m.requests_input, m.type, m.metadata, m.created_at, m.updated_at + FROM task_session_messages m + WHERE %s = ? AND %s = ? + AND m.turn_id = ( + SELECT turn_row.id + FROM task_session_turns turn_row + WHERE turn_row.task_session_id = m.task_session_id + AND %s + ORDER BY turn_row.started_at DESC, turn_row.created_at DESC, turn_row.id DESC + LIMIT 1 + ) + ORDER BY m.created_at ASC, m.id ASC + `, dialect.JSONExtract(drv, "m.metadata", "pending_id"), claimedStatusExpr, + turnAuthorityPredicate(drv, "turn_row"))), pendingID, clarificationStatusResponding) + if err != nil { + return nil, fmt.Errorf("load claimed clarification bundle: %w", err) + } + defer func() { _ = rows.Close() }() + messages, _, scanErr := scanMessageRows(rows, 0) + if scanErr != nil { + return nil, fmt.Errorf("scan claimed clarification bundle: %w", scanErr) + } + return messages, nil +} + +func (r *Repository) completeClaimedClarificationMessages( + ctx context.Context, + tx *sqlx.Tx, + drv string, + messages []*models.Message, + status string, + responses map[string]interface{}, +) error { + updatedAt := r.nowUTC() + claimedStatusExpr := dialect.JSONExtract(drv, "metadata", "status") + updateQuery := r.db.Rebind(fmt.Sprintf(` + UPDATE task_session_messages + SET metadata = ?, updated_at = ? + WHERE id = ? AND %s = ? + `, claimedStatusExpr)) + completedMetadataByMessage := make([]map[string]interface{}, len(messages)) + for i, message := range messages { + questionID, _ := message.Metadata["question_id"].(string) + if questionID == "" { + return fmt.Errorf("clarification message %s is missing question_id", message.ID) + } + completedMetadata := maps.Clone(message.Metadata) + completedMetadata["status"] = status + completedMetadata[clarificationResponseDeliveryPendingKey] = true + if status == clarificationStatusAnswered { + response, ok := responses[questionID] + if !ok { + return fmt.Errorf("missing response for clarification question %s", questionID) + } + completedMetadata["response"] = response + } + metadataJSON, marshalErr := json.Marshal(completedMetadata) + if marshalErr != nil { + return fmt.Errorf("marshal clarification message %s: %w", message.ID, marshalErr) + } + updateResult, updateErr := tx.ExecContext( + ctx, + updateQuery, + string(metadataJSON), updatedAt, message.ID, clarificationStatusResponding, + ) + if updateErr != nil { + return fmt.Errorf("complete clarification message %s: %w", message.ID, updateErr) + } + updatedRows, rowsErr := updateResult.RowsAffected() + if rowsErr != nil { + return fmt.Errorf("count completed clarification message %s: %w", message.ID, rowsErr) + } + if updatedRows != 1 { + return fmt.Errorf("clarification message %s lost its claim", message.ID) + } + completedMetadataByMessage[i] = completedMetadata + } + for i, message := range messages { + message.Metadata = completedMetadataByMessage[i] + message.UpdatedAt = updatedAt + } + return nil +} diff --git a/apps/backend/internal/task/repository/sqlite/message_crud_coverage_test.go b/apps/backend/internal/task/repository/sqlite/message_crud_coverage_test.go index f0ae65530e..a94f5c34a0 100644 --- a/apps/backend/internal/task/repository/sqlite/message_crud_coverage_test.go +++ b/apps/backend/internal/task/repository/sqlite/message_crud_coverage_test.go @@ -86,9 +86,9 @@ func TestMessageCRUDPaginationSearchAndMetadataLookups(t *testing.T) { if err != nil || latestPending.ID != "message-b" { t.Fatalf("FindMessageByPendingID = %+v, %v", latestPending, err) } - pendingClarifications, err := repo.FindPendingClarificationMessagesBySessionID(ctx, "session-message-crud") + pendingClarifications, err := repo.FindActiveClarificationMessagesBySessionID(ctx, "session-message-crud") if err != nil || strings.Join(messageIDs(pendingClarifications), ",") != "message-a" { - t.Fatalf("FindPendingClarificationMessagesBySessionID = %v, %v", messageIDs(pendingClarifications), err) + t.Fatalf("FindActiveClarificationMessagesBySessionID = %v, %v", messageIDs(pendingClarifications), err) } question, err := repo.FindMessageByPendingIDAndQuestion(ctx, "session-message-crud", "pending-shared", "question-b") if err != nil || question.ID != "message-b" { diff --git a/apps/backend/internal/task/repository/sqlite/message_pending_postgres_test.go b/apps/backend/internal/task/repository/sqlite/message_pending_postgres_test.go new file mode 100644 index 0000000000..28fdfa0d3d --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/message_pending_postgres_test.go @@ -0,0 +1,591 @@ +package sqlite + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/testutil" +) + +// TestPostgresActiveClarificationUsesNewestDurableTurn pins the same ownership +// rule as SQLite. It skips unless KANDEV_TEST_POSTGRES_DSN is configured. +func TestPostgresActiveClarificationUsesNewestDurableTurn(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + ctx := context.Background() + base := time.Date(2026, time.August, 14, 15, 0, 0, 0, time.UTC) + seedPendingActionSession(t, repo, "task-active-pg", "session-active-pg") + createPendingActionTurn(t, repo, "task-active-pg", "session-active-pg", "turn-old-pg", base, base) + createPendingActionMessage(t, repo, "clarification-old-pg", "task-active-pg", "session-active-pg", "turn-old-pg", models.MessageTypeClarificationRequest, "pending", base) + createPendingActionTurn(t, repo, "task-active-pg", "session-active-pg", "turn-new-pg", base.Add(time.Minute), base.Add(time.Minute)) + + assertNoActivePostgresClarification(t, ctx, repo) + createPendingActionMessage(t, repo, "clarification-new-pg", "task-active-pg", "session-active-pg", "turn-new-pg", models.MessageTypeClarificationRequest, "", base.Add(time.Minute)) + active, err := repo.FindActiveClarificationMessagesBySessionID(ctx, "session-active-pg") + if err != nil { + t.Fatalf("FindActiveClarificationMessagesBySessionID: %v", err) + } + if ids := messageIDs(active); len(ids) != 1 || ids[0] != "clarification-new-pg" { + t.Fatalf("postgres active clarification IDs = %v", ids) + } + actions, err := repo.GetPendingActionsBySessionIDs(ctx, []string{"session-active-pg"}) + if err != nil { + t.Fatalf("GetPendingActionsBySessionIDs: %v", err) + } + if actions["session-active-pg"] != models.TaskPendingActionClarification { + t.Fatalf("postgres pending action = %#v", actions) + } + + if err := repo.DeleteMessage(ctx, "clarification-new-pg"); err != nil { + t.Fatalf("DeleteMessage(new current-turn row): %v", err) + } + assertNoActivePostgresClarification(t, ctx, repo) +} + +func TestPostgresPendingActionsExcludeTerminalSessionsAndPreferClarification(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + ctx := context.Background() + base := time.Date(2026, time.August, 16, 21, 0, 0, 0, time.UTC) + seedPendingActionSession(t, repo, "task-precedence-pg", "session-precedence-pg") + createPendingActionTurn( + t, repo, "task-precedence-pg", "session-precedence-pg", "turn-precedence-pg", base, base, + ) + createPendingActionMessage( + t, repo, "permission-precedence-pg", "task-precedence-pg", "session-precedence-pg", + "turn-precedence-pg", models.MessageTypePermissionRequest, "pending", base, + ) + createPendingActionMessage( + t, repo, "clarification-precedence-pg", "task-precedence-pg", "session-precedence-pg", + "turn-precedence-pg", models.MessageTypeClarificationRequest, "pending", base.Add(time.Second), + ) + seedPendingActionSession(t, repo, "task-terminal-action-pg", "session-terminal-action-pg") + createPendingActionTurn( + t, repo, "task-terminal-action-pg", "session-terminal-action-pg", "turn-terminal-action-pg", base, base, + ) + createPendingActionMessage( + t, repo, "clarification-terminal-action-pg", "task-terminal-action-pg", "session-terminal-action-pg", + "turn-terminal-action-pg", models.MessageTypeClarificationRequest, "pending", base, + ) + if err := repo.UpdateTaskSessionState( + ctx, "session-terminal-action-pg", models.TaskSessionStateCancelled, "cancelled", + ); err != nil { + t.Fatalf("cancel terminal session: %v", err) + } + + actions, err := repo.GetPendingActionsBySessionIDs( + ctx, + []string{"session-precedence-pg", "session-terminal-action-pg"}, + ) + if err != nil { + t.Fatalf("GetPendingActionsBySessionIDs: %v", err) + } + if actions["session-precedence-pg"] != models.TaskPendingActionClarification { + t.Fatalf("postgres precedence action = %#v, want clarification", actions) + } + if _, ok := actions["session-terminal-action-pg"]; ok { + t.Fatalf("postgres terminal session retained pending action: %#v", actions) + } +} + +func TestPostgresCompleteActiveClarificationBundleClaimsOnce(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + ctx := context.Background() + base := time.Date(2026, time.August, 15, 14, 0, 0, 0, time.UTC) + seedPendingActionSession(t, repo, "task-claim-pg", "session-claim-pg") + createPendingActionTurn(t, repo, "task-claim-pg", "session-claim-pg", "turn-claim-pg", base, base) + createClarificationBundleMessage(t, repo, "message-claim-pg", "task-claim-pg", "session-claim-pg", "turn-claim-pg", "pending-claim-pg", "q1", base) + responses := map[string]interface{}{ + "q1": map[string]interface{}{"question_id": "q1", "custom_text": "continue"}, + } + + updated, claimed, err := repo.CompleteActiveClarificationBundle(ctx, "pending-claim-pg", "answered", responses) + if err != nil { + t.Fatalf("first CompleteActiveClarificationBundle: %v", err) + } + if !claimed || len(updated) != 1 { + t.Fatalf("first completion = claimed %v, rows %d; want true, 1", claimed, len(updated)) + } + _, claimed, err = repo.CompleteActiveClarificationBundle(ctx, "pending-claim-pg", "answered", responses) + if err != nil { + t.Fatalf("second CompleteActiveClarificationBundle: %v", err) + } + if claimed { + t.Fatal("already-answered Postgres bundle was claimed twice") + } + _, restored, err := repo.RestoreActiveClarificationBundle( + ctx, + "pending-claim-pg", + "answered", + updated, + ) + if err != nil || !restored { + t.Fatalf("RestoreActiveClarificationBundle: restored=%v err=%v", restored, err) + } + _, claimed, err = repo.CompleteActiveClarificationBundle(ctx, "pending-claim-pg", "answered", responses) + if err != nil || !claimed { + t.Fatalf("completion after restore: claimed=%v err=%v", claimed, err) + } +} + +func TestPostgresClarificationClaimWaitsForTerminalSessionWrite(t *testing.T) { + db := openIsolatedPostgresMultiConn(t, testutil.PostgresDSNFromEnv(t), 5) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + ctx := context.Background() + base := time.Date(2026, time.August, 16, 13, 40, 0, 0, time.UTC) + seedPendingActionSession(t, repo, "task-terminal-claim-pg", "session-terminal-claim-pg") + createPendingActionTurn( + t, repo, "task-terminal-claim-pg", "session-terminal-claim-pg", + "turn-terminal-claim-pg", base, base, + ) + createClarificationBundleMessage( + t, repo, "message-terminal-claim-pg", "task-terminal-claim-pg", + "session-terminal-claim-pg", "turn-terminal-claim-pg", + "pending-terminal-claim-pg", "q1", base, + ) + + terminalWriter, err := repo.db.BeginTxx(ctx, nil) + if err != nil { + t.Fatalf("begin terminal session writer: %v", err) + } + t.Cleanup(func() { _ = terminalWriter.Rollback() }) + if _, err := terminalWriter.ExecContext(ctx, repo.db.Rebind(` + UPDATE task_sessions SET state = ? WHERE id = ? + `), string(models.TaskSessionStateCancelled), "session-terminal-claim-pg"); err != nil { + t.Fatalf("stage terminal session state: %v", err) + } + + type completionResult struct { + claimed bool + err error + } + completionDone := make(chan completionResult, 1) + go func() { + _, claimed, completionErr := repo.CompleteActiveClarificationBundle( + ctx, + "pending-terminal-claim-pg", + clarificationStatusAnswered, + map[string]interface{}{"q1": "continue"}, + ) + completionDone <- completionResult{claimed: claimed, err: completionErr} + }() + waitForPostgresLockWait(t, ctx, repo, "SELECT id FROM task_sessions") + select { + case result := <-completionDone: + t.Fatalf("clarification claim bypassed terminal session row lock: %+v", result) + default: + } + + if err := terminalWriter.Commit(); err != nil { + t.Fatalf("commit terminal session state: %v", err) + } + select { + case result := <-completionDone: + if result.err != nil || result.claimed { + t.Fatalf("clarification claim after terminal commit = %+v, want unclaimed", result) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for clarification claim") + } +} + +func TestPostgresDetachActiveClarificationMessagesClaimsCurrentRows(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + ctx := context.Background() + base := time.Date(2026, time.August, 15, 16, 0, 0, 0, time.UTC) + seedPendingActionSession(t, repo, "task-detach-pg", "session-detach-pg") + createPendingActionTurn( + t, repo, "task-detach-pg", "session-detach-pg", "turn-detach-old-pg", base, base, + ) + createClarificationBundleMessage( + t, repo, "message-detach-old-pg", "task-detach-pg", "session-detach-pg", + "turn-detach-old-pg", "pending-detach-old-pg", "q-old", base, + ) + createPendingActionTurn( + t, repo, "task-detach-pg", "session-detach-pg", "turn-detach-current-pg", + base.Add(time.Minute), base.Add(time.Minute), + ) + createClarificationBundleMessage( + t, repo, "message-detach-current-pg", "task-detach-pg", "session-detach-pg", + "turn-detach-current-pg", "pending-detach-current-pg", "q-current", + base.Add(time.Minute), + ) + for index, flag := range []string{"true", "1"} { + messageID := "message-detach-string-" + flag + "-pg" + createClarificationBundleMessage( + t, repo, messageID, "task-detach-pg", "session-detach-pg", + "turn-detach-current-pg", "pending-detach-string-"+flag+"-pg", "q-string-"+flag, + base.Add(time.Minute+time.Duration(index+1)*time.Second), + ) + setClarificationMessageMetadata(t, repo, messageID, func(metadata map[string]interface{}) { + metadata["agent_disconnected"] = flag + }) + } + + updated, err := repo.DetachActiveClarificationMessagesBySessionID(ctx, "session-detach-pg") + if err != nil { + t.Fatalf("DetachActiveClarificationMessagesBySessionID: %v", err) + } + if ids := messageIDs(updated); len(ids) != 1 || ids[0] != "message-detach-current-pg" { + t.Fatalf("postgres detached message IDs = %v", ids) + } + message, err := repo.GetMessage(ctx, "message-detach-current-pg") + if err != nil { + t.Fatalf("GetMessage(current): %v", err) + } + if detached, _ := message.Metadata["agent_disconnected"].(bool); !detached { + t.Fatalf("postgres detached metadata = %#v", message.Metadata) + } + repeated, err := repo.DetachActiveClarificationMessagesBySessionID(ctx, "session-detach-pg") + if err != nil { + t.Fatalf("repeated postgres detach: %v", err) + } + if len(repeated) != 0 { + t.Fatalf("repeated postgres detach changed rows: %v", messageIDs(repeated)) + } +} + +func TestPostgresExpireActiveClarificationMessagesPreservesTerminalRows(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + ctx := context.Background() + base := time.Date(2026, time.August, 15, 16, 10, 0, 0, time.UTC) + seedPendingActionSession(t, repo, "task-expire-pg", "session-expire-pg") + createPendingActionTurn( + t, repo, "task-expire-pg", "session-expire-pg", "turn-expire-pg", base, base, + ) + createClarificationBundleMessage( + t, repo, "message-expire-pg", "task-expire-pg", "session-expire-pg", + "turn-expire-pg", "pending-expire-pg", "q-pending", base, + ) + createClarificationBundleMessage( + t, repo, "message-answered-pg", "task-expire-pg", "session-expire-pg", + "turn-expire-pg", "pending-answered-pg", "q-answered", base.Add(time.Second), + ) + setClarificationMessageMetadata(t, repo, "message-answered-pg", func(metadata map[string]interface{}) { + metadata["status"] = "answered" + }) + + updated, err := repo.ExpireActiveClarificationBundle(ctx, "session-expire-pg", "pending-expire-pg") + if err != nil { + t.Fatalf("ExpireActiveClarificationBundle: %v", err) + } + if ids := messageIDs(updated); len(ids) != 1 || ids[0] != "message-expire-pg" { + t.Fatalf("postgres expired message IDs = %v", ids) + } + expired, err := repo.GetMessage(ctx, "message-expire-pg") + if err != nil { + t.Fatalf("GetMessage(expired): %v", err) + } + if expired.Metadata["status"] != "expired" || expired.Metadata["agent_disconnected"] != true { + t.Fatalf("postgres expired metadata = %#v", expired.Metadata) + } + answered, err := repo.GetMessage(ctx, "message-answered-pg") + if err != nil { + t.Fatalf("GetMessage(answered): %v", err) + } + if answered.Metadata["status"] != "answered" { + t.Fatalf("postgres answered status = %v", answered.Metadata["status"]) + } +} + +func TestPostgresTurnCreationSerializesWithClarificationDetach(t *testing.T) { + // Four concurrent holders peak: blocker, detach, turn creation, and lock + // observer. Two spare connections keep repository bookkeeping off that budget. + db := openIsolatedPostgresMultiConn(t, testutil.PostgresDSNFromEnv(t), 6) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + ctx := context.Background() + base := time.Date(2026, time.August, 15, 16, 15, 0, 0, time.UTC) + seedPendingActionSession(t, repo, "task-detach-lock-pg", "session-detach-lock-pg") + createPendingActionTurn( + t, repo, "task-detach-lock-pg", "session-detach-lock-pg", "turn-detach-lock-pg", base, base, + ) + createClarificationBundleMessage( + t, repo, "message-detach-lock-pg", "task-detach-lock-pg", "session-detach-lock-pg", + "turn-detach-lock-pg", "pending-detach-lock-pg", "q1", base, + ) + + blocker, err := repo.db.BeginTxx(ctx, nil) + if err != nil { + t.Fatalf("begin row blocker: %v", err) + } + t.Cleanup(func() { _ = blocker.Rollback() }) + var lockedMessageID string + if err := blocker.QueryRowxContext( + ctx, + repo.db.Rebind(`SELECT id FROM task_session_messages WHERE id = ? FOR UPDATE`), + "message-detach-lock-pg", + ).Scan(&lockedMessageID); err != nil { + t.Fatalf("lock clarification row: %v", err) + } + + type detachResult struct { + messages []*models.Message + err error + } + detachDone := make(chan detachResult, 1) + go func() { + messages, detachErr := repo.DetachActiveClarificationMessagesBySessionID( + ctx, + "session-detach-lock-pg", + ) + detachDone <- detachResult{messages: messages, err: detachErr} + }() + waitForPostgresLockWait(t, ctx, repo, "UPDATE task_session_messages") + + createStarted := make(chan struct{}) + createDone := make(chan error, 1) + go func() { + close(createStarted) + createDone <- repo.CreateTurn(ctx, &models.Turn{ + ID: "turn-successor-lock-pg", + TaskSessionID: "session-detach-lock-pg", + TaskID: "task-detach-lock-pg", + StartedAt: base.Add(time.Minute), + }) + }() + <-createStarted + select { + case createErr := <-createDone: + t.Fatalf("CreateTurn completed before clarification detach released its session lock: %v", createErr) + case <-time.After(150 * time.Millisecond): + } + + if err := blocker.Commit(); err != nil { + t.Fatalf("release clarification row: %v", err) + } + select { + case result := <-detachDone: + if result.err != nil { + t.Fatalf("detach clarification: %v", result.err) + } + if ids := messageIDs(result.messages); len(ids) != 1 || ids[0] != "message-detach-lock-pg" { + t.Fatalf("detached message IDs = %v", ids) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for clarification detach") + } + select { + case createErr := <-createDone: + if createErr != nil { + t.Fatalf("CreateTurn after clarification detach: %v", createErr) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for successor turn creation") + } +} + +func TestPostgresMessageCreationSerializesWithTurnRollback(t *testing.T) { + // Four concurrent holders peak; two spare connections preserve lock-test headroom. + db := openIsolatedPostgresMultiConn(t, testutil.PostgresDSNFromEnv(t), 6) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + ctx := context.Background() + base := time.Date(2026, time.August, 15, 16, 20, 0, 0, time.UTC) + seedPendingActionSession(t, repo, "task-message-lock-pg", "session-message-lock-pg") + createPendingActionTurn( + t, repo, "task-message-lock-pg", "session-message-lock-pg", "turn-message-lock-pg", base, base, + ) + + blocker, err := repo.db.BeginTxx(ctx, nil) + if err != nil { + t.Fatalf("begin session lock blocker: %v", err) + } + t.Cleanup(func() { _ = blocker.Rollback() }) + if err := lockSessionTurnWrites(ctx, blocker, repo.db.DriverName(), "session-message-lock-pg"); err != nil { + t.Fatalf("hold session turn lock: %v", err) + } + + createDone := make(chan error, 1) + go func() { + createDone <- repo.CreateMessage(ctx, &models.Message{ + ID: "message-lock-pg", + TaskSessionID: "session-message-lock-pg", + TaskID: "task-message-lock-pg", + TurnID: "turn-message-lock-pg", + AuthorType: models.MessageAuthorUser, + Content: "keep the accepted prompt", + }) + }() + select { + case createErr := <-createDone: + t.Fatalf("CreateMessage bypassed the session turn lock: %v", createErr) + case <-time.After(150 * time.Millisecond): + } + waitForPostgresLockWait(t, ctx, repo, "pg_advisory_xact_lock") + + type deleteResult struct { + deleted bool + err error + } + deleteDone := make(chan deleteResult, 1) + go func() { + deleted, deleteErr := repo.DeleteTurnIfUnreferenced( + ctx, + "session-message-lock-pg", + "turn-message-lock-pg", + ) + deleteDone <- deleteResult{deleted: deleted, err: deleteErr} + }() + select { + case result := <-deleteDone: + t.Fatalf("DeleteTurnIfUnreferenced bypassed the session turn lock: %+v", result) + case <-time.After(150 * time.Millisecond): + } + + if err := blocker.Commit(); err != nil { + t.Fatalf("release session turn lock: %v", err) + } + select { + case createErr := <-createDone: + if createErr != nil { + t.Fatalf("CreateMessage after session lock release: %v", createErr) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for message creation") + } + select { + case result := <-deleteDone: + if result.err != nil || result.deleted { + t.Fatalf("turn rollback after message creation = %+v, want preserved turn", result) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for turn rollback") + } + if _, err := repo.GetTurn(ctx, "turn-message-lock-pg"); err != nil { + t.Fatalf("durable turn was lost: %v", err) + } + if _, err := repo.GetMessage(ctx, "message-lock-pg"); err != nil { + t.Fatalf("durable message was lost: %v", err) + } +} + +func waitForPostgresLockWait( + t *testing.T, + ctx context.Context, + repo *Repository, + queryFragment string, +) { + t.Helper() + waitCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { + var waiting bool + err := repo.db.QueryRowxContext(waitCtx, ` + SELECT EXISTS ( + SELECT 1 + FROM pg_stat_activity + WHERE datname = current_database() + AND pid != pg_backend_pid() + AND state = 'active' + AND wait_event_type = 'Lock' + AND query LIKE '%' || $1 || '%' + ) + `, queryFragment).Scan(&waiting) + if err != nil { + t.Fatalf("inspect PostgreSQL lock wait: %v", err) + } + if waiting { + return + } + select { + case <-waitCtx.Done(): + t.Fatalf("timed out waiting for PostgreSQL query containing %q to block", queryFragment) + case <-ticker.C: + } + } +} + +func TestPostgresRestoreClarificationMessagesRechecksCurrentTurn(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + ctx := context.Background() + base := time.Date(2026, time.August, 15, 16, 30, 0, 0, time.UTC) + seedPendingActionSession(t, repo, "task-restore-pg", "session-restore-pg") + createPendingActionTurn( + t, repo, "task-restore-pg", "session-restore-pg", "turn-restore-pg", base, base, + ) + createClarificationBundleMessage( + t, repo, "message-restore-pg", "task-restore-pg", "session-restore-pg", + "turn-restore-pg", "pending-restore-pg", "q1", base, + ) + claimedMessages, claimed, err := repo.CompleteActiveClarificationBundle( + ctx, + "pending-restore-pg", + "answered", + map[string]interface{}{"q1": map[string]interface{}{"question_id": "q1"}}, + ) + if err != nil || !claimed { + t.Fatalf("complete before postgres restore race: claimed=%v err=%v", claimed, err) + } + createPendingActionTurn( + t, repo, "task-restore-pg", "session-restore-pg", "turn-restore-successor-pg", + base.Add(time.Second), base.Add(time.Second), + ) + + tx, err := repo.db.BeginTxx(ctx, nil) + if err != nil { + t.Fatalf("BeginTxx: %v", err) + } + restoreErr := repo.restoreClarificationMessages( + ctx, + tx, + repo.db.DriverName(), + claimedMessages, + "answered", + ) + _ = tx.Rollback() + if restoreErr == nil { + t.Fatal("postgres restore accepted a bundle after a successor turn became current") + } +} + +func assertNoActivePostgresClarification(t *testing.T, ctx context.Context, repo *Repository) { + t.Helper() + active, err := repo.FindActiveClarificationMessagesBySessionID(ctx, "session-active-pg") + if err != nil { + t.Fatalf("FindActiveClarificationMessagesBySessionID: %v", err) + } + if len(active) != 0 { + t.Fatalf("postgres reactivated older clarification: %v", messageIDs(active)) + } + actions, err := repo.GetPendingActionsBySessionIDs(ctx, []string{"session-active-pg"}) + if err != nil { + t.Fatalf("GetPendingActionsBySessionIDs: %v", err) + } + if _, ok := actions["session-active-pg"]; ok { + t.Fatalf("postgres reactivated older pending action: %#v", actions) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/message_test.go b/apps/backend/internal/task/repository/sqlite/message_test.go index c5378f3c62..cfa833b4f3 100644 --- a/apps/backend/internal/task/repository/sqlite/message_test.go +++ b/apps/backend/internal/task/repository/sqlite/message_test.go @@ -197,6 +197,8 @@ func TestGetPendingActionsBySessionIDs(t *testing.T) { now := time.Now().UTC() seedForMsgTest(t, repo, "task-clar", "sess-clar", "turn-clar") + // Transitional history may contain both kinds. Clarification deliberately + // wins within one session, independent of UNION ALL row order. createPendingActionMessage(t, repo, "perm-clar", "task-clar", "sess-clar", "turn-clar", models.MessageTypePermissionRequest, "", now) createPendingActionMessage(t, repo, "clar-clar", "task-clar", "sess-clar", "turn-clar", models.MessageTypeClarificationRequest, "pending", now.Add(time.Second)) @@ -217,12 +219,25 @@ func TestGetPendingActionsBySessionIDs(t *testing.T) { seedForMsgTest(t, repo, "task-stale", "sess-stale", "turn-current") createPendingActionMessage(t, repo, "message-current", "task-stale", "sess-stale", "turn-current", models.MessageTypeMessage, "", now.Add(time.Second)) + seedForMsgTest(t, repo, "task-reserved", "sess-reserved", "turn-reserved-old") + createPendingActionMessage(t, repo, "clar-reserved", "task-reserved", "sess-reserved", "turn-reserved-old", models.MessageTypeClarificationRequest, "pending", now) + if err := repo.CreateTurn(ctx, &models.Turn{ + ID: "turn-reserved-empty", + TaskSessionID: "sess-reserved", + TaskID: "task-reserved", + StartedAt: now.Add(time.Second), + CreatedAt: now.Add(time.Second), + }); err != nil { + t.Fatalf("CreateTurn(reserved empty): %v", err) + } + got, err := repo.GetPendingActionsBySessionIDs(ctx, []string{ "sess-clar", "sess-resolved", "sess-perm", "sess-perm-tie", "sess-stale", + "sess-reserved", "sess-missing", }) if err != nil { @@ -243,6 +258,9 @@ func TestGetPendingActionsBySessionIDs(t *testing.T) { if _, ok := got["sess-stale"]; ok { t.Fatalf("sess-stale should not inherit previous turn actions: %#v", got["sess-stale"]) } + if _, ok := got["sess-reserved"]; ok { + t.Fatalf("sess-reserved should not inherit actions while its newest turn is empty: %#v", got["sess-reserved"]) + } if _, ok := got["sess-missing"]; ok { t.Fatalf("sess-missing should not have a pending action: %#v", got["sess-missing"]) } diff --git a/apps/backend/internal/task/repository/sqlite/pending_action_projection.go b/apps/backend/internal/task/repository/sqlite/pending_action_projection.go new file mode 100644 index 0000000000..ec35036f8b --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/pending_action_projection.go @@ -0,0 +1,43 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strconv" +) + +const pendingActionProjectionEpochMetaKey = "pending_action_projection_epoch" + +// NextPendingActionProjectionEpoch atomically allocates a durable backend +// generation. Keeping this value in kandev_meta lets clients compare snapshots +// emitted on opposite sides of a backend restart without trusting wall clocks. +func (r *Repository) NextPendingActionProjectionEpoch(ctx context.Context) (uint64, error) { + var value string + err := r.db.QueryRowContext(ctx, r.db.Rebind(` + INSERT INTO kandev_meta (key, value) VALUES (?, '1') + ON CONFLICT (key) DO UPDATE SET + value = CAST(CAST(kandev_meta.value AS BIGINT) + 1 AS TEXT) + WHERE kandev_meta.value = CAST(CAST(kandev_meta.value AS BIGINT) AS TEXT) + AND CAST(kandev_meta.value AS BIGINT) > 0 + RETURNING value + `), pendingActionProjectionEpochMetaKey).Scan(&value) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return 0, fmt.Errorf( + "allocate pending-action projection epoch: stored metadata is not a canonical positive integer: %w", + err, + ) + } + return 0, fmt.Errorf("allocate pending-action projection epoch: %w", err) + } + epoch, err := strconv.ParseUint(value, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid pending-action projection epoch %q: %w", value, err) + } + if epoch == 0 { + return 0, fmt.Errorf("invalid pending-action projection epoch %q: must be positive", value) + } + return epoch, nil +} diff --git a/apps/backend/internal/task/repository/sqlite/pending_action_projection_test.go b/apps/backend/internal/task/repository/sqlite/pending_action_projection_test.go new file mode 100644 index 0000000000..3dd07686e3 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/pending_action_projection_test.go @@ -0,0 +1,102 @@ +package sqlite + +import ( + "context" + "strings" + "testing" + + "github.com/kandev/kandev/internal/testutil" +) + +func TestNextPendingActionProjectionEpochPersistsGenerationOrder(t *testing.T) { + repo := newRepoForEntityTests(t) + ctx := context.Background() + + first, err := repo.NextPendingActionProjectionEpoch(ctx) + if err != nil { + t.Fatalf("allocate first epoch: %v", err) + } + second, err := repo.NextPendingActionProjectionEpoch(ctx) + if err != nil { + t.Fatalf("allocate second epoch: %v", err) + } + + if first != 1 || second != 2 { + t.Fatalf("allocated epochs = %d, %d; want 1, 2", first, second) + } + var stored string + if err := repo.db.QueryRowContext( + ctx, + repo.db.Rebind(`SELECT value FROM kandev_meta WHERE key = ?`), + pendingActionProjectionEpochMetaKey, + ).Scan(&stored); err != nil { + t.Fatalf("read stored epoch: %v", err) + } + if stored != "2" { + t.Fatalf("stored epoch = %q, want 2", stored) + } +} + +func TestNextPendingActionProjectionEpochRejectsCorruptGeneration(t *testing.T) { + repo := newRepoForEntityTests(t) + ctx := context.Background() + if _, err := repo.db.ExecContext( + ctx, + repo.db.Rebind(`INSERT INTO kandev_meta (key, value) VALUES (?, ?)`), + pendingActionProjectionEpochMetaKey, + "not-a-generation", + ); err != nil { + t.Fatalf("seed corrupt epoch: %v", err) + } + + if _, err := repo.NextPendingActionProjectionEpoch(ctx); err == nil { + t.Fatal("corrupt epoch was silently reset") + } else if !strings.Contains(err.Error(), "canonical positive integer") { + t.Fatalf("corrupt epoch error = %q, want diagnostic metadata cause", err) + } +} + +func TestPostgresNextPendingActionProjectionEpochPreservesMonotonicCorruptValue(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + ctx := context.Background() + + first, err := repo.NextPendingActionProjectionEpoch(ctx) + if err != nil { + t.Fatalf("allocate first postgres epoch: %v", err) + } + second, err := repo.NextPendingActionProjectionEpoch(ctx) + if err != nil { + t.Fatalf("allocate second postgres epoch: %v", err) + } + if first != 1 || second != 2 { + t.Fatalf("allocated postgres epochs = %d, %d; want 1, 2", first, second) + } + + const corrupt = "not-a-generation" + if _, err := db.ExecContext( + ctx, + db.Rebind(`UPDATE kandev_meta SET value = ? WHERE key = ?`), + corrupt, + pendingActionProjectionEpochMetaKey, + ); err != nil { + t.Fatalf("seed corrupt postgres epoch: %v", err) + } + if _, err := repo.NextPendingActionProjectionEpoch(ctx); err == nil { + t.Fatal("corrupt postgres epoch was silently reset") + } + var stored string + if err := db.QueryRowContext( + ctx, + db.Rebind(`SELECT value FROM kandev_meta WHERE key = ?`), + pendingActionProjectionEpochMetaKey, + ).Scan(&stored); err != nil { + t.Fatalf("read corrupt postgres epoch: %v", err) + } + if stored != corrupt { + t.Fatalf("stored postgres epoch = %q, want unchanged %q", stored, corrupt) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/session.go b/apps/backend/internal/task/repository/sqlite/session.go index 29184ae874..cf64b76737 100644 --- a/apps/backend/internal/task/repository/sqlite/session.go +++ b/apps/backend/internal/task/repository/sqlite/session.go @@ -29,7 +29,7 @@ type taskSessionExecutor interface { // CreateTurn creates a new turn func (r *Repository) CreateTurn(ctx context.Context, turn *models.Turn) error { stampTurnDefaults(turn) - return r.insertTurnRow(ctx, r.db, turn) + return r.insertTurnWithSessionLock(ctx, turn) } // CreateTurnWithStepStamp is documented on the TurnRepository interface. It @@ -45,7 +45,7 @@ func (r *Repository) CreateTurnWithStepStamp(ctx context.Context, turn *models.T tx, err := r.db.BeginTx(ctx, nil) if err != nil { - return false, r.insertTurnRow(ctx, r.db, turn) + return false, r.insertTurnWithSessionLock(ctx, turn) } committed := false defer func() { @@ -54,11 +54,14 @@ func (r *Repository) CreateTurnWithStepStamp(ctx context.Context, turn *models.T } }() + if err := lockSessionTurnWrites(ctx, tx, r.db.DriverName(), turn.TaskSessionID); err != nil { + return false, err + } _, stepID, found, stepErr := r.readTaskStepInTx(ctx, tx, turn.TaskID) if stepErr != nil { _ = tx.Rollback() committed = true - return false, r.insertTurnRow(ctx, r.db, turn) + return false, r.insertTurnWithSessionLock(ctx, turn) } stamped := false @@ -113,15 +116,79 @@ func (r *Repository) insertTurnRow(ctx context.Context, execer taskSessionExecut INSERT INTO task_session_turns (id, task_session_id, task_id, started_at, completed_at, metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `), turn.ID, turn.TaskSessionID, turn.TaskID, turn.StartedAt, turn.CompletedAt, metadataJSON, turn.CreatedAt, turn.UpdatedAt) - return err } -func scanTurnRow(row *sql.Row) (*models.Turn, error) { +// insertTurnWithSessionLock serializes successor-turn creation with every +// current-turn clarification decision on PostgreSQL. SQLite's writer pool +// already provides the equivalent serialization. +func (r *Repository) insertTurnWithSessionLock(ctx context.Context, turn *models.Turn) error { + if !dialect.IsPostgres(r.db.DriverName()) { + return r.insertTurnRow(ctx, r.db, turn) + } + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return fmt.Errorf("begin turn creation: %w", err) + } + defer func() { _ = tx.Rollback() }() + if err := lockSessionTurnWrites(ctx, tx, r.db.DriverName(), turn.TaskSessionID); err != nil { + return err + } + if err := r.insertTurnRow(ctx, tx, turn); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit turn creation: %w", err) + } + return nil +} + +// DeleteTurnIfUnreferenced removes a rejected pre-dispatch turn only while it +// has no messages. The message guard preserves an ambiguously accepted prompt. +func (r *Repository) DeleteTurnIfUnreferenced( + ctx context.Context, + sessionID, turnID string, +) (bool, error) { + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return false, fmt.Errorf("begin turn rollback: %w", err) + } + defer func() { _ = tx.Rollback() }() + if err := lockSessionTurnWrites(ctx, tx, r.db.DriverName(), sessionID); err != nil { + return false, err + } + result, err := tx.ExecContext(ctx, r.db.Rebind(` + DELETE FROM task_session_turns + WHERE id = ? + AND task_session_id = ? + AND NOT EXISTS ( + SELECT 1 + FROM task_session_messages + WHERE turn_id = task_session_turns.id + ) + `), turnID, sessionID) + if err != nil { + return false, fmt.Errorf("delete unreferenced turn: %w", err) + } + deleted, err := result.RowsAffected() + if err != nil { + return false, fmt.Errorf("inspect unreferenced turn deletion: %w", err) + } + if err := tx.Commit(); err != nil { + return false, fmt.Errorf("commit turn rollback: %w", err) + } + return deleted == 1, nil +} + +type turnScanner interface { + Scan(dest ...interface{}) error +} + +func scanTurn(scanner turnScanner) (*models.Turn, error) { turn := &models.Turn{} var metadataJSON string var completedAt sql.NullTime - err := row.Scan(&turn.ID, &turn.TaskSessionID, &turn.TaskID, &turn.StartedAt, &completedAt, &metadataJSON, &turn.CreatedAt, &turn.UpdatedAt) + err := scanner.Scan(&turn.ID, &turn.TaskSessionID, &turn.TaskID, &turn.StartedAt, &completedAt, &metadataJSON, &turn.CreatedAt, &turn.UpdatedAt) if err != nil { return nil, err } @@ -136,6 +203,10 @@ func scanTurnRow(row *sql.Row) (*models.Turn, error) { return turn, nil } +func scanTurnRow(row *sql.Row) (*models.Turn, error) { + return scanTurn(row) +} + // GetTurn retrieves a turn by ID func (r *Repository) GetTurn(ctx context.Context, id string) (*models.Turn, error) { row := r.ro.QueryRowContext(ctx, r.ro.Rebind(` @@ -147,35 +218,184 @@ func (r *Repository) GetTurn(ctx context.Context, id string) (*models.Turn, erro // GetActiveTurnBySessionID gets the currently active (non-completed) turn for a session func (r *Repository) GetActiveTurnBySessionID(ctx context.Context, sessionID string) (*models.Turn, error) { - row := r.ro.QueryRowContext(ctx, r.ro.Rebind(` + query := fmt.Sprintf(` SELECT id, task_session_id, task_id, started_at, completed_at, metadata, created_at, updated_at - FROM task_session_turns - WHERE task_session_id = ? AND completed_at IS NULL - ORDER BY started_at DESC LIMIT 1 - `), sessionID) + FROM task_session_turns turn_row + WHERE turn_row.task_session_id = ? + AND turn_row.completed_at IS NULL + AND %s + ORDER BY turn_row.started_at DESC, turn_row.created_at DESC, turn_row.id DESC + LIMIT 1 + `, turnAuthorityPredicate(r.ro.DriverName(), "turn_row")) + row := r.ro.QueryRowContext(ctx, r.ro.Rebind(query), sessionID) return scanTurnRow(row) } // UpdateTurn updates an existing turn func (r *Repository) UpdateTurn(ctx context.Context, turn *models.Turn) error { - turn.UpdatedAt = time.Now().UTC() - - metadataJSON := "{}" - if turn.Metadata != nil { - metadataBytes, err := json.Marshal(turn.Metadata) - if err != nil { - return fmt.Errorf("failed to serialize turn metadata: %w", err) - } - metadataJSON = string(metadataBytes) + metadataJSON, err := serializeTurnMetadata(turn.Metadata) + if err != nil { + return err } - - _, err := r.db.ExecContext(ctx, r.db.Rebind(` + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return fmt.Errorf("begin turn update: %w", err) + } + defer func() { _ = tx.Rollback() }() + if err := lockSessionTurnWrites(ctx, tx, r.db.DriverName(), turn.TaskSessionID); err != nil { + return err + } + updatedAt := r.nowUTC() + result, err := tx.ExecContext(ctx, r.db.Rebind(` UPDATE task_session_turns SET completed_at = ?, metadata = ?, updated_at = ? - WHERE id = ? - `), turn.CompletedAt, metadataJSON, turn.UpdatedAt, turn.ID) + WHERE id = ? AND task_session_id = ? AND updated_at = ? + `), turn.CompletedAt, metadataJSON, updatedAt, turn.ID, turn.TaskSessionID, turn.UpdatedAt) + if err != nil { + return err + } + affected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("inspect turn update: %w", err) + } + if affected != 1 { + return fmt.Errorf("update turn %s: stale metadata snapshot", turn.ID) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit turn update: %w", err) + } + turn.UpdatedAt = updatedAt + return nil +} - return err +// UpdateActiveTurnMetadata applies a narrow metadata patch without copying a +// caller's stale snapshot over unrelated fields or a concurrently completed turn. +func (r *Repository) UpdateActiveTurnMetadata( + ctx context.Context, + sessionID, turnID string, + updates map[string]interface{}, + removeKeys []string, +) (bool, map[string]interface{}, time.Time, error) { + return r.patchTurnMetadata(ctx, sessionID, turnID, updates, removeKeys, true) +} + +// PatchTurnMetadata merges metadata into an active or completed turn. +func (r *Repository) PatchTurnMetadata( + ctx context.Context, + sessionID, turnID string, + updates map[string]interface{}, +) (bool, time.Time, error) { + updated, _, updatedAt, err := r.patchTurnMetadata(ctx, sessionID, turnID, updates, nil, false) + return updated, updatedAt, err +} + +// ClearTurnPromptDispatchMetadata removes durable recovery state only after +// turn.started publication succeeds. It intentionally accepts a completed +// turn because provider output can settle a fast turn while publication is in +// progress; clearing metadata must never reopen or otherwise alter completion. +func (r *Repository) ClearTurnPromptDispatchMetadata( + ctx context.Context, + sessionID, turnID string, +) (bool, map[string]interface{}, time.Time, error) { + return r.patchTurnMetadata(ctx, sessionID, turnID, nil, []string{ + models.TurnMetaKeyPromptDispatchPending, + models.TurnMetaKeyPromptDispatchAttempted, + models.TurnMetaKeyPromptDispatchClarificationPendingID, + models.TurnMetaKeyPromptDispatchClarificationTurnID, + models.TurnMetaKeyPromptDispatchClarificationMessageIDs, + models.TurnMetaKeyPromptDispatchStartEventPending, + }, false) +} + +func (r *Repository) patchTurnMetadata( + ctx context.Context, + sessionID, turnID string, + updates map[string]interface{}, + removeKeys []string, + activeOnly bool, +) (bool, map[string]interface{}, time.Time, error) { + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return false, nil, time.Time{}, fmt.Errorf("begin active turn metadata update: %w", err) + } + defer func() { _ = tx.Rollback() }() + if err := lockSessionTurnWrites(ctx, tx, r.db.DriverName(), sessionID); err != nil { + return false, nil, time.Time{}, err + } + activeClause := "" + if activeOnly { + activeClause = " AND completed_at IS NULL" + } + selectQuery := fmt.Sprintf(` + SELECT metadata + FROM task_session_turns + WHERE id = ? AND task_session_id = ?%s + `, activeClause) + var metadataJSON string + err = tx.QueryRowContext(ctx, r.db.Rebind(selectQuery), turnID, sessionID).Scan(&metadataJSON) + if errors.Is(err, sql.ErrNoRows) { + return false, nil, time.Time{}, nil + } + if err != nil { + return false, nil, time.Time{}, fmt.Errorf("read active turn metadata: %w", err) + } + metadata, metadataJSON, err := applyTurnMetadataPatch(metadataJSON, updates, removeKeys) + if err != nil { + return false, nil, time.Time{}, err + } + updatedAt := r.nowUTC() + updateQuery := fmt.Sprintf(` + UPDATE task_session_turns + SET metadata = ?, updated_at = ? + WHERE id = ? AND task_session_id = ?%s + `, activeClause) + result, err := tx.ExecContext(ctx, r.db.Rebind(updateQuery), metadataJSON, updatedAt, turnID, sessionID) + if err != nil { + return false, nil, time.Time{}, err + } + affected, err := result.RowsAffected() + if err != nil { + return false, nil, time.Time{}, fmt.Errorf("inspect active turn metadata update: %w", err) + } + if err := tx.Commit(); err != nil { + return false, nil, time.Time{}, fmt.Errorf("commit active turn metadata update: %w", err) + } + if affected != 1 { + return false, nil, time.Time{}, nil + } + return true, metadata, updatedAt, nil +} + +func applyTurnMetadataPatch( + metadataJSON string, + updates map[string]interface{}, + removeKeys []string, +) (map[string]interface{}, string, error) { + metadata := make(map[string]interface{}) + if metadataJSON != "" && metadataJSON != "{}" { + if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { + return nil, "", fmt.Errorf("deserialize turn metadata patch: %w", err) + } + } + for key, value := range updates { + metadata[key] = value + } + for _, key := range removeKeys { + delete(metadata, key) + } + serialized, err := serializeTurnMetadata(metadata) + return metadata, serialized, err +} + +func serializeTurnMetadata(metadata map[string]interface{}) (string, error) { + if metadata == nil { + return "{}", nil + } + metadataBytes, err := json.Marshal(metadata) + if err != nil { + return "", fmt.Errorf("failed to serialize turn metadata: %w", err) + } + return string(metadataBytes), nil } // CompleteTurn marks a turn as completed with the current time @@ -208,10 +428,13 @@ func (r *Repository) AbandonTurn(ctx context.Context, id string) error { func (r *Repository) ListTurnsBySession(ctx context.Context, sessionID string) ([]*models.Turn, error) { ctx, span := tracing.Tracer("kandev-db").Start(ctx, "db.ListTurnsBySession") defer span.End() - rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` + query := fmt.Sprintf(` SELECT id, task_session_id, task_id, started_at, completed_at, metadata, created_at, updated_at - FROM task_session_turns WHERE task_session_id = ? ORDER BY started_at ASC - `), sessionID) + FROM task_session_turns turn_row + WHERE turn_row.task_session_id = ? AND %s + ORDER BY turn_row.started_at ASC, turn_row.created_at ASC, turn_row.id ASC + `, turnHistoryPredicate(r.ro.DriverName(), "turn_row")) + rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), sessionID) if err != nil { return nil, err } @@ -219,21 +442,10 @@ func (r *Repository) ListTurnsBySession(ctx context.Context, sessionID string) ( var result []*models.Turn for rows.Next() { - turn := &models.Turn{} - var metadataJSON string - var completedAt sql.NullTime - err := rows.Scan(&turn.ID, &turn.TaskSessionID, &turn.TaskID, &turn.StartedAt, &completedAt, &metadataJSON, &turn.CreatedAt, &turn.UpdatedAt) + turn, err := scanTurn(rows) if err != nil { return nil, err } - if completedAt.Valid { - turn.CompletedAt = &completedAt.Time - } - if metadataJSON != "" && metadataJSON != "{}" { - if err := json.Unmarshal([]byte(metadataJSON), &turn.Metadata); err != nil { - return nil, fmt.Errorf("failed to deserialize turn metadata: %w", err) - } - } result = append(result, turn) } if err := rows.Err(); err != nil { diff --git a/apps/backend/internal/task/repository/sqlite/session_test.go b/apps/backend/internal/task/repository/sqlite/session_test.go index 588aa35c5f..b1b2d30d51 100644 --- a/apps/backend/internal/task/repository/sqlite/session_test.go +++ b/apps/backend/internal/task/repository/sqlite/session_test.go @@ -2052,6 +2052,108 @@ func TestGetActiveTurnBySessionIDPicksNewestOpenTurn(t *testing.T) { } } +func TestGetActiveTurnBySessionIDUsesDeterministicTieBreak(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + const taskID = "task-turn-tie" + const sessionID = "session-turn-tie" + seedSessionForTurns(t, repo, taskID, sessionID) + + startedAt := time.Date(2026, 6, 1, 8, 0, 0, 0, time.UTC) + createdAt := startedAt.Add(time.Minute) + for _, id := range []string{"turn-tie-z", "turn-tie-a"} { + if err := repo.CreateTurn(ctx, &models.Turn{ + ID: id, TaskSessionID: sessionID, TaskID: taskID, + StartedAt: startedAt, CreatedAt: createdAt, + }); err != nil { + t.Fatalf("CreateTurn(%s): %v", id, err) + } + } + + active, err := repo.GetActiveTurnBySessionID(ctx, sessionID) + if err != nil { + t.Fatalf("GetActiveTurnBySessionID: %v", err) + } + if active.ID != "turn-tie-z" { + t.Fatalf("GetActiveTurnBySessionID = %q, want turn-tie-z", active.ID) + } + + listed, err := repo.ListTurnsBySession(ctx, sessionID) + if err != nil { + t.Fatalf("ListTurnsBySession: %v", err) + } + wantOrder := []string{"turn-tie-a", "turn-tie-z"} + if len(listed) != len(wantOrder) { + t.Fatalf("ListTurnsBySession returned %d turns, want %d", len(listed), len(wantOrder)) + } + for index, wantID := range wantOrder { + if listed[index].ID != wantID { + t.Fatalf("ListTurnsBySession[%d] = %q, want %q", index, listed[index].ID, wantID) + } + } +} + +func TestTurnReadsHideEmptyUnpublishedReservationUntilMessageEvidence(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + const taskID = "task-turn-reserved" + const sessionID = "session-turn-reserved" + seedSessionForTurns(t, repo, taskID, sessionID) + base := time.Date(2026, 8, 15, 18, 0, 0, 0, time.UTC) + for _, turn := range []*models.Turn{ + {ID: "turn-accepted", TaskSessionID: sessionID, TaskID: taskID, StartedAt: base}, + { + ID: "turn-unpublished", TaskSessionID: sessionID, TaskID: taskID, + StartedAt: base.Add(time.Minute), + Metadata: map[string]interface{}{ + models.TurnMetaKeyPromptDispatchPending: true, + models.TurnMetaKeyPromptDispatchAttempted: true, + }, + }, + } { + if err := repo.CreateTurn(ctx, turn); err != nil { + t.Fatalf("CreateTurn(%s): %v", turn.ID, err) + } + } + + active, err := repo.GetActiveTurnBySessionID(ctx, sessionID) + if err != nil { + t.Fatalf("GetActiveTurnBySessionID: %v", err) + } + if active.ID != "turn-unpublished" { + t.Fatalf("active turn = %q, want attempted reservation", active.ID) + } + listed, err := repo.ListTurnsBySession(ctx, sessionID) + if err != nil { + t.Fatalf("ListTurnsBySession: %v", err) + } + if len(listed) != 1 || listed[0].ID != "turn-accepted" { + t.Fatalf("listed turns = %#v, want only accepted predecessor", listed) + } + + if err := repo.CreateMessage(ctx, &models.Message{ + ID: "message-reserved-output", TaskSessionID: sessionID, TaskID: taskID, + TurnID: "turn-unpublished", AuthorType: models.MessageAuthorAgent, + Type: models.MessageTypeMessage, Content: "accepted output", CreatedAt: base.Add(2 * time.Minute), + }); err != nil { + t.Fatalf("CreateMessage: %v", err) + } + active, err = repo.GetActiveTurnBySessionID(ctx, sessionID) + if err != nil { + t.Fatalf("GetActiveTurnBySessionID after output: %v", err) + } + if active.ID != "turn-unpublished" { + t.Fatalf("active turn after output = %q, want ambiguous accepted reservation", active.ID) + } + listed, err = repo.ListTurnsBySession(ctx, sessionID) + if err != nil { + t.Fatalf("ListTurnsBySession after output: %v", err) + } + if len(listed) != 2 || listed[1].ID != "turn-unpublished" { + t.Fatalf("listed turns after output = %#v, want reservation restored", listed) + } +} + func TestUpdateTurnWritesCompletionAndMetadata(t *testing.T) { repo := newRepoForSessionTests(t) ctx := context.Background() @@ -2101,6 +2203,48 @@ func TestUpdateTurnWritesCompletionAndMetadata(t *testing.T) { } } +func TestUpdateTurnRejectsSnapshotStaleBehindMetadataPatch(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + seedSessionForTurns(t, repo, "task-turn-stale", "session-turn-stale") + turn := &models.Turn{ + ID: "turn-stale", TaskSessionID: "session-turn-stale", TaskID: "task-turn-stale", + Metadata: map[string]interface{}{"initial": true}, + } + if err := repo.CreateTurn(ctx, turn); err != nil { + t.Fatalf("CreateTurn: %v", err) + } + stale, err := repo.GetTurn(ctx, turn.ID) + if err != nil { + t.Fatalf("GetTurn(stale snapshot): %v", err) + } + updated, _, _, err := repo.UpdateActiveTurnMetadata( + ctx, + turn.TaskSessionID, + turn.ID, + map[string]interface{}{models.TurnMetaKeyPromptDispatchAttempted: true}, + nil, + ) + if err != nil || !updated { + t.Fatalf("UpdateActiveTurnMetadata: updated=%v err=%v", updated, err) + } + stale.Metadata["prompt_usage"] = map[string]interface{}{"input_tokens": float64(1)} + + if err := repo.UpdateTurn(ctx, stale); err == nil { + t.Fatal("UpdateTurn accepted a stale full metadata snapshot") + } + persisted, err := repo.GetTurn(ctx, turn.ID) + if err != nil { + t.Fatalf("GetTurn(persisted): %v", err) + } + if attempted, _ := persisted.Metadata[models.TurnMetaKeyPromptDispatchAttempted].(bool); !attempted { + t.Fatalf("stale update dropped dispatch-attempt marker: %#v", persisted.Metadata) + } + if _, exists := persisted.Metadata["prompt_usage"]; exists { + t.Fatalf("stale update committed prompt metadata: %#v", persisted.Metadata) + } +} + func TestCompleteTurnStampsNowAndAbandonTurnCollapsesToStartedAt(t *testing.T) { repo := newRepoForSessionTests(t) ctx := context.Background() diff --git a/apps/backend/internal/task/repository/sqlite/session_turn_lock.go b/apps/backend/internal/task/repository/sqlite/session_turn_lock.go new file mode 100644 index 0000000000..fdd4bd6c73 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/session_turn_lock.go @@ -0,0 +1,50 @@ +package sqlite + +import ( + "context" + "fmt" + "sort" + + "github.com/kandev/kandev/internal/db/dialect" +) + +const sessionTurnLockNamespace = "task-session-turn:" + +// lockSessionTurnWrites serializes current-turn decisions with successor-turn +// creation on PostgreSQL. The lock must be acquired in its own statement so a +// following READ COMMITTED statement observes commits made by the prior owner. +// All message types participate intentionally: any durable successor-turn +// message is acceptance evidence during reserved-prompt recovery, including +// ordinary agent output and tool results, not only clarification messages. +func lockSessionTurnWrites( + ctx context.Context, + tx taskSessionExecutor, + driverName string, + sessionIDs ...string, +) error { + if !dialect.IsPostgres(driverName) { + // SQLite's database-level single-writer lock already serializes these writes. + return nil + } + unique := make(map[string]struct{}, len(sessionIDs)) + for _, sessionID := range sessionIDs { + if sessionID != "" { + unique[sessionID] = struct{}{} + } + } + ordered := make([]string, 0, len(unique)) + for sessionID := range unique { + ordered = append(ordered, sessionID) + } + sort.Strings(ordered) + for _, sessionID := range ordered { + if _, err := tx.ExecContext( + ctx, + `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, + sessionTurnLockNamespace+sessionID, + ); err != nil { + return fmt.Errorf("lock turn writes for session %q: %w", sessionID, err) + } + } + return nil +} diff --git a/apps/backend/internal/task/repository/sqlite/turn_authority.go b/apps/backend/internal/task/repository/sqlite/turn_authority.go new file mode 100644 index 0000000000..6d4d385547 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/turn_authority.go @@ -0,0 +1,71 @@ +package sqlite + +import ( + "fmt" + + "github.com/kandev/kandev/internal/db/dialect" + "github.com/kandev/kandev/internal/task/models" +) + +// turnAuthorityPredicate excludes an empty successor reservation until prompt +// dispatch is attempted or published. An attempt marker or referencing message +// is durable evidence that the prompt may have been accepted before a crash, so +// that turn remains current. The orchestrator keeps its live reservation private +// from ready handling until dispatch resolution. +// +// Startup recovery removes stale empty reservations before prompt admission +// resumes. A new live unattempted reservation remains intentionally excluded +// until dispatch resolution or durable attempt evidence makes it authoritative. +func turnAuthorityPredicate(driverName, turnAlias string) string { + pending := turnDispatchPendingPredicate(driverName, turnAlias) + attempted := turnDispatchAttemptedPredicate(driverName, turnAlias) + // Positive invariant: a turn is authoritative once it is not a pure + // reservation, or durable attempt/message evidence proves it was started. + return fmt.Sprintf(`( + NOT (%s) + OR (%s) + OR EXISTS ( + SELECT 1 + FROM task_session_messages turn_authority_message + WHERE turn_authority_message.turn_id = %s.id + ) + )`, pending, attempted, turnAlias) +} + +// turnHistoryPredicate keeps live prompt reservations out of client history. +// An attempt marker is authority for crash recovery, but not publication: the +// reservation can still be rejected and rolled back without a turn event. +func turnHistoryPredicate(driverName, turnAlias string) string { + pending := turnDispatchPendingPredicate(driverName, turnAlias) + return fmt.Sprintf(`( + NOT (%s) + OR EXISTS ( + SELECT 1 + FROM task_session_messages turn_history_message + WHERE turn_history_message.turn_id = %s.id + ) + )`, pending, turnAlias) +} + +func turnDispatchPendingPredicate(driverName, turnAlias string) string { + return turnMetadataFlagPredicate( + driverName, turnAlias, models.TurnMetaKeyPromptDispatchPending, + ) +} + +func turnDispatchAttemptedPredicate(driverName, turnAlias string) string { + return turnMetadataFlagPredicate( + driverName, turnAlias, models.TurnMetaKeyPromptDispatchAttempted, + ) +} + +func turnMetadataFlagPredicate(driverName, turnAlias, key string) string { + value := dialect.JSONExtract( + driverName, + turnAlias+".metadata", + key, + ) + // Metadata normally stores JSON booleans, but older/manual rows can contain + // equivalent string values. Normalizing to text keeps both dialects tolerant. + return fmt.Sprintf("CAST(COALESCE(%s, '') AS TEXT) IN ('true', '1')", value) +} diff --git a/apps/backend/internal/task/repository/sqlite/turn_authority_test.go b/apps/backend/internal/task/repository/sqlite/turn_authority_test.go new file mode 100644 index 0000000000..2311797e52 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/turn_authority_test.go @@ -0,0 +1,45 @@ +package sqlite + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/task/models" +) + +func TestTurnAuthorityToleratesStringFlagEncodings(t *testing.T) { + for _, tt := range []struct { + name string + pending interface{} + attempted interface{} + wantCurrentID string + }{ + {name: "pending string true", pending: "true", wantCurrentID: "turn-previous"}, + {name: "pending string one", pending: "1", wantCurrentID: "turn-previous"}, + {name: "attempted string true", pending: true, attempted: "true", wantCurrentID: "turn-reserved"}, + {name: "attempted string one", pending: true, attempted: "1", wantCurrentID: "turn-reserved"}, + } { + t.Run(tt.name, func(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + const taskID = "task-string-flags" + const sessionID = "session-string-flags" + seedSessionForTurns(t, repo, taskID, sessionID) + base := time.Date(2026, time.August, 15, 22, 0, 0, 0, time.UTC) + createRecoveryTurn(t, repo, taskID, sessionID, "turn-previous", base, nil) + createRecoveryTurn(t, repo, taskID, sessionID, "turn-reserved", base.Add(time.Minute), map[string]interface{}{ + models.TurnMetaKeyPromptDispatchPending: tt.pending, + models.TurnMetaKeyPromptDispatchAttempted: tt.attempted, + }) + + active, err := repo.GetActiveTurnBySessionID(ctx, sessionID) + if err != nil { + t.Fatalf("GetActiveTurnBySessionID: %v", err) + } + if active == nil || active.ID != tt.wantCurrentID { + t.Fatalf("active turn = %#v, want %s", active, tt.wantCurrentID) + } + }) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/turn_reservation_recovery.go b/apps/backend/internal/task/repository/sqlite/turn_reservation_recovery.go new file mode 100644 index 0000000000..676993749e --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/turn_reservation_recovery.go @@ -0,0 +1,460 @@ +package sqlite + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/db/dialect" + "github.com/kandev/kandev/internal/task/models" +) + +type unpublishedPromptTurn struct { + id string + sessionID string +} + +const metadataTrueString = "true" + +// ReconcileUnpublishedPromptTurns repairs durable prompt reservations first, +// then response-delivery claims left behind before their handoff completed. +func (r *Repository) ReconcileUnpublishedPromptTurns(ctx context.Context) (int, error) { + turns, err := r.listUnpublishedPromptTurns(ctx) + if err != nil { + return 0, err + } + reconciled := 0 + var reconcileErrs []error + for _, turn := range turns { + changed, reconcileErr := r.reconcileUnpublishedPromptTurn(ctx, turn) + if reconcileErr != nil { + reconcileErrs = append(reconcileErrs, fmt.Errorf( + "reconcile unpublished prompt turn %s for session %s: %w", + turn.id, + turn.sessionID, + reconcileErr, + )) + continue + } + if changed { + reconciled++ + } + } + deliveries, deliveryErr := r.reconcilePendingClarificationResponseDeliveries(ctx) + if deliveryErr != nil { + reconcileErrs = append(reconcileErrs, deliveryErr) + } + return reconciled + deliveries, errors.Join(reconcileErrs...) +} + +// ListTurnsPendingStartEvent returns the durable event outbox in deterministic +// turn order. Startup replays every row before prompt admission, then clears +// the marker through ClearTurnPromptDispatchMetadata. +func (r *Repository) ListTurnsPendingStartEvent(ctx context.Context) ([]*models.Turn, error) { + query := fmt.Sprintf(` + SELECT id, task_session_id, task_id, started_at, completed_at, metadata, created_at, updated_at + FROM task_session_turns turn_row + WHERE %s + ORDER BY turn_row.task_session_id, turn_row.started_at, turn_row.created_at, turn_row.id + `, turnMetadataFlagPredicate( + r.ro.DriverName(), + "turn_row", + models.TurnMetaKeyPromptDispatchStartEventPending, + )) + rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query)) + if err != nil { + return nil, fmt.Errorf("list turns pending start-event replay: %w", err) + } + defer func() { _ = rows.Close() }() + + var turns []*models.Turn + for rows.Next() { + turn, scanErr := scanTurn(rows) + if scanErr != nil { + return nil, fmt.Errorf("scan turn pending start-event replay: %w", scanErr) + } + turns = append(turns, turn) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate turns pending start-event replay: %w", err) + } + return turns, nil +} + +func (r *Repository) listUnpublishedPromptTurns(ctx context.Context) ([]unpublishedPromptTurn, error) { + driverName := r.ro.DriverName() + query := fmt.Sprintf(` + SELECT turn_row.id, turn_row.task_session_id + FROM task_session_turns turn_row + WHERE %s + ORDER BY turn_row.task_session_id, turn_row.started_at, turn_row.id + `, turnDispatchPendingPredicate(driverName, "turn_row")) + rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query)) + if err != nil { + return nil, fmt.Errorf("list unpublished prompt turns: %w", err) + } + defer func() { _ = rows.Close() }() + var turns []unpublishedPromptTurn + for rows.Next() { + var turn unpublishedPromptTurn + if err := rows.Scan(&turn.id, &turn.sessionID); err != nil { + return nil, fmt.Errorf("scan unpublished prompt turn: %w", err) + } + turns = append(turns, turn) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate unpublished prompt turns: %w", err) + } + return turns, nil +} + +func (r *Repository) reconcileUnpublishedPromptTurn( + ctx context.Context, + turn unpublishedPromptTurn, +) (bool, error) { + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return false, fmt.Errorf("begin unpublished prompt turn recovery: %w", err) + } + defer func() { _ = tx.Rollback() }() + if err := lockSessionTurnWrites(ctx, tx, r.db.DriverName(), turn.sessionID); err != nil { + return false, err + } + metadata, err := r.loadUnpublishedPromptTurnMetadata(ctx, tx, turn) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, err + } + referenced, err := r.promptTurnHasMessages(ctx, tx, turn.id) + if err != nil { + return false, err + } + updatedAt := r.nowUTC() + attempted := metadataFlagIsTrue(metadata[models.TurnMetaKeyPromptDispatchAttempted]) + if attempted || referenced { + err = r.finalizeReservedClarificationDelivery(ctx, tx, metadata) + if err == nil { + models.ClearPromptDispatchMetadata(metadata) + metadata[models.TurnMetaKeyPromptDispatchStartEventPending] = true + err = updateTurnMetadata(ctx, tx, r.db, turn.id, metadata, updatedAt) + } + } else { + err = r.restoreReservedClarificationClaim(ctx, tx, metadata, updatedAt) + if err == nil { + err = r.deleteEmptyUnpublishedPromptTurn(ctx, tx, turn) + } + } + if err != nil { + return false, err + } + if err := tx.Commit(); err != nil { + return false, fmt.Errorf("commit unpublished prompt turn recovery: %w", err) + } + return true, nil +} + +func (r *Repository) finalizeReservedClarificationDelivery( + ctx context.Context, + tx *sqlx.Tx, + metadata map[string]interface{}, +) error { + claim, err := reservedClarificationClaimFromMetadata(metadata) + if err != nil { + return err + } + if claim == nil { + return nil + } + claimedIDs := make(map[string]struct{}, len(claim.messageIDs)) + for _, messageID := range claim.messageIDs { + claimedIDs[messageID] = struct{}{} + } + messages, err := r.loadPendingClarificationResponseDeliveryBundle( + ctx, + tx, + r.db.DriverName(), + claim.pendingID, + ) + if err != nil { + return err + } + if len(messages) > 0 && len(messages) != len(claimedIDs) { + return fmt.Errorf( + "response delivery intent expected %d messages, found %d for pending_id %s", + len(claimedIDs), + len(messages), + claim.pendingID, + ) + } + for _, message := range messages { + if _, claimed := claimedIDs[message.ID]; !claimed { + return fmt.Errorf("response delivery intent includes unreserved message %s", message.ID) + } + if message.TurnID != claim.turnID { + return fmt.Errorf( + "response delivery intent message %s belongs to turn %s, want %s", + message.ID, + message.TurnID, + claim.turnID, + ) + } + if status, _ := message.Metadata["status"].(string); status != clarificationStatusAnswered { + return fmt.Errorf("response delivery intent message %s has status %q", message.ID, status) + } + } + if len(messages) == 0 { + return nil + } + return r.clearClarificationResponseDeliveryMarkers( + ctx, + tx, + r.db.DriverName(), + messages, + clarificationStatusAnswered, + ) +} + +type reservedClarificationClaim struct { + pendingID string + turnID string + messageIDs []string +} + +func reservedClarificationClaimFromMetadata( + metadata map[string]interface{}, +) (*reservedClarificationClaim, error) { + pendingID, _ := metadata[models.TurnMetaKeyPromptDispatchClarificationPendingID].(string) + turnID, _ := metadata[models.TurnMetaKeyPromptDispatchClarificationTurnID].(string) + messageIDs, err := metadataStringSlice(metadata[models.TurnMetaKeyPromptDispatchClarificationMessageIDs]) + if err != nil { + return nil, fmt.Errorf("decode prompt dispatch clarification message ids: %w", err) + } + hasPendingID := pendingID != "" + hasTurnID := turnID != "" + hasMessageIDs := len(messageIDs) > 0 + if !hasPendingID && !hasTurnID && !hasMessageIDs { + return nil, nil + } + if !hasPendingID || !hasTurnID || !hasMessageIDs { + return nil, fmt.Errorf( + "incomplete prompt dispatch clarification metadata: pending_id=%t turn_id=%t message_ids=%t", + hasPendingID, + hasTurnID, + hasMessageIDs, + ) + } + return &reservedClarificationClaim{ + pendingID: pendingID, + turnID: turnID, + messageIDs: messageIDs, + }, nil +} + +// metadataFlagIsTrue mirrors the SQL JSON predicates across supported +// boolean, string, and numeric encodings of a true metadata flag. +func metadataFlagIsTrue(value interface{}) bool { + switch typed := value.(type) { + case bool: + return typed + case string: + return typed == metadataTrueString || typed == "1" + case float64: + return typed == 1 + default: + return false + } +} + +func (r *Repository) loadUnpublishedPromptTurnMetadata( + ctx context.Context, + tx *sqlx.Tx, + turn unpublishedPromptTurn, +) (map[string]interface{}, error) { + driverName := r.db.DriverName() + query := fmt.Sprintf(` + SELECT turn_row.metadata + FROM task_session_turns turn_row + WHERE turn_row.id = ? AND turn_row.task_session_id = ? AND %s + `, turnDispatchPendingPredicate(driverName, "turn_row")) + var raw string + if err := tx.GetContext(ctx, &raw, r.db.Rebind(query), turn.id, turn.sessionID); err != nil { + return nil, err + } + metadata := map[string]interface{}{} + if raw != "" { + if err := json.Unmarshal([]byte(raw), &metadata); err != nil { + return nil, fmt.Errorf("decode unpublished prompt turn %s metadata: %w", turn.id, err) + } + } + return metadata, nil +} + +func (r *Repository) promptTurnHasMessages(ctx context.Context, tx *sqlx.Tx, turnID string) (bool, error) { + var referenced bool + if err := tx.GetContext(ctx, &referenced, r.db.Rebind(` + SELECT EXISTS (SELECT 1 FROM task_session_messages WHERE turn_id = ?) + `), turnID); err != nil { + return false, fmt.Errorf("inspect unpublished prompt turn %s messages: %w", turnID, err) + } + return referenced, nil +} + +func updateTurnMetadata( + ctx context.Context, + tx *sqlx.Tx, + db *sqlx.DB, + turnID string, + metadata map[string]interface{}, + updatedAt time.Time, +) error { + raw, err := json.Marshal(metadata) + if err != nil { + return fmt.Errorf("encode published prompt turn %s metadata: %w", turnID, err) + } + if _, err := tx.ExecContext(ctx, db.Rebind(` + UPDATE task_session_turns SET metadata = ?, updated_at = ? WHERE id = ? + `), string(raw), updatedAt, turnID); err != nil { + return fmt.Errorf("publish message-backed prompt turn %s: %w", turnID, err) + } + return nil +} + +func (r *Repository) restoreReservedClarificationClaim( + ctx context.Context, + tx *sqlx.Tx, + metadata map[string]interface{}, + updatedAt time.Time, +) error { + pendingID, _ := metadata[models.TurnMetaKeyPromptDispatchClarificationPendingID].(string) + turnID, _ := metadata[models.TurnMetaKeyPromptDispatchClarificationTurnID].(string) + messageIDs, err := metadataStringSlice(metadata[models.TurnMetaKeyPromptDispatchClarificationMessageIDs]) + if err != nil { + return fmt.Errorf("decode prompt dispatch clarification message ids: %w", err) + } + if pendingID == "" || turnID == "" || len(messageIDs) == 0 { + return nil + } + for _, messageID := range messageIDs { + if err := r.restoreReservedClarificationMessage( + ctx, tx, messageID, pendingID, turnID, updatedAt, + ); err != nil { + return err + } + } + return nil +} + +func (r *Repository) restoreReservedClarificationMessage( + ctx context.Context, + tx *sqlx.Tx, + messageID, pendingID, turnID string, + updatedAt time.Time, +) error { + driverName := r.db.DriverName() + pendingIDExpr := dialect.JSONExtract(driverName, "metadata", "pending_id") + query := fmt.Sprintf(` + SELECT metadata + FROM task_session_messages + WHERE id = ? AND turn_id = ? AND type = 'clarification_request' AND %s = ? + `, pendingIDExpr) + var raw string + if err := tx.GetContext(ctx, &raw, r.db.Rebind(query), messageID, turnID, pendingID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil + } + return fmt.Errorf("load clarification message %s for prompt recovery: %w", messageID, err) + } + metadata := map[string]interface{}{} + if err := json.Unmarshal([]byte(raw), &metadata); err != nil { + return fmt.Errorf("decode clarification message %s for prompt recovery: %w", messageID, err) + } + // Only an answered bundle was claimed for the unpublished resume prompt. + // Pending or rejected bundles were never reserved and must remain unchanged. + if metadata["status"] != clarificationStatusAnswered { + return nil + } + metadata["status"] = clarificationStatusPending + delete(metadata, "response") + delete(metadata, clarificationResponseDeliveryPendingKey) + updated, err := json.Marshal(metadata) + if err != nil { + return fmt.Errorf("encode clarification message %s for prompt recovery: %w", messageID, err) + } + statusExpr := dialect.JSONExtract(driverName, "metadata", "status") + result, err := tx.ExecContext(ctx, r.db.Rebind(fmt.Sprintf(` + UPDATE task_session_messages SET metadata = ?, updated_at = ? + WHERE id = ? AND %s = ? + `, statusExpr)), string(updated), updatedAt, messageID, clarificationStatusAnswered) + if err != nil { + return fmt.Errorf("restore clarification message %s after unpublished prompt: %w", messageID, err) + } + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("count restored clarification message %s: %w", messageID, err) + } + if rows != 1 { + return fmt.Errorf("clarification message %s changed during prompt recovery", messageID) + } + return nil +} + +func (r *Repository) deleteEmptyUnpublishedPromptTurn( + ctx context.Context, + tx *sqlx.Tx, + turn unpublishedPromptTurn, +) error { + query := fmt.Sprintf(` + DELETE FROM task_session_turns + WHERE id = ? AND task_session_id = ? AND %s + AND NOT EXISTS (SELECT 1 FROM task_session_messages WHERE turn_id = task_session_turns.id) + `, turnDispatchPendingPredicate(r.db.DriverName(), "task_session_turns")) + result, err := tx.ExecContext(ctx, r.db.Rebind(query), turn.id, turn.sessionID) + if err != nil { + return fmt.Errorf("delete unpublished prompt turn %s: %w", turn.id, err) + } + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("count deleted unpublished prompt turn %s: %w", turn.id, err) + } + if rows != 1 { + // The session write lock serializes message creation and reservation + // publication with the checks above and this DELETE. Zero rows therefore + // means an unexpected unowned write or corrupted reservation, so fail closed. + return fmt.Errorf("unpublished prompt turn %s could not be removed (rows=%d) during recovery", turn.id, rows) + } + return nil +} + +func metadataStringSlice(value interface{}) ([]string, error) { + if value == nil { + return nil, nil + } + if values, ok := value.([]string); ok { + for index, value := range values { + if value == "" { + return nil, fmt.Errorf("entry %d is empty", index) + } + } + return values, nil + } + values, ok := value.([]interface{}) + if !ok { + return nil, fmt.Errorf("expected an array, got %T", value) + } + result := make([]string, 0, len(values)) + for index, value := range values { + text, ok := value.(string) + if !ok || text == "" { + return nil, fmt.Errorf("entry %d is not a non-empty string", index) + } + result = append(result, text) + } + return result, nil +} diff --git a/apps/backend/internal/task/repository/sqlite/turn_reservation_recovery_postgres_test.go b/apps/backend/internal/task/repository/sqlite/turn_reservation_recovery_postgres_test.go new file mode 100644 index 0000000000..b00aa5873a --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/turn_reservation_recovery_postgres_test.go @@ -0,0 +1,375 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "fmt" + "testing" + "time" + + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/testutil" +) + +func TestPostgresTurnAuthorityToleratesStringFlagEncodings(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + ctx := context.Background() + base := time.Date(2026, time.August, 15, 22, 0, 0, 0, time.UTC) + for index, tt := range []struct { + name string + pending interface{} + attempted interface{} + wantCurrentID string + }{ + {name: "pending string true", pending: "true", wantCurrentID: "turn-previous"}, + {name: "pending string one", pending: "1", wantCurrentID: "turn-previous"}, + {name: "attempted string true", pending: true, attempted: "true", wantCurrentID: "turn-reserved"}, + {name: "attempted string one", pending: true, attempted: "1", wantCurrentID: "turn-reserved"}, + } { + t.Run(tt.name, func(t *testing.T) { + taskID := fmt.Sprintf("task-string-flags-pg-%d", index) + sessionID := fmt.Sprintf("session-string-flags-pg-%d", index) + previousID := fmt.Sprintf("turn-previous-pg-%d", index) + reservedID := fmt.Sprintf("turn-reserved-pg-%d", index) + seedSessionForTurns(t, repo, taskID, sessionID) + createRecoveryTurn(t, repo, taskID, sessionID, previousID, base, nil) + createRecoveryTurn(t, repo, taskID, sessionID, reservedID, base.Add(time.Minute), map[string]interface{}{ + models.TurnMetaKeyPromptDispatchPending: tt.pending, + models.TurnMetaKeyPromptDispatchAttempted: tt.attempted, + }) + + active, err := repo.GetActiveTurnBySessionID(ctx, sessionID) + if err != nil { + t.Fatalf("GetActiveTurnBySessionID: %v", err) + } + var wantCurrentID string + switch tt.wantCurrentID { + case "turn-previous": + wantCurrentID = previousID + case "turn-reserved": + wantCurrentID = reservedID + default: + t.Fatalf("unknown expected turn %q", tt.wantCurrentID) + } + if active == nil || active.ID != wantCurrentID { + t.Fatalf("active turn = %#v, want %s", active, wantCurrentID) + } + }) + } +} + +func TestPostgresListTurnsHidesEmptyReservationUntilMessageEvidence(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + ctx := context.Background() + const taskID = "task-turn-list-pg" + const sessionID = "session-turn-list-pg" + seedSessionForTurns(t, repo, taskID, sessionID) + base := time.Date(2026, time.August, 16, 9, 0, 0, 0, time.UTC) + createRecoveryTurn(t, repo, taskID, sessionID, "turn-accepted-pg", base, nil) + createRecoveryTurn(t, repo, taskID, sessionID, "turn-reserved-pg", base.Add(time.Minute), map[string]interface{}{ + models.TurnMetaKeyPromptDispatchPending: true, + models.TurnMetaKeyPromptDispatchAttempted: true, + }) + + listed, err := repo.ListTurnsBySession(ctx, sessionID) + if err != nil { + t.Fatalf("ListTurnsBySession before output: %v", err) + } + if len(listed) != 1 || listed[0].ID != "turn-accepted-pg" { + t.Fatalf("listed turns before output = %#v, want only accepted predecessor", listed) + } + + if err := repo.CreateMessage(ctx, &models.Message{ + ID: "message-reserved-output-pg", TaskSessionID: sessionID, TaskID: taskID, + TurnID: "turn-reserved-pg", AuthorType: models.MessageAuthorAgent, + Type: models.MessageTypeMessage, Content: "accepted output", CreatedAt: base.Add(2 * time.Minute), + }); err != nil { + t.Fatalf("CreateMessage: %v", err) + } + listed, err = repo.ListTurnsBySession(ctx, sessionID) + if err != nil { + t.Fatalf("ListTurnsBySession after output: %v", err) + } + if len(listed) != 2 || listed[1].ID != "turn-reserved-pg" { + t.Fatalf("listed turns after output = %#v, want reservation restored", listed) + } +} + +// TestPostgresReconcileUnpublishedPromptTurns pins restart recovery on the +// second repository dialect. It skips unless KANDEV_TEST_POSTGRES_DSN is set. +func TestPostgresReconcileUnpublishedPromptTurns(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + ctx := context.Background() + const taskID = "task-reservation-recovery-pg" + const sessionID = "session-reservation-recovery-pg" + seedSessionForTurns(t, repo, taskID, sessionID) + base := time.Date(2026, time.August, 15, 21, 0, 0, 0, time.UTC) + createRecoveryTurn(t, repo, taskID, sessionID, "turn-clarification-pg", base, nil) + createRecoveryClarification( + t, repo, "message-claimed-pg", taskID, sessionID, + "turn-clarification-pg", "pending-recovery-pg", base, + ) + createRecoveryTurn(t, repo, taskID, sessionID, "turn-unpublished-pg", base.Add(time.Minute), map[string]interface{}{ + models.TurnMetaKeyPromptDispatchPending: true, + models.TurnMetaKeyPromptDispatchClarificationPendingID: "pending-recovery-pg", + models.TurnMetaKeyPromptDispatchClarificationTurnID: "turn-clarification-pg", + models.TurnMetaKeyPromptDispatchClarificationMessageIDs: []string{"message-claimed-pg"}, + }) + + reconciled, err := repo.ReconcileUnpublishedPromptTurns(ctx) + if err != nil || reconciled != 1 { + t.Fatalf("ReconcileUnpublishedPromptTurns = %d, %v; want 1, nil", reconciled, err) + } + if _, err := repo.GetTurn(ctx, "turn-unpublished-pg"); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("GetTurn(unpublished) error = %v, want sql.ErrNoRows", err) + } + message, err := repo.GetMessage(ctx, "message-claimed-pg") + if err != nil { + t.Fatalf("GetMessage(claimed): %v", err) + } + if message.Metadata["status"] != "pending" || message.Metadata["response"] != nil { + t.Fatalf("recovered postgres metadata = %#v", message.Metadata) + } +} + +func TestPostgresDeliveryRecoverySkipsBundleOwnedByFailedReservation(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + ctx := context.Background() + base := time.Date(2026, time.August, 16, 21, 20, 0, 0, time.UTC) + seedSessionForTurns(t, repo, "task-owned-pg", "session-owned-pg") + createRecoveryTurn(t, repo, "task-owned-pg", "session-owned-pg", "turn-owned-pg", base, nil) + for index, messageID := range []string{"message-owned-a-pg", "message-owned-b-pg"} { + createRecoveryClarification( + t, repo, messageID, "task-owned-pg", "session-owned-pg", "turn-owned-pg", + "pending-owned-pg", base.Add(time.Duration(index)*time.Second), + ) + } + markRecoveryClarificationDeliveryPending(t, repo, "message-owned-a-pg") + createRecoveryTurn( + t, repo, "task-owned-pg", "session-owned-pg", "turn-reserved-owned-pg", base.Add(time.Minute), + map[string]interface{}{ + models.TurnMetaKeyPromptDispatchPending: true, + models.TurnMetaKeyPromptDispatchAttempted: true, + models.TurnMetaKeyPromptDispatchClarificationPendingID: "pending-owned-pg", + models.TurnMetaKeyPromptDispatchClarificationTurnID: "turn-owned-pg", + models.TurnMetaKeyPromptDispatchClarificationMessageIDs: []string{"message-owned-a-pg", "message-owned-b-pg"}, + }, + ) + + seedPendingActionSession(t, repo, "task-independent-pg", "session-independent-pg") + createPendingActionTurn( + t, repo, "task-independent-pg", "session-independent-pg", "turn-independent-pg", base, base, + ) + createClarificationBundleMessage( + t, repo, "message-independent-pg", "task-independent-pg", "session-independent-pg", + "turn-independent-pg", "pending-independent-pg", "q1", base, + ) + _, claimed, err := repo.CompleteActiveClarificationBundle( + ctx, + "pending-independent-pg", + clarificationStatusAnswered, + map[string]interface{}{"q1": map[string]interface{}{"question_id": "q1"}}, + ) + if err != nil || !claimed { + t.Fatalf("claim independent delivery = %v, %v; want true, nil", claimed, err) + } + + reconciled, err := repo.ReconcileUnpublishedPromptTurns(ctx) + if err == nil || reconciled != 1 { + t.Fatalf("ReconcileUnpublishedPromptTurns = %d, %v; want 1 and reservation error", reconciled, err) + } + owned, err := repo.GetMessage(ctx, "message-owned-a-pg") + if err != nil { + t.Fatalf("GetMessage(owned): %v", err) + } + if owned.Metadata[clarificationResponseDeliveryPendingKey] != true { + t.Fatalf("owned delivery marker changed: %#v", owned.Metadata) + } + independent, err := repo.GetMessage(ctx, "message-independent-pg") + if err != nil { + t.Fatalf("GetMessage(independent): %v", err) + } + if independent.Metadata["status"] != clarificationStatusPending { + t.Fatalf("independent delivery status = %v, want pending", independent.Metadata["status"]) + } +} + +func TestPostgresReconcileAmbiguousEmptyPromptTurn(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + ctx := context.Background() + const taskID = "task-reservation-accepted-pg" + const sessionID = "session-reservation-accepted-pg" + seedSessionForTurns(t, repo, taskID, sessionID) + base := time.Date(2026, time.August, 15, 21, 30, 0, 0, time.UTC) + createRecoveryTurn(t, repo, taskID, sessionID, "turn-clarification-accepted-pg", base, nil) + createRecoveryClarification( + t, repo, "message-accepted-pg", taskID, sessionID, + "turn-clarification-accepted-pg", "pending-accepted-pg", base, + ) + createRecoveryTurn(t, repo, taskID, sessionID, "turn-reservation-accepted-pg", base.Add(time.Minute), map[string]interface{}{ + models.TurnMetaKeyPromptDispatchPending: true, + models.TurnMetaKeyPromptDispatchAttempted: true, + models.TurnMetaKeyPromptDispatchClarificationPendingID: "pending-accepted-pg", + models.TurnMetaKeyPromptDispatchClarificationTurnID: "turn-clarification-accepted-pg", + models.TurnMetaKeyPromptDispatchClarificationMessageIDs: []string{"message-accepted-pg"}, + }) + + reconciled, err := repo.ReconcileUnpublishedPromptTurns(ctx) + if err != nil || reconciled != 1 { + t.Fatalf("ReconcileUnpublishedPromptTurns = %d, %v; want 1, nil", reconciled, err) + } + active, err := repo.GetActiveTurnBySessionID(ctx, sessionID) + if err != nil || active.ID != "turn-reservation-accepted-pg" { + t.Fatalf("accepted active turn = %#v, %v", active, err) + } + if pending, _ := active.Metadata[models.TurnMetaKeyPromptDispatchStartEventPending].(bool); !pending { + t.Fatalf("accepted active turn metadata = %#v, want durable start-event marker", active.Metadata) + } + pendingEvents, err := repo.ListTurnsPendingStartEvent(ctx) + if err != nil { + t.Fatalf("ListTurnsPendingStartEvent: %v", err) + } + if len(pendingEvents) != 1 || pendingEvents[0].ID != active.ID { + t.Fatalf("turns pending start event = %#v, want %s", pendingEvents, active.ID) + } + message, err := repo.GetMessage(ctx, "message-accepted-pg") + if err != nil { + t.Fatalf("GetMessage(accepted): %v", err) + } + if message.Metadata["status"] != "answered" { + t.Fatalf("accepted postgres claim status = %v, want answered", message.Metadata["status"]) + } +} + +func TestPostgresActiveTurnMetadataUpdateUsesSessionTurnLock(t *testing.T) { + db := openIsolatedPostgresMultiConn(t, testutil.PostgresDSNFromEnv(t), 5) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + ctx := context.Background() + const taskID = "task-metadata-lock-pg" + const sessionID = "session-metadata-lock-pg" + const turnID = "turn-metadata-lock-pg" + seedSessionForTurns(t, repo, taskID, sessionID) + base := time.Date(2026, time.August, 16, 13, 45, 0, 0, time.UTC) + createRecoveryTurn(t, repo, taskID, sessionID, turnID, base, map[string]interface{}{ + models.TurnMetaKeyPromptDispatchPending: true, + }) + + blocker, err := repo.db.BeginTxx(ctx, nil) + if err != nil { + t.Fatalf("begin session turn lock blocker: %v", err) + } + t.Cleanup(func() { _ = blocker.Rollback() }) + if err := lockSessionTurnWrites(ctx, blocker, repo.db.DriverName(), sessionID); err != nil { + t.Fatalf("hold session turn lock: %v", err) + } + + type updateResult struct { + updated bool + err error + } + updateDone := make(chan updateResult, 1) + go func() { + updated, _, _, updateErr := repo.UpdateActiveTurnMetadata( + ctx, + sessionID, + turnID, + map[string]interface{}{ + models.TurnMetaKeyPromptDispatchPending: true, + models.TurnMetaKeyPromptDispatchAttempted: true, + }, + nil, + ) + updateDone <- updateResult{updated: updated, err: updateErr} + }() + waitForPostgresLockWait(t, ctx, repo, "pg_advisory_xact_lock") + select { + case result := <-updateDone: + t.Fatalf("metadata update bypassed session turn lock: %+v", result) + default: + } + + if err := blocker.Commit(); err != nil { + t.Fatalf("release session turn lock: %v", err) + } + select { + case result := <-updateDone: + if result.err != nil || !result.updated { + t.Fatalf("metadata update after lock release = %+v, want updated", result) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for metadata update") + } +} + +func TestPostgresUpdateTurnUsesSessionTurnLock(t *testing.T) { + db := openIsolatedPostgresMultiConn(t, testutil.PostgresDSNFromEnv(t), 5) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + ctx := context.Background() + const taskID = "task-full-metadata-lock-pg" + const sessionID = "session-full-metadata-lock-pg" + const turnID = "turn-full-metadata-lock-pg" + seedSessionForTurns(t, repo, taskID, sessionID) + createRecoveryTurn(t, repo, taskID, sessionID, turnID, time.Now().UTC(), nil) + turn, err := repo.GetTurn(ctx, turnID) + if err != nil { + t.Fatalf("GetTurn: %v", err) + } + turn.Metadata = map[string]interface{}{"prompt_usage": true} + + blocker, err := repo.db.BeginTxx(ctx, nil) + if err != nil { + t.Fatalf("begin session turn lock blocker: %v", err) + } + t.Cleanup(func() { _ = blocker.Rollback() }) + if err := lockSessionTurnWrites(ctx, blocker, repo.db.DriverName(), sessionID); err != nil { + t.Fatalf("hold session turn lock: %v", err) + } + + updateDone := make(chan error, 1) + go func() { updateDone <- repo.UpdateTurn(ctx, turn) }() + waitForPostgresLockWait(t, ctx, repo, "pg_advisory_xact_lock") + select { + case updateErr := <-updateDone: + t.Fatalf("full metadata update bypassed session turn lock: %v", updateErr) + default: + } + + if err := blocker.Commit(); err != nil { + t.Fatalf("release session turn lock: %v", err) + } + select { + case updateErr := <-updateDone: + if updateErr != nil { + t.Fatalf("full metadata update after lock release: %v", updateErr) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for full metadata update") + } +} diff --git a/apps/backend/internal/task/repository/sqlite/turn_reservation_recovery_test.go b/apps/backend/internal/task/repository/sqlite/turn_reservation_recovery_test.go new file mode 100644 index 0000000000..4043ba3891 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/turn_reservation_recovery_test.go @@ -0,0 +1,480 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "strings" + "testing" + "time" + + "github.com/kandev/kandev/internal/task/models" +) + +func TestReconcileUnpublishedPromptTurnsRestoresOnlyClaimedMessages(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + const taskID = "task-reservation-recovery" + const sessionID = "session-reservation-recovery" + seedSessionForTurns(t, repo, taskID, sessionID) + base := time.Date(2026, time.August, 15, 19, 0, 0, 0, time.UTC) + createRecoveryTurn(t, repo, taskID, sessionID, "turn-clarification", base, nil) + createRecoveryClarification( + t, repo, "message-claimed", taskID, sessionID, "turn-clarification", "pending-recovery", base, + ) + markRecoveryClarificationDeliveryPending(t, repo, "message-claimed") + createRecoveryClarification( + t, repo, "message-terminal", taskID, sessionID, "turn-clarification", "pending-recovery", base.Add(time.Second), + ) + createRecoveryTurn(t, repo, taskID, sessionID, "turn-unpublished", base.Add(time.Minute), map[string]interface{}{ + models.TurnMetaKeyPromptDispatchPending: true, + models.TurnMetaKeyPromptDispatchClarificationPendingID: "pending-recovery", + models.TurnMetaKeyPromptDispatchClarificationTurnID: "turn-clarification", + models.TurnMetaKeyPromptDispatchClarificationMessageIDs: []string{"message-claimed"}, + }) + mutationTime := base.Add(2*time.Hour + 123*time.Millisecond) + repo.clockNow = func() time.Time { return mutationTime } + previousUpdatedAt := setMessageUpdatedAtBeforeMutation(t, repo, "message-claimed", mutationTime) + + reconciled, err := repo.ReconcileUnpublishedPromptTurns(ctx) + if err != nil || reconciled != 1 { + t.Fatalf("ReconcileUnpublishedPromptTurns = %d, %v; want 1, nil", reconciled, err) + } + if _, err := repo.GetTurn(ctx, "turn-unpublished"); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("GetTurn(unpublished) error = %v, want sql.ErrNoRows", err) + } + claimed, err := repo.GetMessage(ctx, "message-claimed") + if err != nil { + t.Fatalf("GetMessage(claimed): %v", err) + } + if claimed.Metadata["status"] != "pending" || claimed.Metadata["response"] != nil { + t.Fatalf("claimed metadata = %#v, want pending without response", claimed.Metadata) + } + if !claimed.UpdatedAt.After(previousUpdatedAt) { + t.Fatalf("restored updated_at = %s, want after %s", claimed.UpdatedAt, previousUpdatedAt) + } + if !claimed.UpdatedAt.Equal(mutationTime) { + t.Fatalf("restored updated_at = %s, want injected mutation time %s", claimed.UpdatedAt, mutationTime) + } + terminal, err := repo.GetMessage(ctx, "message-terminal") + if err != nil { + t.Fatalf("GetMessage(terminal): %v", err) + } + if terminal.Metadata["status"] != "answered" || terminal.Metadata["response"] == nil { + t.Fatalf("unclaimed terminal metadata = %#v, want unchanged", terminal.Metadata) + } +} + +func TestReconcileUnpublishedPromptTurnsRejectsMalformedClaimMessageIDs(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + const taskID = "task-malformed-recovery" + const sessionID = "session-malformed-recovery" + seedSessionForTurns(t, repo, taskID, sessionID) + base := time.Date(2026, time.August, 16, 0, 20, 0, 0, time.UTC) + createRecoveryTurn(t, repo, taskID, sessionID, "turn-clarification", base, nil) + createRecoveryClarification( + t, repo, "message-claimed", taskID, sessionID, "turn-clarification", "pending-recovery", base, + ) + createRecoveryTurn(t, repo, taskID, sessionID, "turn-unpublished", base.Add(time.Minute), map[string]interface{}{ + models.TurnMetaKeyPromptDispatchPending: true, + models.TurnMetaKeyPromptDispatchClarificationPendingID: "pending-recovery", + models.TurnMetaKeyPromptDispatchClarificationTurnID: "turn-clarification", + models.TurnMetaKeyPromptDispatchClarificationMessageIDs: []interface{}{"message-claimed", float64(7)}, + }) + + reconciled, err := repo.ReconcileUnpublishedPromptTurns(ctx) + if err == nil || reconciled != 0 { + t.Fatalf("ReconcileUnpublishedPromptTurns = %d, %v; want 0 and malformed metadata error", reconciled, err) + } + if !strings.Contains(err.Error(), "turn-unpublished") || !strings.Contains(err.Error(), sessionID) { + t.Fatalf("reconciliation error lacks actionable turn/session identity: %v", err) + } + if _, err := repo.GetTurn(ctx, "turn-unpublished"); err != nil { + t.Fatalf("malformed reservation was deleted: %v", err) + } + message, err := repo.GetMessage(ctx, "message-claimed") + if err != nil { + t.Fatalf("GetMessage(claimed): %v", err) + } + if message.Metadata["status"] != "answered" { + t.Fatalf("claimed message status = %v, want answered", message.Metadata["status"]) + } +} + +func TestReconcileUnpublishedPromptTurnsRejectsPartialClarificationRecoveryMetadata(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + const taskID = "task-partial-recovery-metadata" + const sessionID = "session-partial-recovery-metadata" + const turnID = "turn-partial-recovery-metadata" + seedSessionForTurns(t, repo, taskID, sessionID) + createRecoveryTurn(t, repo, taskID, sessionID, turnID, time.Now().UTC(), map[string]interface{}{ + models.TurnMetaKeyPromptDispatchPending: true, + models.TurnMetaKeyPromptDispatchAttempted: true, + models.TurnMetaKeyPromptDispatchClarificationPendingID: "pending-without-turn-or-messages", + }) + + reconciled, err := repo.ReconcileUnpublishedPromptTurns(ctx) + if err == nil || reconciled != 0 { + t.Fatalf("ReconcileUnpublishedPromptTurns = %d, %v; want 0 and partial metadata error", reconciled, err) + } + turn, getErr := repo.GetTurn(ctx, turnID) + if getErr != nil { + t.Fatalf("GetTurn(partial recovery metadata): %v", getErr) + } + if pending, _ := turn.Metadata[models.TurnMetaKeyPromptDispatchPending].(bool); !pending { + t.Fatalf("partial recovery metadata was cleared: %#v", turn.Metadata) + } +} + +func TestReconcileUnpublishedPromptTurnsContinuesAfterMalformedReservation(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + base := time.Date(2026, time.August, 16, 0, 25, 0, 0, time.UTC) + seedSessionForTurns(t, repo, "task-malformed-mixed", "session-malformed-mixed") + createRecoveryTurn( + t, repo, "task-malformed-mixed", "session-malformed-mixed", "turn-malformed-mixed", base, + map[string]interface{}{ + models.TurnMetaKeyPromptDispatchPending: true, + models.TurnMetaKeyPromptDispatchClarificationMessageIDs: []interface{}{"message", float64(7)}, + }, + ) + seedSessionForTurns(t, repo, "task-healthy-mixed", "session-healthy-mixed") + createRecoveryTurn( + t, repo, "task-healthy-mixed", "session-healthy-mixed", "turn-healthy-mixed", base.Add(time.Minute), + map[string]interface{}{models.TurnMetaKeyPromptDispatchPending: true}, + ) + + reconciled, err := repo.ReconcileUnpublishedPromptTurns(ctx) + if err == nil || reconciled != 1 { + t.Fatalf("ReconcileUnpublishedPromptTurns = %d, %v; want 1 and malformed metadata error", reconciled, err) + } + if _, err := repo.GetTurn(ctx, "turn-malformed-mixed"); err != nil { + t.Fatalf("malformed reservation changed: %v", err) + } + if _, err := repo.GetTurn(ctx, "turn-healthy-mixed"); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("healthy reservation error = %v, want recovered deletion", err) + } +} + +func TestReconcileUnpublishedPromptTurnsRunsDeliveryRecoveryAfterReservationError(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + base := time.Date(2026, time.August, 16, 20, 40, 0, 0, time.UTC) + seedSessionForTurns(t, repo, "task-malformed-delivery", "session-malformed-delivery") + createRecoveryTurn( + t, repo, "task-malformed-delivery", "session-malformed-delivery", + "turn-malformed-delivery", base, + map[string]interface{}{ + models.TurnMetaKeyPromptDispatchPending: true, + models.TurnMetaKeyPromptDispatchClarificationMessageIDs: []interface{}{"message", float64(7)}, + }, + ) + + seedPendingActionSession(t, repo, "task-independent-delivery", "session-independent-delivery") + createPendingActionTurn( + t, repo, "task-independent-delivery", "session-independent-delivery", + "turn-independent-delivery", base, base, + ) + createClarificationBundleMessage( + t, repo, "message-independent-delivery", "task-independent-delivery", + "session-independent-delivery", "turn-independent-delivery", + "pending-independent-delivery", "q1", base, + ) + _, claimed, err := repo.CompleteActiveClarificationBundle( + ctx, + "pending-independent-delivery", + clarificationStatusAnswered, + map[string]interface{}{"q1": map[string]interface{}{"question_id": "q1", "custom_text": "continue"}}, + ) + if err != nil || !claimed { + t.Fatalf("claim independent delivery = %v, %v; want true, nil", claimed, err) + } + + reconciled, err := repo.ReconcileUnpublishedPromptTurns(ctx) + if err == nil || reconciled != 1 { + t.Fatalf("ReconcileUnpublishedPromptTurns = %d, %v; want 1 and reservation error", reconciled, err) + } + if !strings.Contains(err.Error(), "turn-malformed-delivery") { + t.Fatalf("reservation error missing turn identity: %v", err) + } + message, err := repo.GetMessage(ctx, "message-independent-delivery") + if err != nil { + t.Fatalf("GetMessage: %v", err) + } + if message.Metadata["status"] != clarificationStatusPending || message.Metadata["response"] != nil { + t.Fatalf("independent delivery metadata = %#v, want recovered pending", message.Metadata) + } +} + +func TestReconcileUnpublishedPromptTurnsAcceptsMessageBackedReservation(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + const taskID = "task-reservation-output" + const sessionID = "session-reservation-output" + seedSessionForTurns(t, repo, taskID, sessionID) + base := time.Date(2026, time.August, 15, 20, 0, 0, 0, time.UTC) + createRecoveryTurn(t, repo, taskID, sessionID, "turn-message-backed", base, map[string]interface{}{ + models.TurnMetaKeyPromptDispatchPending: true, + models.TurnMetaKeyPromptDispatchClarificationPendingID: "pending-output", + models.TurnMetaKeyPromptDispatchClarificationTurnID: "turn-clarification", + models.TurnMetaKeyPromptDispatchClarificationMessageIDs: []string{"message-clarification"}, + }) + if err := repo.CreateMessage(ctx, &models.Message{ + ID: "message-output", TaskID: taskID, TaskSessionID: sessionID, + TurnID: "turn-message-backed", AuthorType: models.MessageAuthorAgent, + Type: models.MessageTypeMessage, Content: "accepted output", CreatedAt: base.Add(time.Second), + }); err != nil { + t.Fatalf("CreateMessage(output): %v", err) + } + mutationTime := base.Add(2*time.Hour + 456*time.Millisecond) + repo.clockNow = func() time.Time { return mutationTime } + previousUpdatedAt := setTurnUpdatedAtBeforeMutation(t, repo, "turn-message-backed", mutationTime) + + reconciled, err := repo.ReconcileUnpublishedPromptTurns(ctx) + if err != nil || reconciled != 1 { + t.Fatalf("ReconcileUnpublishedPromptTurns = %d, %v; want 1, nil", reconciled, err) + } + turn, err := repo.GetTurn(ctx, "turn-message-backed") + if err != nil { + t.Fatalf("GetTurn(message-backed): %v", err) + } + if !turn.UpdatedAt.After(previousUpdatedAt) { + t.Fatalf("published updated_at = %s, want after %s", turn.UpdatedAt, previousUpdatedAt) + } + if !turn.UpdatedAt.Equal(mutationTime) { + t.Fatalf("published updated_at = %s, want injected mutation time %s", turn.UpdatedAt, mutationTime) + } + for _, key := range []string{ + models.TurnMetaKeyPromptDispatchPending, + models.TurnMetaKeyPromptDispatchClarificationPendingID, + models.TurnMetaKeyPromptDispatchClarificationTurnID, + models.TurnMetaKeyPromptDispatchClarificationMessageIDs, + } { + if _, exists := turn.Metadata[key]; exists { + t.Fatalf("message-backed turn retained %q: %#v", key, turn.Metadata) + } + } + if pending, _ := turn.Metadata[models.TurnMetaKeyPromptDispatchStartEventPending].(bool); !pending { + t.Fatalf("message-backed turn metadata = %#v, want durable start-event marker", turn.Metadata) + } + pendingEvents, err := repo.ListTurnsPendingStartEvent(ctx) + if err != nil { + t.Fatalf("ListTurnsPendingStartEvent: %v", err) + } + if len(pendingEvents) != 1 || pendingEvents[0].ID != turn.ID { + t.Fatalf("turns pending start event = %#v, want %s", pendingEvents, turn.ID) + } +} + +func TestReconcileUnpublishedPromptTurnsPreservesAmbiguousEmptyReservation(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + const taskID = "task-reservation-accepted" + const sessionID = "session-reservation-accepted" + seedSessionForTurns(t, repo, taskID, sessionID) + base := time.Date(2026, time.August, 15, 20, 30, 0, 0, time.UTC) + createRecoveryTurn(t, repo, taskID, sessionID, "turn-clarification", base, nil) + createRecoveryClarification( + t, repo, "message-accepted-claim", taskID, sessionID, "turn-clarification", + "pending-accepted", base, + ) + markRecoveryClarificationDeliveryPending(t, repo, "message-accepted-claim") + createRecoveryTurn(t, repo, taskID, sessionID, "turn-accepted-reservation", base.Add(time.Minute), map[string]interface{}{ + models.TurnMetaKeyPromptDispatchPending: true, + models.TurnMetaKeyPromptDispatchAttempted: true, + models.TurnMetaKeyPromptDispatchClarificationPendingID: "pending-accepted", + models.TurnMetaKeyPromptDispatchClarificationTurnID: "turn-clarification", + models.TurnMetaKeyPromptDispatchClarificationMessageIDs: []string{"message-accepted-claim"}, + }) + + reconciled, err := repo.ReconcileUnpublishedPromptTurns(ctx) + if err != nil || reconciled != 1 { + t.Fatalf("ReconcileUnpublishedPromptTurns = %d, %v; want 1, nil", reconciled, err) + } + turn, err := repo.GetTurn(ctx, "turn-accepted-reservation") + if err != nil { + t.Fatalf("GetTurn(accepted reservation): %v", err) + } + for _, key := range []string{ + models.TurnMetaKeyPromptDispatchPending, + models.TurnMetaKeyPromptDispatchAttempted, + models.TurnMetaKeyPromptDispatchClarificationPendingID, + models.TurnMetaKeyPromptDispatchClarificationTurnID, + models.TurnMetaKeyPromptDispatchClarificationMessageIDs, + } { + if _, exists := turn.Metadata[key]; exists { + t.Fatalf("accepted reservation retained %q: %#v", key, turn.Metadata) + } + } + if pending, _ := turn.Metadata[models.TurnMetaKeyPromptDispatchStartEventPending].(bool); !pending { + t.Fatalf("accepted reservation metadata = %#v, want durable start-event marker", turn.Metadata) + } + claimed, err := repo.GetMessage(ctx, "message-accepted-claim") + if err != nil { + t.Fatalf("GetMessage(accepted claim): %v", err) + } + if claimed.Metadata["status"] != "answered" { + t.Fatalf("accepted claim status = %v, want answered", claimed.Metadata["status"]) + } +} + +func TestReconcileUnpublishedPromptTurnsRejectsPartialResponseDeliveryIntent(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + const taskID = "task-partial-delivery" + const sessionID = "session-partial-delivery" + const pendingID = "pending-partial-delivery" + base := time.Date(2026, time.August, 16, 20, 20, 0, 0, time.UTC) + seedSessionForTurns(t, repo, taskID, sessionID) + createRecoveryTurn(t, repo, taskID, sessionID, "turn-clarification", base, nil) + for index, messageID := range []string{"message-a", "message-b"} { + createRecoveryClarification( + t, repo, messageID, taskID, sessionID, "turn-clarification", pendingID, + base.Add(time.Duration(index)*time.Second), + ) + } + markRecoveryClarificationDeliveryPending(t, repo, "message-a") + createRecoveryTurn(t, repo, taskID, sessionID, "turn-unpublished", base.Add(time.Minute), map[string]interface{}{ + models.TurnMetaKeyPromptDispatchPending: true, + models.TurnMetaKeyPromptDispatchAttempted: true, + models.TurnMetaKeyPromptDispatchClarificationPendingID: pendingID, + models.TurnMetaKeyPromptDispatchClarificationTurnID: "turn-clarification", + models.TurnMetaKeyPromptDispatchClarificationMessageIDs: []string{"message-a", "message-b"}, + }) + + reconciled, err := repo.ReconcileUnpublishedPromptTurns(ctx) + if err == nil || reconciled != 0 { + t.Fatalf("ReconcileUnpublishedPromptTurns = %d, %v; want 0 and partial intent error", reconciled, err) + } + if !strings.Contains(err.Error(), "expected 2 messages, found 1") { + t.Fatalf("partial response delivery error = %v", err) + } + if _, err := repo.GetTurn(ctx, "turn-unpublished"); err != nil { + t.Fatalf("partial response delivery reservation changed: %v", err) + } +} + +func TestReconcileUnpublishedPromptTurnsPreservesToleratedAttemptMarkers(t *testing.T) { + for _, tt := range []struct { + name string + value interface{} + }{ + {name: "boolean", value: true}, + {name: "string_true", value: metadataTrueString}, + {name: "string_one", value: "1"}, + {name: "number_one", value: float64(1)}, + } { + t.Run(tt.name, func(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + taskID := "task-attempt-marker-" + tt.name + sessionID := "session-attempt-marker-" + tt.name + turnID := "turn-attempt-marker-" + tt.name + seedSessionForTurns(t, repo, taskID, sessionID) + createRecoveryTurn(t, repo, taskID, sessionID, turnID, time.Now().UTC(), map[string]interface{}{ + models.TurnMetaKeyPromptDispatchPending: true, + models.TurnMetaKeyPromptDispatchAttempted: tt.value, + }) + + reconciled, err := repo.ReconcileUnpublishedPromptTurns(ctx) + if err != nil || reconciled != 1 { + t.Fatalf("ReconcileUnpublishedPromptTurns = %d, %v; want 1, nil", reconciled, err) + } + turn, err := repo.GetTurn(ctx, turnID) + if err != nil { + t.Fatalf("attempted reservation was deleted: %v", err) + } + if _, exists := turn.Metadata[models.TurnMetaKeyPromptDispatchAttempted]; exists { + t.Fatalf("attempt marker was not cleared: %#v", turn.Metadata) + } + }) + } +} + +func TestDeleteTurnIfUnreferencedRemovesDefinitivelyRejectedAttempt(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + const taskID = "task-reservation-rollback" + const sessionID = "session-reservation-rollback" + seedSessionForTurns(t, repo, taskID, sessionID) + createRecoveryTurn(t, repo, taskID, sessionID, "turn-accepted-rollback", time.Now().UTC(), map[string]interface{}{ + models.TurnMetaKeyPromptDispatchPending: true, + models.TurnMetaKeyPromptDispatchAttempted: true, + }) + + deleted, err := repo.DeleteTurnIfUnreferenced(ctx, sessionID, "turn-accepted-rollback") + if err != nil || !deleted { + t.Fatalf("DeleteTurnIfUnreferenced = %v, %v; want true, nil", deleted, err) + } + if _, err := repo.GetTurn(ctx, "turn-accepted-rollback"); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("definitively rejected attempt remains: %v", err) + } +} + +func setTurnUpdatedAtBeforeMutation( + t *testing.T, + repo *Repository, + turnID string, + mutationTime time.Time, +) time.Time { + t.Helper() + updatedAt := mutationTime.Add(-time.Nanosecond) + if _, err := repo.db.Exec( + repo.db.Rebind(`UPDATE task_session_turns SET updated_at = ? WHERE id = ?`), + updatedAt, + turnID, + ); err != nil { + t.Fatalf("seed turn updated_at: %v", err) + } + return updatedAt +} + +func createRecoveryTurn( + t *testing.T, + repo *Repository, + taskID, sessionID, turnID string, + startedAt time.Time, + metadata map[string]interface{}, +) { + t.Helper() + if err := repo.CreateTurn(context.Background(), &models.Turn{ + ID: turnID, TaskID: taskID, TaskSessionID: sessionID, + StartedAt: startedAt, CreatedAt: startedAt, Metadata: metadata, + }); err != nil { + t.Fatalf("CreateTurn(%s): %v", turnID, err) + } +} + +func createRecoveryClarification( + t *testing.T, + repo *Repository, + messageID, taskID, sessionID, turnID, pendingID string, + createdAt time.Time, +) { + t.Helper() + if err := repo.CreateMessage(context.Background(), &models.Message{ + ID: messageID, TaskID: taskID, TaskSessionID: sessionID, TurnID: turnID, + AuthorType: models.MessageAuthorAgent, Type: models.MessageTypeClarificationRequest, + Content: "Continue?", CreatedAt: createdAt, + Metadata: map[string]interface{}{ + "pending_id": pendingID, "question_id": messageID, "status": "answered", + "response": map[string]interface{}{"custom_text": "yes"}, + }, + }); err != nil { + t.Fatalf("CreateMessage(%s): %v", messageID, err) + } +} + +func markRecoveryClarificationDeliveryPending(t *testing.T, repo *Repository, messageID string) { + t.Helper() + message, err := repo.GetMessage(context.Background(), messageID) + if err != nil { + t.Fatalf("GetMessage(%s): %v", messageID, err) + } + message.Metadata[clarificationResponseDeliveryPendingKey] = true + if err := repo.UpdateMessage(context.Background(), message); err != nil { + t.Fatalf("mark response delivery pending for %s: %v", messageID, err) + } +} diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index a14fad4979..968f10623f 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -75,6 +75,12 @@ type TaskExecutionStopper interface { RegisterExecutionStopOwner(sessionID, executionID string, force bool) } +// TerminalClarificationCanceller expires durable input requests after a task +// service-owned terminal transition, such as archive cancellation. +type TerminalClarificationCanceller interface { + ExpireSessionAndNotify(ctx context.Context, sessionID string) (int, error) +} + // TaskRowLivenessProber classifies an executors_running row's backing-process // liveness in a runtime-aware way (a local process check is never applied to a // remote/SSH row). It is optional and satisfied by the lifecycle adapter. When @@ -298,12 +304,14 @@ type Service struct { subagentContexts repository.SubagentContextRepository attachmentSvc *AttachmentService statusSummaryPRs TaskStatusSummaryPRReader + statusSummaryProjector TaskStatusSummaryEventProjector queuedPromptCounter QueuedPromptCounter eventBus bus.EventBus logger *logger.Logger discoveryConfig RepositoryDiscoveryConfig worktreeCleanup WorktreeCleanup executionStopper TaskExecutionStopper + clarificationCanceller TerminalClarificationCanceller rowLivenessProber TaskRowLivenessProber contextWindowResetter func(context.Context, string) error cleanupActivity TaskResourceCleanupActivityGate @@ -371,6 +379,12 @@ type Service struct { // within this backend process only — this backend is single-process per // SQLite database, so that is the complete threat model today. repoResolveMu sync.Mutex + // pendingActionProjectionMu guards the durable-generation logical clock + // shared by REST snapshots and semantic message events. Revisions are + // reserved before each repository read so delayed results stay ordered. + pendingActionProjectionMu sync.Mutex + pendingActionProjectionEpoch string + pendingActionProjectionSequence uint64 } // SetAttachmentService wires the file-backed prompt attachment owner into the @@ -433,6 +447,9 @@ func NewService(repos Repos, eventBus bus.EventBus, log *logger.Logger, discover branchFetcher: newBranchFetcher(log.Zap()), lastTaskActivity: make(map[string]v1.ForegroundActivity), lastTaskSubagentCount: make(map[string]int), + // Focused service tests do not run backend composition. Production + // replaces this fallback with a database-allocated generation. + pendingActionProjectionEpoch: "1", } } @@ -483,6 +500,12 @@ func (s *Service) SetExecutionStopper(stopper TaskExecutionStopper) { s.executionStopper = stopper } +// SetClarificationCanceller wires terminal clarification cleanup for session +// transitions owned by the task service. +func (s *Service) SetClarificationCanceller(canceller TerminalClarificationCanceller) { + s.clarificationCanceller = canceller +} + // SetRowLivenessProber wires the runtime-aware executors_running liveness probe // (satisfied by the lifecycle adapter). It is optional; when unwired, cleanup // treats every row as Unknown. diff --git a/apps/backend/internal/task/service/service_clarification_bundle_updates_test.go b/apps/backend/internal/task/service/service_clarification_bundle_updates_test.go new file mode 100644 index 0000000000..4b56513094 --- /dev/null +++ b/apps/backend/internal/task/service/service_clarification_bundle_updates_test.go @@ -0,0 +1,99 @@ +package service + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/task/models" +) + +type clarificationSummaryProjectorStub struct { + err error + events []*bus.Event +} + +func (s *clarificationSummaryProjectorStub) HandleEvent(_ context.Context, event *bus.Event) error { + s.events = append(s.events, event) + return s.err +} + +func TestPublishClarificationBundleUpdatesReportsProjectionErrorAfterPublishing(t *testing.T) { + eventBus := NewMockEventBus() + svc := NewService(Repos{}, eventBus, logger.Default(), RepositoryDiscoveryConfig{}) + projector := &clarificationSummaryProjectorStub{err: errors.New("projection unavailable")} + svc.SetTaskStatusSummaryEventProjector(projector) + message := &models.Message{ + ID: "message-restore", TaskID: "task-restore", TaskSessionID: "session-restore", + Type: models.MessageTypeClarificationRequest, RequestsInput: true, + Metadata: map[string]interface{}{"pending_id": "pending-restore", "status": "pending"}, + } + + err := svc.PublishClarificationBundleUpdates(context.Background(), []*models.Message{message}) + if err == nil || !errors.Is(err, projector.err) { + t.Fatalf("PublishClarificationBundleUpdates error = %v, want projection error", err) + } + if len(projector.events) != 1 { + t.Fatalf("projected events = %d, want 1", len(projector.events)) + } + if len(eventBus.GetPublishedEvents()) != 1 { + t.Fatalf("published events = %d, want restored row despite projection error", len(eventBus.GetPublishedEvents())) + } +} + +func TestPublishClarificationBundleUpdatesReportsMissingProjectorAfterPublishing(t *testing.T) { + eventBus := NewMockEventBus() + svc := NewService(Repos{}, eventBus, logger.Default(), RepositoryDiscoveryConfig{}) + message := &models.Message{ + ID: "message-restore", TaskID: "task-restore", TaskSessionID: "session-restore", + Type: models.MessageTypeClarificationRequest, RequestsInput: true, + Metadata: map[string]interface{}{"pending_id": "pending-restore", "status": "pending"}, + } + + err := svc.PublishClarificationBundleUpdates(context.Background(), []*models.Message{message}) + if err == nil || err.Error() != "task status summary projector is unavailable" { + t.Fatalf("PublishClarificationBundleUpdates error = %v, want missing projector", err) + } + if len(eventBus.GetPublishedEvents()) != 1 { + t.Fatalf("published events = %d, want restored row despite missing projector", len(eventBus.GetPublishedEvents())) + } +} + +func TestPublishClarificationBundleUpdatesPublishesAfterProjection(t *testing.T) { + eventBus := NewMockEventBus() + svc := NewService(Repos{}, eventBus, logger.Default(), RepositoryDiscoveryConfig{}) + projector := &clarificationSummaryProjectorStub{} + svc.SetTaskStatusSummaryEventProjector(projector) + message := &models.Message{ + ID: "message-restore", TaskID: "task-restore", TaskSessionID: "session-restore", + Type: models.MessageTypeClarificationRequest, RequestsInput: true, + Metadata: map[string]interface{}{"pending_id": "pending-restore", "status": "pending"}, + } + + if err := svc.PublishClarificationBundleUpdates(context.Background(), []*models.Message{message}); err != nil { + t.Fatalf("PublishClarificationBundleUpdates: %v", err) + } + if len(projector.events) != 1 || len(eventBus.GetPublishedEvents()) != 1 { + t.Fatalf("projected/published events = %d/%d, want 1/1", + len(projector.events), len(eventBus.GetPublishedEvents())) + } +} + +func TestPublishClarificationBundleUpdatesPublishesTerminalRowsWithoutProjection(t *testing.T) { + eventBus := NewMockEventBus() + svc := NewService(Repos{}, eventBus, logger.Default(), RepositoryDiscoveryConfig{}) + message := &models.Message{ + ID: "message-answered", TaskID: "task-answered", TaskSessionID: "session-answered", + Type: models.MessageTypeClarificationRequest, + Metadata: map[string]interface{}{"pending_id": "pending-answered", "status": "answered"}, + } + + if err := svc.PublishClarificationBundleUpdates(context.Background(), []*models.Message{message}); err != nil { + t.Fatalf("PublishClarificationBundleUpdates: %v", err) + } + if len(eventBus.GetPublishedEvents()) != 1 { + t.Fatalf("published events = %d, want 1", len(eventBus.GetPublishedEvents())) + } +} diff --git a/apps/backend/internal/task/service/service_events.go b/apps/backend/internal/task/service/service_events.go index 5fc9ab9a70..19020c8cc5 100644 --- a/apps/backend/internal/task/service/service_events.go +++ b/apps/backend/internal/task/service/service_events.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "maps" "time" @@ -856,11 +857,75 @@ func (s *Service) publishEnvironmentEvent(ctx context.Context, eventType string, // publishMessageEvent publishes message events to the event bus. // Only true system-injected content (wrapped in tags) is stripped // from the visible message content delivered to clients. -func (s *Service) publishMessageEvent(ctx context.Context, eventType string, message *models.Message) { +// Ordinary persistence callers intentionally treat delivery as best effort +// after their durable write succeeds. Synchronization-sensitive callers, such +// as clarification bundle convergence, check and propagate the returned error. +func (s *Service) publishMessageEvent(ctx context.Context, eventType string, message *models.Message) error { if s.eventBus == nil { s.logger.Warn("publishMessageEvent: eventBus is nil, skipping") + return errors.New("event bus is unavailable") + } + event := newMessageEvent(eventType, message) + s.addMessagePendingAction(ctx, eventType, message, event) + if err := s.eventBus.Publish(ctx, eventType, event); err != nil { + s.logger.Error("failed to publish message event", + zap.String("event_type", eventType), + zap.String("message_id", message.ID), + zap.Error(err)) + return err + } + return nil +} + +func (s *Service) addMessagePendingAction( + ctx context.Context, + eventType string, + message *models.Message, + event *bus.Event, +) { + if s.messages == nil || + !messageEventChangesPendingAction(eventType, message) || + message.TaskSessionID == "" { return } + actions, revisions, err := s.GetPendingActionProjectionsForSessions( + ctx, + []string{message.TaskSessionID}, + ) + if err != nil { + s.logger.Warn("failed to project pending action for message event", + zap.String("event_type", eventType), + zap.String("message_id", message.ID), + zap.Error(err)) + return + } + data, ok := event.Data.(map[string]interface{}) + if !ok { + return + } + if action, ok := actions[message.TaskSessionID]; ok { + data["pending_action"] = string(action) + } else { + data["pending_action"] = nil + } + data["pending_action_revision"] = revisions[message.TaskSessionID] +} + +func messageEventChangesPendingAction(eventType string, message *models.Message) bool { + switch eventType { + case events.MessageAdded, events.MessageDeleted: + // Adding or deleting any message can establish or remove the message + // evidence that makes an unpublished successor turn authoritative. + return true + case events.MessageUpdated: + return message.Type == models.MessageTypeClarificationRequest || + message.Type == models.MessageTypePermissionRequest + default: + return false + } +} + +func newMessageEvent(eventType string, message *models.Message) *bus.Event { messageType := string(message.Type) if messageType == "" { @@ -906,14 +971,7 @@ func (s *Service) publishMessageEvent(ctx context.Context, eventType string, mes data["metadata"] = meta } - event := bus.NewEvent(eventType, "task-service", data) - - if err := s.eventBus.Publish(ctx, eventType, event); err != nil { - s.logger.Error("failed to publish message event", - zap.String("event_type", eventType), - zap.String("message_id", message.ID), - zap.Error(err)) - } + return bus.NewEvent(eventType, "task-service", data) } func (s *Service) publishRepositoryEvent(ctx context.Context, eventType string, repository *models.Repository) { diff --git a/apps/backend/internal/task/service/service_events_test.go b/apps/backend/internal/task/service/service_events_test.go index 665acf01f9..f7933f44c7 100644 --- a/apps/backend/internal/task/service/service_events_test.go +++ b/apps/backend/internal/task/service/service_events_test.go @@ -405,6 +405,30 @@ func TestTaskPublication_TaskCreatedAfterTombstoneClearsAndPublishes(t *testing. } } +func TestMessageEventChangesPendingActionForAuthorityMutations(t *testing.T) { + tests := []struct { + name string + eventType string + messageType models.MessageType + want bool + }{ + {name: "ordinary add can establish turn authority", eventType: events.MessageAdded, messageType: models.MessageTypeMessage, want: true}, + {name: "ordinary delete can remove turn authority", eventType: events.MessageDeleted, messageType: models.MessageTypeAgentPlan, want: true}, + {name: "ordinary update preserves turn authority", eventType: events.MessageUpdated, messageType: models.MessageTypeMessage, want: false}, + {name: "clarification add changes pending action", eventType: events.MessageAdded, messageType: models.MessageTypeClarificationRequest, want: true}, + {name: "clarification update changes pending action", eventType: events.MessageUpdated, messageType: models.MessageTypeClarificationRequest, want: true}, + {name: "permission delete changes pending action", eventType: events.MessageDeleted, messageType: models.MessageTypePermissionRequest, want: true}, + {name: "unrelated event", eventType: events.TurnStarted, messageType: models.MessageTypeClarificationRequest, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := messageEventChangesPendingAction(tt.eventType, &models.Message{Type: tt.messageType}); got != tt.want { + t.Fatalf("messageEventChangesPendingAction(%s, %s) = %t, want %t", tt.eventType, tt.messageType, got, tt.want) + } + }) + } +} + func TestTaskPublication_TaskUpdatedIncludesNullArchivedAtForActiveTasks(t *testing.T) { svc, eventBus, repo := createTestService(t) ctx := context.Background() diff --git a/apps/backend/internal/task/service/service_messages.go b/apps/backend/internal/task/service/service_messages.go index ad38efe86d..5bb0a90f36 100644 --- a/apps/backend/internal/task/service/service_messages.go +++ b/apps/backend/internal/task/service/service_messages.go @@ -16,8 +16,9 @@ import ( ) const ( - messageCreateMaxRetries = 5 - messageCreateRetryDelay = 50 * time.Millisecond + messageCreateMaxRetries = 5 + messageCreateRetryDelay = 50 * time.Millisecond + clarificationPendingStatus = "pending" ) // CreateMessage creates a new message on an agent session @@ -777,3 +778,85 @@ func (s *Service) UpdateClarificationMessageForQuestion(ctx context.Context, ses return nil } + +// CompleteActiveClarificationBundle atomically transitions a current-turn +// bundle. The caller publishes the returned messages only after response +// delivery succeeds, so a failed detached resume can be restored for retry. +func (s *Service) CompleteActiveClarificationBundle( + ctx context.Context, + pendingID, status string, + responses map[string]interface{}, +) ([]*models.Message, bool, error) { + return s.messages.CompleteActiveClarificationBundle(ctx, pendingID, status, responses) +} + +// FinalizeClarificationResponseDelivery retires the durable recovery intent +// after the claimed response reaches its live or detached handoff boundary. +func (s *Service) FinalizeClarificationResponseDelivery( + ctx context.Context, + pendingID, terminalStatus string, + claimedMessages []*models.Message, +) ([]*models.Message, bool, error) { + return s.messages.FinalizeClarificationResponseDelivery( + ctx, + pendingID, + terminalStatus, + claimedMessages, + ) +} + +// RestoreActiveClarificationBundle reopens a terminal bundle after detached +// resume acceptance fails and returns the committed pending rows for publication. +func (s *Service) RestoreActiveClarificationBundle( + ctx context.Context, + pendingID, terminalStatus string, + claimedMessages []*models.Message, +) ([]*models.Message, bool, error) { + return s.messages.RestoreActiveClarificationBundle( + ctx, + pendingID, + terminalStatus, + claimedMessages, + ) +} + +// PublishClarificationBundleUpdates exposes committed rows to ordinary bus +// subscribers. A restored pending bundle first drives the live summary +// projector synchronously. Projection failure is returned to the caller, but +// does not make the durably restored bundle unsafe to retry; later events and +// reads repair that cache. Committed rows are still published on failure so +// clients do not retain the terminal snapshot. +func (s *Service) PublishClarificationBundleUpdates(ctx context.Context, messages []*models.Message) error { + var resultErr error + if restored := firstRestoredClarification(messages); restored != nil { + if s.statusSummaryProjector == nil { + resultErr = errors.New("task status summary projector is unavailable") + } else if err := s.statusSummaryProjector.HandleEvent( + ctx, + newMessageEvent(events.MessageUpdated, restored), + ); err != nil { + resultErr = fmt.Errorf("converge clarification status summary: %w", err) + } + } + for _, message := range messages { + if message == nil { + continue + } + if err := s.publishMessageEvent(ctx, events.MessageUpdated, message); err != nil && resultErr == nil { + resultErr = fmt.Errorf("publish clarification message update: %w", err) + } + } + return resultErr +} + +func firstRestoredClarification(messages []*models.Message) *models.Message { + for _, message := range messages { + if message == nil || message.Type != models.MessageTypeClarificationRequest { + continue + } + if status, _ := message.Metadata["status"].(string); status == clarificationPendingStatus { + return message + } + } + return nil +} diff --git a/apps/backend/internal/task/service/service_pending_action_projection.go b/apps/backend/internal/task/service/service_pending_action_projection.go new file mode 100644 index 0000000000..f998b5a8ea --- /dev/null +++ b/apps/backend/internal/task/service/service_pending_action_projection.go @@ -0,0 +1,62 @@ +package service + +import ( + "context" + "strconv" + + "github.com/kandev/kandev/internal/task/models" +) + +// SetPendingActionProjectionEpoch installs the durable generation allocated at +// backend startup. Callers must set it before serving requests. +func (s *Service) SetPendingActionProjectionEpoch(epoch uint64) { + if epoch == 0 { + return + } + s.pendingActionProjectionMu.Lock() + defer s.pendingActionProjectionMu.Unlock() + s.pendingActionProjectionEpoch = strconv.FormatUint(epoch, 10) + s.pendingActionProjectionSequence = 0 +} + +// GetPendingActionProjectionsForSessions returns the authoritative action and +// a cross-channel revision for every requested session. The logical revision +// is reserved before the database read: if this read is delayed behind a newer +// message event, clients can reject it without relying on HTTP completion order. +func (s *Service) GetPendingActionProjectionsForSessions( + ctx context.Context, + sessionIDs []string, +) ( + map[string]models.TaskPendingAction, + map[string]models.PendingActionRevision, + error, +) { + revisions := s.reservePendingActionProjectionRevisions(sessionIDs) + actions, err := s.GetPendingActionsForSessions(ctx, sessionIDs) + if err != nil { + return nil, nil, err + } + return actions, revisions, nil +} + +func (s *Service) reservePendingActionProjectionRevisions( + sessionIDs []string, +) map[string]models.PendingActionRevision { + s.pendingActionProjectionMu.Lock() + defer s.pendingActionProjectionMu.Unlock() + revisions := make(map[string]models.PendingActionRevision, len(sessionIDs)) + for _, sessionID := range sessionIDs { + if sessionID == "" { + continue + } + if _, exists := revisions[sessionID]; exists { + continue + } + s.pendingActionProjectionSequence++ + revisions[sessionID] = models.PendingActionRevision{ + Epoch: s.pendingActionProjectionEpoch, + Sequence: s.pendingActionProjectionSequence, + } + } + return revisions +} diff --git a/apps/backend/internal/task/service/service_status_summary.go b/apps/backend/internal/task/service/service_status_summary.go index 115a127f81..c7ccafaaef 100644 --- a/apps/backend/internal/task/service/service_status_summary.go +++ b/apps/backend/internal/task/service/service_status_summary.go @@ -3,9 +3,24 @@ package service import ( "context" + "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/task/statussummary" ) +// TaskStatusSummaryEventProjector is the synchronous status-summary boundary +// used when a committed state transition must be acknowledged before return. +type TaskStatusSummaryEventProjector interface { + HandleEvent(context.Context, *bus.Event) error +} + +// SetTaskStatusSummaryEventProjector wires the live projector for acknowledged +// pending-state convergence. Ordinary source events still flow through the bus. +func (s *Service) SetTaskStatusSummaryEventProjector(projector TaskStatusSummaryEventProjector) { + if s != nil { + s.statusSummaryProjector = projector + } +} + // GetTaskStatusSummaries batch-loads the bounded task projections used by all // list/snapshot DTO builders. A nil repository is a safe compatibility state // for focused tests and installations that have not materialized projections. diff --git a/apps/backend/internal/task/service/service_status_summary_rebuild.go b/apps/backend/internal/task/service/service_status_summary_rebuild.go index 32fe802440..0a67efcbdb 100644 --- a/apps/backend/internal/task/service/service_status_summary_rebuild.go +++ b/apps/backend/internal/task/service/service_status_summary_rebuild.go @@ -2,12 +2,16 @@ package service import ( "context" + "errors" + "fmt" "reflect" "strings" "time" "go.uber.org/zap" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/statussummary" v1 "github.com/kandev/kandev/pkg/api/v1" @@ -31,11 +35,12 @@ func (s *Service) SetTaskStatusSummaryPRReader(reader TaskStatusSummaryPRReader) } } -// HydrateMissingTaskStatusSummaries repairs only absent rows. All durable -// inputs are batch-loaded by the caller or by optional batch readers; this is -// intentionally a lazy repair so startup does not scan every historical task. -// The returned map includes both existing and newly repaired summaries. -func (s *Service) HydrateMissingTaskStatusSummaries( +// ReconcileTaskStatusSummaries repairs stale pending state in existing rows and +// builds absent rows. All durable inputs are batch-loaded by the caller or by +// optional batch readers, so startup does not scan every historical task. A +// present nil result is an explicit cache invalidation; an absent key remains +// an ordinary partial-response omission. +func (s *Service) ReconcileTaskStatusSummaries( ctx context.Context, tasks []*models.Task, sessionsByTask map[string][]*models.TaskSession, @@ -48,26 +53,91 @@ func (s *Service) HydrateMissingTaskStatusSummaries( if s == nil || s.statusSummaries == nil || len(tasks) == 0 { return summaries, nil } + failedTaskIDs, reconcileErr := s.reconcileExistingSummaries( + ctx, tasks, sessionsByTask, pendingBySession, summaries, + ) + rebuildTasks := tasks + if len(failedTaskIDs) > 0 { + rebuildTasks = make([]*models.Task, 0, len(tasks)-len(failedTaskIDs)) + for _, task := range tasks { + if task == nil { + continue + } + if _, failed := failedTaskIDs[task.ID]; !failed { + rebuildTasks = append(rebuildTasks, task) + } + } + } + summaries = s.rebuildMissingSummaries( + ctx, rebuildTasks, sessionsByTask, pendingBySession, summaries, + ) + return summaries, reconcileErr +} +func (s *Service) reconcileExistingSummaries( + ctx context.Context, + tasks []*models.Task, + sessionsByTask map[string][]*models.TaskSession, + pendingBySession map[string]models.TaskPendingAction, + summaries map[string]*statussummary.TaskStatusSummary, +) (map[string]struct{}, error) { + var reconcileErr error + failedTaskIDs := make(map[string]struct{}) + for _, task := range tasks { + if task == nil || task.ID == "" || summaries[task.ID] == nil { + continue + } + sessions := sessionsByTask[task.ID] + action := pendingActionForTask(sessions, pendingBySession) + reconciled, err := s.reconcileExistingSummary( + ctx, + task, + summaries[task.ID], + action, + ) + if err != nil { + if reconciled != nil { + summaries[task.ID] = reconciled + } else { + // A present nil entry is an explicit invalidation. DTO assembly + // distinguishes it from an ordinarily absent partial projection so + // clients can clear a known-stale cached summary. + summaries[task.ID] = nil + } + failedTaskIDs[task.ID] = struct{}{} + s.logSummaryRepairFailure(task.ID, "reconcile", err) + reconcileErr = errors.Join( + reconcileErr, + fmt.Errorf("reconcile task %s status summary: %w", task.ID, err), + ) + continue + } + if reconciled == nil { + delete(summaries, task.ID) + continue + } + summaries[task.ID] = reconciled + } + return failedTaskIDs, reconcileErr +} + +func (s *Service) rebuildMissingSummaries( + ctx context.Context, + tasks []*models.Task, + sessionsByTask map[string][]*models.TaskSession, + pendingBySession map[string]models.TaskPendingAction, + summaries map[string]*statussummary.TaskStatusSummary, +) map[string]*statussummary.TaskStatusSummary { missing := missingSummaryTasks(tasks, summaries) if len(missing) == 0 { - return summaries, nil + return summaries } prByTask, prObserved := s.loadSummaryPRs(ctx, taskIDs(missing)) gitBySession, gitObserved := s.loadSummaryGit(ctx, sessionIDsForTasks(missing, sessionsByTask)) - queuedByTask, queuedErr := s.CountPendingQueuedByTaskIDs(ctx, taskIDs(missing)) - if queuedErr != nil { - if s.logger != nil { - s.logger.Warn("failed to load queued prompt counts for status summary repair", zap.Error(queuedErr)) - } - queuedByTask = map[string]int{} - } + queuedByTask := s.loadQueuedSummaryCounts(ctx, taskIDs(missing)) now := time.Now().UTC() for _, task := range missing { - if task == nil || task.ID == "" { - continue - } - input := s.rebuildInput( + s.rebuildMissingSummary(ctx, task, summaries, s.rebuildInput( sessionsByTask[task.ID], pendingBySession, gitBySession, @@ -76,13 +146,85 @@ func (s *Service) HydrateMissingTaskStatusSummaries( prObserved, queuedByTask[task.ID], now, - ) - next := statussummary.BuildFromAuthoritative(input) - next.Revision = 1 - next.UpdatedAt = now + )) + } + return summaries +} + +func (s *Service) loadQueuedSummaryCounts(ctx context.Context, taskIDs []string) map[string]int { + queuedByTask, err := s.CountPendingQueuedByTaskIDs(ctx, taskIDs) + if err == nil { + return queuedByTask + } + if s.logger != nil { + s.logger.Warn("failed to load queued prompt counts for status summary repair", zap.Error(err)) + } + return map[string]int{} +} + +func (s *Service) rebuildMissingSummary( + ctx context.Context, + task *models.Task, + summaries map[string]*statussummary.TaskStatusSummary, + input statussummary.RebuildInput, +) { + if task == nil || task.ID == "" { + return + } + next := statussummary.BuildFromAuthoritative(input) + next.Revision = 1 + next.UpdatedAt = input.Now + if err := next.Validate(); err != nil { + s.logSummaryRepairFailure(task.ID, "validate", err) + return + } + accepted, err := s.statusSummaries.CompareAndUpdateTaskStatusSummary(ctx, &statussummary.StoredTaskStatusSummary{ + TaskID: task.ID, + WorkspaceID: task.WorkspaceID, + Summary: next, + }) + if err != nil { + s.logSummaryRepairFailure(task.ID, "persist", err) + return + } + if accepted { + summaries[task.ID] = &next + s.publishReconciledSummary(ctx, task, next) + return + } + // A projector event may have won the race while this repair was running. + // Return that authoritative row instead of exposing a stale repair. + rows, err := s.statusSummaries.LoadTaskStatusSummaries(ctx, []string{task.ID}) + if err != nil { + s.logSummaryRepairFailure(task.ID, "reload", err) + return + } + if stored := rows[task.ID]; stored != nil { + summaries[task.ID] = stored + } +} + +const maxSummaryReconcileAttempts = 3 + +func (s *Service) reconcileExistingSummary( + ctx context.Context, + task *models.Task, + current *statussummary.TaskStatusSummary, + pendingAction string, +) (*statussummary.TaskStatusSummary, error) { + for attempt := 0; attempt < maxSummaryReconcileAttempts && current != nil; attempt++ { + if current.PendingAction == pendingAction { + return current, nil + } + if err := prepareSummaryReconcileAttempt(ctx, attempt, current.Revision); err != nil { + return nil, err + } + next := *current + next.PendingAction = pendingAction + next.Revision = current.Revision + 1 + next.UpdatedAt = advancedSummaryTime(current.UpdatedAt, time.Now().UTC()) if err := next.Validate(); err != nil { - s.logSummaryRepairFailure(task.ID, "validate", err) - continue + return nil, fmt.Errorf("validate repair: %w", err) } accepted, err := s.statusSummaries.CompareAndUpdateTaskStatusSummary(ctx, &statussummary.StoredTaskStatusSummary{ TaskID: task.ID, @@ -90,25 +232,129 @@ func (s *Service) HydrateMissingTaskStatusSummaries( Summary: next, }) if err != nil { - s.logSummaryRepairFailure(task.ID, "persist", err) - continue + return nil, fmt.Errorf("persist repair: %w", err) } if accepted { - summaries[task.ID] = &next - continue + s.publishReconciledSummary(ctx, task, next) + return &next, nil } - // A projector event may have won the race while this repair was running. - // Return that authoritative row instead of exposing a stale repair. rows, err := s.statusSummaries.LoadTaskStatusSummaries(ctx, []string{task.ID}) if err != nil { - s.logSummaryRepairFailure(task.ID, "reload", err) + return nil, fmt.Errorf("reload after compare-and-set rejection: %w", err) + } + current = rows[task.ID] + if current == nil { + return nil, nil + } + if s.sessions == nil { + return nil, errors.New("reload sessions: session repository unavailable") + } + refreshedSessions, err := s.sessions.ListTaskSessions(ctx, task.ID) + if err != nil { + return nil, fmt.Errorf("reload sessions: %w", err) + } + pendingBySession, err := s.GetPendingActionsForSessions(ctx, taskSessionIDs(refreshedSessions)) + if err != nil { + return nil, fmt.Errorf("reload pending actions: %w", err) + } + pendingAction = pendingActionForTask(refreshedSessions, pendingBySession) + } + if current != nil && current.PendingAction == pendingAction { + return current, nil + } + s.logSummaryReconcileExhaustion(task.ID, current) + return nil, errors.New("exhausted compare-and-set retries") +} + +func (s *Service) logSummaryReconcileExhaustion( + taskID string, + current *statussummary.TaskStatusSummary, +) { + if s.logger != nil { + lastRevision := uint64(0) + if current != nil { + lastRevision = current.Revision + } + s.logger.Warn("task status summary compare-and-set retries exhausted", + zap.String("task_id", taskID), + zap.Int("attempts", maxSummaryReconcileAttempts), + zap.Uint64("last_revision", lastRevision)) + } +} + +const summaryReconcileInitialRetryDelay = time.Millisecond + +func prepareSummaryReconcileAttempt(ctx context.Context, attempt int, revision uint64) error { + if revision == ^uint64(0) { + return errors.New("revision overflow") + } + if err := waitForSummaryReconcileRetry(ctx, attempt); err != nil { + return fmt.Errorf("wait before compare-and-set retry: %w", err) + } + return nil +} + +func waitForSummaryReconcileRetry(ctx context.Context, attempt int) error { + if attempt <= 0 { + return nil + } + timer := time.NewTimer(summaryReconcileInitialRetryDelay << (attempt - 1)) + defer timer.Stop() + select { + case <-timer.C: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func advancedSummaryTime(previous, now time.Time) time.Time { + if !now.After(previous) { + return previous.Add(time.Nanosecond) + } + return now +} + +func (s *Service) publishReconciledSummary( + ctx context.Context, + task *models.Task, + summary statussummary.TaskStatusSummary, +) { + if s.eventBus == nil { + return + } + payload := statussummary.SummaryUpdated{ + TaskID: task.ID, + WorkspaceID: task.WorkspaceID, + Summary: summary, + } + if err := s.eventBus.Publish(ctx, events.TaskStatusSummaryUpdated, + bus.NewEvent(events.TaskStatusSummaryUpdated, "task-status-summary-reconciler", payload)); err != nil { + s.logSummaryRepairFailure(task.ID, "publish", err) + } +} + +func pendingActionForTask( + sessions []*models.TaskSession, + actions map[string]models.TaskPendingAction, +) string { + hasClarification := false + for _, session := range sessions { + if session == nil || (session.State != models.TaskSessionStateRunning && + session.State != models.TaskSessionStateWaitingForInput) { continue } - if stored := rows[task.ID]; stored != nil { - summaries[task.ID] = stored + switch actions[session.ID] { + case models.TaskPendingActionPermission: + return string(models.TaskPendingActionPermission) + case models.TaskPendingActionClarification: + hasClarification = true } } - return summaries, nil + if hasClarification { + return string(models.TaskPendingActionClarification) + } + return "" } func (s *Service) logSummaryRepairFailure(taskID, stage string, err error) { diff --git a/apps/backend/internal/task/service/service_status_summary_rebuild_test.go b/apps/backend/internal/task/service/service_status_summary_rebuild_test.go index 41f3ec0513..6b154fe3a5 100644 --- a/apps/backend/internal/task/service/service_status_summary_rebuild_test.go +++ b/apps/backend/internal/task/service/service_status_summary_rebuild_test.go @@ -2,10 +2,18 @@ package service import ( "context" + "errors" "testing" "time" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" + + commonlogger "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository" "github.com/kandev/kandev/internal/task/statussummary" v1 "github.com/kandev/kandev/pkg/api/v1" ) @@ -43,6 +51,140 @@ type statusSummaryQueuedPromptCounter struct { counts map[string]int } +type rejectingStatusSummaryRepository struct { + repository.TaskStatusSummaryRepository + competing statussummary.StoredTaskStatusSummary + rejected bool +} + +type cancelingRejectStatusSummaryRepository struct { + repository.TaskStatusSummaryRepository + summary *statussummary.TaskStatusSummary + cancel context.CancelFunc + compareCalls int +} + +type exhaustingStatusSummaryRepository struct { + repository.TaskStatusSummaryRepository + summary statussummary.TaskStatusSummary + compareCalls int +} + +type vanishingStatusSummaryRepository struct { + repository.TaskStatusSummaryRepository + compareCalls int +} + +type failingStatusSummaryRepository struct { + repository.TaskStatusSummaryRepository + err error + failTaskID string +} + +func (r *failingStatusSummaryRepository) CompareAndUpdateTaskStatusSummary( + ctx context.Context, + stored *statussummary.StoredTaskStatusSummary, +) (bool, error) { + if r.failTaskID == "" || stored.TaskID == r.failTaskID { + return false, r.err + } + return r.TaskStatusSummaryRepository.CompareAndUpdateTaskStatusSummary(ctx, stored) +} + +func (r *vanishingStatusSummaryRepository) CompareAndUpdateTaskStatusSummary( + ctx context.Context, + stored *statussummary.StoredTaskStatusSummary, +) (bool, error) { + r.compareCalls++ + if r.compareCalls == 1 { + return false, nil + } + return r.TaskStatusSummaryRepository.CompareAndUpdateTaskStatusSummary(ctx, stored) +} + +func (r *vanishingStatusSummaryRepository) LoadTaskStatusSummaries( + context.Context, + []string, +) (map[string]*statussummary.TaskStatusSummary, error) { + return map[string]*statussummary.TaskStatusSummary{}, nil +} + +type authoritativePendingMessageRepository struct { + repository.MessageRepository + actions map[string]models.TaskPendingAction + calls int +} + +type statusSummarySessionRepository struct { + repository.SessionRepository + sessions []*models.TaskSession + calls int +} + +func (r *statusSummarySessionRepository) ListTaskSessions( + context.Context, + string, +) ([]*models.TaskSession, error) { + r.calls++ + return r.sessions, nil +} + +func (r *authoritativePendingMessageRepository) GetPendingActionsBySessionIDs( + context.Context, + []string, +) (map[string]models.TaskPendingAction, error) { + r.calls++ + return r.actions, nil +} + +func (r *rejectingStatusSummaryRepository) CompareAndUpdateTaskStatusSummary( + ctx context.Context, + stored *statussummary.StoredTaskStatusSummary, +) (bool, error) { + if !r.rejected { + r.rejected = true + if _, err := r.TaskStatusSummaryRepository.CompareAndUpdateTaskStatusSummary(ctx, &r.competing); err != nil { + return false, err + } + return false, nil + } + return r.TaskStatusSummaryRepository.CompareAndUpdateTaskStatusSummary(ctx, stored) +} + +func (r *cancelingRejectStatusSummaryRepository) CompareAndUpdateTaskStatusSummary( + context.Context, + *statussummary.StoredTaskStatusSummary, +) (bool, error) { + r.compareCalls++ + if r.compareCalls == 1 { + r.cancel() + } + return false, nil +} + +func (r *cancelingRejectStatusSummaryRepository) LoadTaskStatusSummaries( + context.Context, + []string, +) (map[string]*statussummary.TaskStatusSummary, error) { + return map[string]*statussummary.TaskStatusSummary{"task-1": r.summary}, nil +} + +func (r *exhaustingStatusSummaryRepository) CompareAndUpdateTaskStatusSummary( + context.Context, + *statussummary.StoredTaskStatusSummary, +) (bool, error) { + r.compareCalls++ + return false, nil +} + +func (r *exhaustingStatusSummaryRepository) LoadTaskStatusSummaries( + context.Context, + []string, +) (map[string]*statussummary.TaskStatusSummary, error) { + summary := r.summary + return map[string]*statussummary.TaskStatusSummary{"task-1": &summary}, nil +} + func (c statusSummaryQueuedPromptCounter) CountPendingByTaskIDs(_ context.Context, taskIDs []string) (map[string]int, error) { out := make(map[string]int, len(taskIDs)) for _, id := range taskIDs { @@ -51,8 +193,8 @@ func (c statusSummaryQueuedPromptCounter) CountPendingByTaskIDs(_ context.Contex return out, nil } -func TestHydrateMissingTaskStatusSummariesRepairsExistingTaskOnce(t *testing.T) { - svc, _, repo := createTestService(t) +func TestReconcileTaskStatusSummariesRepairsMissingTaskOnce(t *testing.T) { + svc, eventBus, repo := createTestService(t) ctx := context.Background() createTaskWithoutRepositories(t, ctx, repo) @@ -101,11 +243,11 @@ func TestHydrateMissingTaskStatusSummariesRepairsExistingTaskOnce(t *testing.T) sessions := map[string][]*models.TaskSession{task.ID: {session}} pending := map[string]models.TaskPendingAction{session.ID: models.TaskPendingActionPermission} - got, err := svc.HydrateMissingTaskStatusSummaries( + got, err := svc.ReconcileTaskStatusSummaries( ctx, []*models.Task{task}, sessions, pending, map[string]*statussummary.TaskStatusSummary{}, ) if err != nil { - t.Fatalf("HydrateMissingTaskStatusSummaries: %v", err) + t.Fatalf("ReconcileTaskStatusSummaries: %v", err) } summary := got[task.ID] if summary == nil { @@ -143,18 +285,25 @@ func TestHydrateMissingTaskStatusSummariesRepairsExistingTaskOnce(t *testing.T) if persisted[task.ID] == nil || persisted[task.ID].Revision != 1 { t.Fatalf("persisted summary = %+v", persisted[task.ID]) } + published := eventBus.GetPublishedEvents() + if len(published) != 1 || published[0].Type != events.TaskStatusSummaryUpdated { + t.Fatalf("missing-summary repair events = %+v, want one summary update", published) + } - if _, err := svc.HydrateMissingTaskStatusSummaries(ctx, []*models.Task{task}, sessions, pending, got); err != nil { - t.Fatalf("second HydrateMissingTaskStatusSummaries: %v", err) + if _, err := svc.ReconcileTaskStatusSummaries(ctx, []*models.Task{task}, sessions, pending, got); err != nil { + t.Fatalf("second ReconcileTaskStatusSummaries: %v", err) } if prReader.calls != 1 { t.Fatalf("PR reader calls after existing summary = %d, want one", prReader.calls) } + if published = eventBus.GetPublishedEvents(); len(published) != 1 { + t.Fatalf("events after no-op reconcile = %+v, want no duplicate", published) + } } // The rebuild path is what runs after a restart, so it is where a stale record // used to come back. A session whose error was cleared must rebuild clean. -func TestHydrateMissingTaskStatusSummariesSkipsClearedAgentErrors(t *testing.T) { +func TestReconcileTaskStatusSummariesSkipsClearedAgentErrors(t *testing.T) { svc, _, repo := createTestService(t) ctx := context.Background() createTaskWithoutRepositories(t, ctx, repo) @@ -180,7 +329,7 @@ func TestHydrateMissingTaskStatusSummariesSkipsClearedAgentErrors(t *testing.T) svc.statusSummaries = repo task := &models.Task{ID: "task-1", WorkspaceID: "ws-1"} - got, err := svc.HydrateMissingTaskStatusSummaries( + got, err := svc.ReconcileTaskStatusSummaries( ctx, []*models.Task{task}, map[string][]*models.TaskSession{task.ID: {stored}}, @@ -188,7 +337,7 @@ func TestHydrateMissingTaskStatusSummariesSkipsClearedAgentErrors(t *testing.T) map[string]*statussummary.TaskStatusSummary{}, ) if err != nil { - t.Fatalf("HydrateMissingTaskStatusSummaries: %v", err) + t.Fatalf("ReconcileTaskStatusSummaries: %v", err) } summary := got[task.ID] if summary == nil { @@ -198,3 +347,393 @@ func TestHydrateMissingTaskStatusSummariesSkipsClearedAgentErrors(t *testing.T) t.Fatalf("active error = %+v, want a cleared record to stay invisible", summary.ActiveError) } } + +func TestReconcileTaskStatusSummariesRepairsExistingPendingOnly(t *testing.T) { + svc, eventBus, repo := createTestService(t) + ctx := context.Background() + createTaskWithoutRepositories(t, ctx, repo) + svc.statusSummaries = repo + + storedAt := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) + stored := statussummary.TaskStatusSummary{ + Revision: 7, + UpdatedAt: storedAt, + PrimarySession: &statussummary.PrimarySessionSummary{ID: "primary-1", State: "RUNNING"}, + ForegroundActivity: "generating", + ActiveSubagentCount: 2, + PendingAction: string(models.TaskPendingActionClarification), + Git: &statussummary.GitSummary{ChangedFiles: 4, Ahead: 1}, + QueuedPromptCount: 3, + } + accepted, err := repo.CompareAndUpdateTaskStatusSummary(ctx, &statussummary.StoredTaskStatusSummary{ + TaskID: "task-1", + WorkspaceID: "ws-1", + Summary: stored, + }) + if err != nil || !accepted { + t.Fatalf("seed task status summary: accepted=%v err=%v", accepted, err) + } + eventBus.ClearEvents() + + task := &models.Task{ID: "task-1", WorkspaceID: "ws-1"} + got, err := svc.ReconcileTaskStatusSummaries( + ctx, + []*models.Task{task}, + map[string][]*models.TaskSession{}, + map[string]models.TaskPendingAction{}, + map[string]*statussummary.TaskStatusSummary{"task-1": &stored}, + ) + if err != nil { + t.Fatalf("repair existing pending state: %v", err) + } + repaired := got[task.ID] + if repaired == nil || repaired.PendingAction != "" || repaired.Revision != 8 { + t.Fatalf("repaired existing summary = %+v", repaired) + } + if repaired.PrimarySession == nil || repaired.PrimarySession.ID != "primary-1" || + repaired.ForegroundActivity != "generating" || repaired.ActiveSubagentCount != 2 || + repaired.Git == nil || repaired.Git.ChangedFiles != 4 || repaired.QueuedPromptCount != 3 { + t.Fatalf("unrelated summary fields changed: %+v", repaired) + } + published := eventBus.GetPublishedEvents() + if len(published) != 1 || published[0].Type != events.TaskStatusSummaryUpdated { + t.Fatalf("repair events = %+v, want one complete summary update", published) + } + + eventBus.ClearEvents() + if _, err := svc.ReconcileTaskStatusSummaries( + ctx, + []*models.Task{task}, + map[string][]*models.TaskSession{}, + map[string]models.TaskPendingAction{}, + got, + ); err != nil { + t.Fatalf("repeat existing pending repair: %v", err) + } + if len(eventBus.GetPublishedEvents()) != 0 { + t.Fatalf("no-op repair published events = %+v", eventBus.GetPublishedEvents()) + } +} + +func TestReconcileTaskStatusSummariesInvalidatesStaleSummaryOnRepairFailure(t *testing.T) { + svc, _, repo := createTestService(t) + repairErr := errors.New("status summary write failed") + svc.statusSummaries = &failingStatusSummaryRepository{ + TaskStatusSummaryRepository: repo, + err: repairErr, + } + stored := &statussummary.TaskStatusSummary{Revision: 4} + task := &models.Task{ID: "task-1", WorkspaceID: "ws-1"} + session := &models.TaskSession{ + ID: "session-1", + TaskID: task.ID, + State: models.TaskSessionStateWaitingForInput, + } + + got, err := svc.ReconcileTaskStatusSummaries( + context.Background(), + []*models.Task{task}, + map[string][]*models.TaskSession{task.ID: {session}}, + map[string]models.TaskPendingAction{ + session.ID: models.TaskPendingActionClarification, + }, + map[string]*statussummary.TaskStatusSummary{task.ID: stored}, + ) + + if !errors.Is(err, repairErr) { + t.Fatalf("ReconcileTaskStatusSummaries error = %v, want %v", err, repairErr) + } + if summary, exists := got[task.ID]; !exists || summary != nil { + t.Fatalf("stale summary = %+v, present=%v; want explicit invalidation", summary, exists) + } +} + +func TestReconcileTaskStatusSummariesRebuildsUnrelatedMissingSummaryAfterRepairFailure(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + createTaskWithoutRepositories(t, ctx, repo) + if err := repo.CreateTask(ctx, &models.Task{ + ID: "task-2", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-1", + }); err != nil { + t.Fatalf("CreateTask(task-2): %v", err) + } + repairErr := errors.New("task-1 summary write failed") + svc.statusSummaries = &failingStatusSummaryRepository{ + TaskStatusSummaryRepository: repo, + err: repairErr, + failTaskID: "task-1", + } + tasks := []*models.Task{ + {ID: "task-1", WorkspaceID: "ws-1"}, + {ID: "task-2", WorkspaceID: "ws-1"}, + } + + got, err := svc.ReconcileTaskStatusSummaries( + ctx, + tasks, + map[string][]*models.TaskSession{}, + map[string]models.TaskPendingAction{}, + map[string]*statussummary.TaskStatusSummary{"task-1": {Revision: 3, PendingAction: "clarification"}}, + ) + + if !errors.Is(err, repairErr) { + t.Fatalf("ReconcileTaskStatusSummaries error = %v, want %v", err, repairErr) + } + if summary, exists := got["task-1"]; !exists || summary != nil { + t.Fatalf("failed task summary = %+v, present=%v; want explicit invalidation", summary, exists) + } + if got["task-2"] == nil { + t.Fatal("unrelated missing task summary was not rebuilt") + } +} + +func TestReconcileTaskStatusSummariesReReadsPendingAfterCASRejection(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + createTaskWithoutRepositories(t, ctx, repo) + stored := statussummary.TaskStatusSummary{ + Revision: 4, + PendingAction: string(models.TaskPendingActionClarification), + } + accepted, err := repo.CompareAndUpdateTaskStatusSummary(ctx, &statussummary.StoredTaskStatusSummary{ + TaskID: "task-1", WorkspaceID: "ws-1", Summary: stored, + }) + if err != nil || !accepted { + t.Fatalf("seed task status summary: accepted=%v err=%v", accepted, err) + } + svc.statusSummaries = &rejectingStatusSummaryRepository{ + TaskStatusSummaryRepository: repo, + competing: statussummary.StoredTaskStatusSummary{ + TaskID: "task-1", + WorkspaceID: "ws-1", + Summary: statussummary.TaskStatusSummary{ + Revision: 5, + PendingAction: string(models.TaskPendingActionClarification), + QueuedPromptCount: 9, + }, + }, + } + pendingMessages := &authoritativePendingMessageRepository{ + MessageRepository: repo, + actions: map[string]models.TaskPendingAction{ + "session-1": models.TaskPendingActionClarification, + }, + } + svc.messages = pendingMessages + + task := &models.Task{ID: "task-1", WorkspaceID: "ws-1"} + session := &models.TaskSession{ + ID: "session-1", + TaskID: task.ID, + State: models.TaskSessionStateWaitingForInput, + } + sessionRepo := &statusSummarySessionRepository{ + SessionRepository: repo, + sessions: []*models.TaskSession{session}, + } + svc.sessions = sessionRepo + got, err := svc.ReconcileTaskStatusSummaries( + ctx, + []*models.Task{task}, + map[string][]*models.TaskSession{task.ID: {session}}, + map[string]models.TaskPendingAction{}, + map[string]*statussummary.TaskStatusSummary{"task-1": &stored}, + ) + if err != nil { + t.Fatalf("reconcile after CAS rejection: %v", err) + } + repaired := got[task.ID] + if repaired == nil || repaired.Revision != 5 || + repaired.PendingAction != string(models.TaskPendingActionClarification) || repaired.QueuedPromptCount != 9 { + t.Fatalf("summary after CAS rejection = %+v", repaired) + } + if pendingMessages.calls != 1 { + t.Fatalf("authoritative pending reads = %d, want 1", pendingMessages.calls) + } + if sessionRepo.calls != 1 { + t.Fatalf("authoritative session reads = %d, want 1", sessionRepo.calls) + } +} + +func TestReconcileExistingSummaryStopsBeforeRetryWhenContextIsCanceled(t *testing.T) { + svc, _, repo := createTestService(t) + ctx, cancel := context.WithCancel(context.Background()) + stored := &statussummary.TaskStatusSummary{ + Revision: 4, + PendingAction: string(models.TaskPendingActionClarification), + } + rejecting := &cancelingRejectStatusSummaryRepository{ + TaskStatusSummaryRepository: repo, + summary: &statussummary.TaskStatusSummary{ + Revision: 5, + PendingAction: string(models.TaskPendingActionClarification), + }, + cancel: cancel, + } + svc.statusSummaries = rejecting + svc.sessions = &statusSummarySessionRepository{ + SessionRepository: repo, + sessions: []*models.TaskSession{{ + ID: "session-1", TaskID: "task-1", State: models.TaskSessionStateCompleted, + }}, + } + svc.messages = &authoritativePendingMessageRepository{MessageRepository: repo} + + _, err := svc.reconcileExistingSummary( + ctx, + &models.Task{ID: "task-1", WorkspaceID: "ws-1"}, + stored, + "", + ) + if !errors.Is(err, context.Canceled) { + t.Fatalf("reconcileExistingSummary error = %v, want context canceled", err) + } + if rejecting.compareCalls != 1 { + t.Fatalf("compare calls after cancellation = %d, want 1", rejecting.compareCalls) + } +} + +func TestReconcileTaskStatusSummariesMarksStaleRowInvalidAfterCASExhaustion(t *testing.T) { + svc, _, repo := createTestService(t) + core, logs := observer.New(zapcore.WarnLevel) + log, logErr := commonlogger.NewFromZap(zap.New(core)) + if logErr != nil { + t.Fatalf("create logger: %v", logErr) + } + svc.logger = log + stored := statussummary.TaskStatusSummary{Revision: 4, QueuedPromptCount: 9} + exhausting := &exhaustingStatusSummaryRepository{ + TaskStatusSummaryRepository: repo, + summary: stored, + } + svc.statusSummaries = exhausting + session := &models.TaskSession{ + ID: "session-1", TaskID: "task-1", State: models.TaskSessionStateWaitingForInput, + } + svc.sessions = &statusSummarySessionRepository{ + SessionRepository: repo, + sessions: []*models.TaskSession{session}, + } + svc.messages = &authoritativePendingMessageRepository{ + MessageRepository: repo, + actions: map[string]models.TaskPendingAction{ + "session-1": models.TaskPendingActionClarification, + }, + } + + got, err := svc.ReconcileTaskStatusSummaries( + context.Background(), + []*models.Task{{ID: "task-1", WorkspaceID: "ws-1"}}, + map[string][]*models.TaskSession{"task-1": {session}}, + map[string]models.TaskPendingAction{"session-1": models.TaskPendingActionClarification}, + map[string]*statussummary.TaskStatusSummary{"task-1": &stored}, + ) + if err == nil { + t.Fatal("CAS exhaustion error = nil") + } + if summary, exists := got["task-1"]; !exists || summary != nil { + t.Fatalf("summary after CAS exhaustion = %+v, present=%v; want explicit invalidation", summary, exists) + } + if exhausting.compareCalls != maxSummaryReconcileAttempts { + t.Fatalf("compare calls = %d, want %d", exhausting.compareCalls, maxSummaryReconcileAttempts) + } + entries := logs.FilterMessage("task status summary compare-and-set retries exhausted").All() + if len(entries) != 1 { + t.Fatalf("CAS exhaustion warning count = %d, want 1", len(entries)) + } + fields := entries[0].ContextMap() + if fields["task_id"] != "task-1" || fields["attempts"] != int64(maxSummaryReconcileAttempts) { + t.Fatalf("CAS exhaustion warning fields = %#v", fields) + } +} + +func TestReconcileTaskStatusSummariesReReadsSessionStateAfterCASRejection(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + createTaskWithoutRepositories(t, ctx, repo) + stored := statussummary.TaskStatusSummary{Revision: 4} + accepted, err := repo.CompareAndUpdateTaskStatusSummary(ctx, &statussummary.StoredTaskStatusSummary{ + TaskID: "task-1", WorkspaceID: "ws-1", Summary: stored, + }) + if err != nil || !accepted { + t.Fatalf("seed task status summary: accepted=%v err=%v", accepted, err) + } + svc.statusSummaries = &rejectingStatusSummaryRepository{ + TaskStatusSummaryRepository: repo, + competing: statussummary.StoredTaskStatusSummary{ + TaskID: "task-1", + WorkspaceID: "ws-1", + Summary: statussummary.TaskStatusSummary{ + Revision: 5, + PendingAction: string(models.TaskPendingActionClarification), + }, + }, + } + pendingMessages := &authoritativePendingMessageRepository{ + MessageRepository: repo, + actions: map[string]models.TaskPendingAction{ + "session-1": models.TaskPendingActionClarification, + }, + } + svc.messages = pendingMessages + sessionRepo := &statusSummarySessionRepository{ + SessionRepository: repo, + sessions: []*models.TaskSession{{ + ID: "session-1", TaskID: "task-1", State: models.TaskSessionStateCompleted, + }}, + } + svc.sessions = sessionRepo + staleSession := &models.TaskSession{ + ID: "session-1", TaskID: "task-1", State: models.TaskSessionStateWaitingForInput, + } + + got, err := svc.ReconcileTaskStatusSummaries( + ctx, + []*models.Task{{ID: "task-1", WorkspaceID: "ws-1"}}, + map[string][]*models.TaskSession{"task-1": {staleSession}}, + map[string]models.TaskPendingAction{ + "session-1": models.TaskPendingActionClarification, + }, + map[string]*statussummary.TaskStatusSummary{"task-1": &stored}, + ) + if err != nil { + t.Fatalf("ReconcileTaskStatusSummaries: %v", err) + } + repaired := got["task-1"] + if repaired == nil || repaired.Revision != 6 || repaired.PendingAction != "" { + t.Fatalf("summary after session-state refresh = %+v, want revision 6 without pending", repaired) + } + if pendingMessages.calls != 1 || sessionRepo.calls != 1 { + t.Fatalf("retry refresh calls: pending=%d sessions=%d, want 1 each", pendingMessages.calls, sessionRepo.calls) + } +} + +func TestReconcileTaskStatusSummariesRebuildsSummaryThatVanishesDuringRetry(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + createTaskWithoutRepositories(t, ctx, repo) + vanishing := &vanishingStatusSummaryRepository{TaskStatusSummaryRepository: repo} + svc.statusSummaries = vanishing + stored := &statussummary.TaskStatusSummary{ + Revision: 4, + PendingAction: string(models.TaskPendingActionClarification), + } + + got, err := svc.ReconcileTaskStatusSummaries( + ctx, + []*models.Task{{ID: "task-1", WorkspaceID: "ws-1"}}, + map[string][]*models.TaskSession{}, + map[string]models.TaskPendingAction{}, + map[string]*statussummary.TaskStatusSummary{"task-1": stored}, + ) + if err != nil { + t.Fatalf("ReconcileTaskStatusSummaries: %v", err) + } + rebuilt := got["task-1"] + if rebuilt == nil || rebuilt == stored || rebuilt.Revision != 1 || rebuilt.PendingAction != "" { + t.Fatalf("summary after vanished retry = %+v, want rebuilt revision 1", rebuilt) + } + if vanishing.compareCalls != 2 { + t.Fatalf("compare calls = %d, want reconcile plus rebuild", vanishing.compareCalls) + } +} diff --git a/apps/backend/internal/task/service/service_tasks.go b/apps/backend/internal/task/service/service_tasks.go index 1c6151b1b2..e2267b03fe 100644 --- a/apps/backend/internal/task/service/service_tasks.go +++ b/apps/backend/internal/task/service/service_tasks.go @@ -2028,12 +2028,26 @@ func (s *Service) finalizeCancelledSessions(ctx context.Context, taskID string, // not also suppress the event publish below — event-driven clients // need session.state_changed regardless of whether the archiving // caller is still connected. - // Deliberately left unbounded (no timeout) here: publishSessionsCancelled - // gives each session in cancelledSessions its own independent 10s - // deadline around its Publish call, so one slow synchronous subscriber - // can no longer consume a shared batch-wide budget and starve the - // events for sessions later in the loop. + // Deliberately left unbounded at the batch level: clarification expiry and + // publishSessionsCancelled give each session their own independent timeout, + // so one slow write or synchronous subscriber cannot starve later sessions. detachedCtx := context.WithoutCancel(ctx) + if s.clarificationCanceller != nil { + for _, session := range cancelledSessions { + if session == nil || session.ID == "" { + continue + } + expireCtx, cancelExpire := context.WithTimeout(detachedCtx, taskPublicationTimeout) + _, err := s.clarificationCanceller.ExpireSessionAndNotify(expireCtx, session.ID) + cancelExpire() + if err != nil { + s.logger.Error("failed to expire clarification after archive cancellation; response claims remain quarantined", + zap.String("task_id", taskID), + zap.String("session_id", session.ID), + zap.Error(err)) + } + } + } s.publishSessionsCancelled(detachedCtx, taskID, activeSessions, cancelledSessions, models.SessionArchiveCancelReason) } diff --git a/apps/backend/internal/task/service/service_test.go b/apps/backend/internal/task/service/service_test.go index 2773b95783..9acb970ef8 100644 --- a/apps/backend/internal/task/service/service_test.go +++ b/apps/backend/internal/task/service/service_test.go @@ -34,6 +34,24 @@ type MockEventBus struct { closed bool } +type recordingTaskClarificationCanceller struct { + sessions []string + hasDeadline []bool + contextErrs []error + err error +} + +func (c *recordingTaskClarificationCanceller) ExpireSessionAndNotify( + ctx context.Context, + sessionID string, +) (int, error) { + c.sessions = append(c.sessions, sessionID) + _, hasDeadline := ctx.Deadline() + c.hasDeadline = append(c.hasDeadline, hasDeadline) + c.contextErrs = append(c.contextErrs, ctx.Err()) + return 1, c.err +} + func NewMockEventBus() *MockEventBus { return &MockEventBus{ publishedEvents: make([]*bus.Event, 0), @@ -1969,6 +1987,8 @@ func TestService_ArchiveTaskPublishesSessionStateChangedForActiveSessions(t *tes t.Fatalf("CreateTaskSession: %v", err) } + clarifications := &recordingTaskClarificationCanceller{} + svc.SetClarificationCanceller(clarifications) if err := svc.ArchiveTask(ctx, "task-1"); err != nil { t.Fatalf("ArchiveTask: %v", err) } @@ -2012,6 +2032,15 @@ func TestService_ArchiveTaskPublishesSessionStateChangedForActiveSessions(t *tes if session.State != models.TaskSessionStateCancelled { t.Errorf("session state = %q, want CANCELLED", session.State) } + if len(clarifications.sessions) != 1 || clarifications.sessions[0] != "session-running" { + t.Fatalf("expired clarification sessions = %v, want [session-running]", clarifications.sessions) + } + if len(clarifications.hasDeadline) != 1 || !clarifications.hasDeadline[0] { + t.Fatalf("clarification expiry deadlines = %v, want one bounded context", clarifications.hasDeadline) + } + if len(clarifications.contextErrs) != 1 || clarifications.contextErrs[0] != nil { + t.Fatalf("clarification expiry context errors = %v, want active context", clarifications.contextErrs) + } } // TestService_PublishSessionsCancelledCoversSessionsMissingFromSnapshot is a @@ -3192,6 +3221,116 @@ func TestService_CreateMessage(t *testing.T) { } } +func TestService_ClarificationMessageEventsCarryPendingActionProjection(t *testing.T) { + svc, eventBus, repo := createTestService(t) + ctx := context.Background() + setupTestTask(t, repo) + sessionID := setupTestSession(t, repo) + turnID := setupTestTurn(t, repo, sessionID, "task-123", "turn-clarification") + if err := repo.UpdateTaskSessionState( + ctx, + sessionID, + models.TaskSessionStateWaitingForInput, + "", + ); err != nil { + t.Fatalf("set session waiting: %v", err) + } + eventBus.ClearEvents() + + message, err := svc.CreateMessage(ctx, &CreateMessageRequest{ + TaskSessionID: sessionID, + TaskID: "task-123", + TurnID: turnID, + Content: "Choose", + AuthorType: "agent", + Type: string(models.MessageTypeClarificationRequest), + Metadata: map[string]interface{}{"pending_id": "pending-1", "status": "pending"}, + }) + if err != nil { + t.Fatalf("CreateMessage: %v", err) + } + addedData := singlePublishedEventData(t, eventBus) + if got := addedData["pending_action"]; got != "clarification" { + t.Fatalf("message.added pending_action = %#v, want clarification", got) + } + addedRevision, ok := addedData["pending_action_revision"].(models.PendingActionRevision) + if !ok || addedRevision.Epoch == "" || addedRevision.Sequence == 0 { + t.Fatalf("message.added pending_action_revision = %#v", addedData["pending_action_revision"]) + } + + eventBus.ClearEvents() + message.Metadata["status"] = "answered" + if err := svc.UpdateMessage(ctx, message); err != nil { + t.Fatalf("UpdateMessage: %v", err) + } + data := singlePublishedEventData(t, eventBus) + if got, ok := data["pending_action"]; !ok || got != nil { + t.Fatalf("message.updated pending_action = %#v, want explicit nil", got) + } + updatedRevision, ok := data["pending_action_revision"].(models.PendingActionRevision) + if !ok || updatedRevision.Epoch != addedRevision.Epoch || updatedRevision.Sequence <= addedRevision.Sequence { + t.Fatalf("message.updated pending_action_revision = %#v, want after %#v", updatedRevision, addedRevision) + } +} + +func TestService_OrdinaryMessageAuthorityEventsRefreshPendingAction(t *testing.T) { + svc, eventBus, repo := createTestService(t) + ctx := context.Background() + setupTestTask(t, repo) + sessionID := setupTestSession(t, repo) + sourceTurnID := "turn-pending-source" + sourceTime := time.Now().UTC().Add(-time.Minute) + if err := repo.CreateTurn(ctx, &models.Turn{ + ID: sourceTurnID, + TaskSessionID: sessionID, + TaskID: "task-123", + StartedAt: sourceTime, + CreatedAt: sourceTime, + }); err != nil { + t.Fatalf("create source turn: %v", err) + } + if _, err := svc.CreateMessage(ctx, &CreateMessageRequest{ + TaskSessionID: sessionID, + TaskID: "task-123", + TurnID: sourceTurnID, + Content: "Choose", + AuthorType: "agent", + Type: string(models.MessageTypeClarificationRequest), + Metadata: map[string]interface{}{"pending_id": "pending-source", "status": "pending"}, + }); err != nil { + t.Fatalf("create source clarification: %v", err) + } + + successor, err := svc.ReserveTurn(ctx, sessionID, &models.PromptDispatchRecovery{}) + if err != nil { + t.Fatalf("ReserveTurn: %v", err) + } + eventBus.ClearEvents() + ordinary, err := svc.CreateMessage(ctx, &CreateMessageRequest{ + TaskSessionID: sessionID, + TaskID: "task-123", + TurnID: successor.ID, + Content: "Successor output", + AuthorType: "agent", + }) + if err != nil { + t.Fatalf("create successor message: %v", err) + } + added := singlePublishedEventData(t, eventBus) + if got, exists := added["pending_action"]; !exists || got != nil { + t.Fatalf("ordinary message.added pending_action = %#v, want explicit nil", got) + } + + eventBus.ClearEvents() + if err := svc.DeleteMessage(ctx, ordinary.ID); err != nil { + t.Fatalf("DeleteMessage: %v", err) + } + deleted := singlePublishedEventData(t, eventBus) + if got := deleted["pending_action"]; got != "clarification" { + t.Fatalf("ordinary message.deleted pending_action = %#v, want clarification", got) + } +} + func TestService_CreateMessageIdempotentReturnsCommittedMessage(t *testing.T) { svc, eventBus, repo := createTestService(t) ctx := context.Background() diff --git a/apps/backend/internal/task/service/service_turns.go b/apps/backend/internal/task/service/service_turns.go index 96a1a1669c..f2745ba9d0 100644 --- a/apps/backend/internal/task/service/service_turns.go +++ b/apps/backend/internal/task/service/service_turns.go @@ -25,24 +25,59 @@ import ( // Turn operations -const runtimeModelConfigID = "model" +const ( + runtimeModelConfigID = "model" + turnEventMetadataKey = "metadata" +) // StartTurn creates a new turn for a session and publishes the turn.started event. // Returns the created turn. func (s *Service) StartTurn(ctx context.Context, sessionID string) (*models.Turn, error) { + return s.createTurn(ctx, sessionID, true, nil) +} + +// ReserveTurn durably creates a turn before an external prompt dispatch, but +// delays turn.started until the dispatch is acknowledged. +func (s *Service) ReserveTurn( + ctx context.Context, + sessionID string, + recovery *models.PromptDispatchRecovery, +) (*models.Turn, error) { + if recovery == nil { + recovery = &models.PromptDispatchRecovery{} + } + return s.createTurn(ctx, sessionID, false, recovery) +} + +func (s *Service) createTurn( + ctx context.Context, + sessionID string, + publishStarted bool, + recovery *models.PromptDispatchRecovery, +) (*models.Turn, error) { session, err := s.sessions.GetTaskSession(ctx, sessionID) if err != nil { return nil, fmt.Errorf("failed to get session: %w", err) } + if session == nil { + return nil, fmt.Errorf("task session %s not found", sessionID) + } unlock := s.lockWorkspaceSources(session.TaskID) defer unlock() + metadata := turnStartRuntimeMetadata(session) + if recovery != nil { + metadata[models.TurnMetaKeyPromptDispatchPending] = true + metadata[models.TurnMetaKeyPromptDispatchClarificationPendingID] = recovery.PendingID + metadata[models.TurnMetaKeyPromptDispatchClarificationTurnID] = recovery.TurnID + metadata[models.TurnMetaKeyPromptDispatchClarificationMessageIDs] = recovery.MessageIDs + } turn := &models.Turn{ ID: uuid.New().String(), TaskSessionID: sessionID, TaskID: session.TaskID, StartedAt: time.Now().UTC(), - Metadata: turnStartRuntimeMetadata(session), + Metadata: metadata, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), } @@ -56,8 +91,10 @@ func (s *Service) StartTurn(ctx context.Context, sessionID string) (*models.Turn // "turns created" framing must not count a turn CreateTurn rejected. steptelemetry.RecordTurnStamp(s.logger, stamped) - // had_output is only meaningful on turn.completed; omit it from turn.started. - s.publishTurnEvent(events.TurnStarted, turn, nil) + if publishStarted { + // had_output is only meaningful on turn.completed; omit it from turn.started. + _ = s.publishTurnEvent(events.TurnStarted, turn, nil) + } s.logger.Debug("started turn", zap.String("turn_id", turn.ID), @@ -67,6 +104,168 @@ func (s *Service) StartTurn(ctx context.Context, sessionID string) (*models.Turn return turn, nil } +// MarkReservedTurnDispatchAttempted establishes an at-most-once boundary +// immediately before external dispatch. Startup fails closed on this marker +// because a crash can no longer prove whether agentctl accepted the prompt. +// On success it refreshes the supplied turn's metadata and update time from +// the persisted row so PublishReservedTurn can consume the durable marker. +func (s *Service) MarkReservedTurnDispatchAttempted(ctx context.Context, turn *models.Turn) error { + if turn == nil { + return errors.New("cannot mark nil reserved turn attempted") + } + updated, persistedMetadata, updatedAt, err := s.turns.UpdateActiveTurnMetadata( + ctx, + turn.TaskSessionID, + turn.ID, + map[string]interface{}{models.TurnMetaKeyPromptDispatchAttempted: true}, + nil, + ) + if err != nil { + return fmt.Errorf("mark reserved turn %s dispatch attempted: %w", turn.ID, err) + } + if !updated { + return fmt.Errorf("mark reserved turn %s dispatch attempted: turn is no longer active", turn.ID) + } + durable, _ := persistedMetadata[models.TurnMetaKeyPromptDispatchAttempted].(bool) + if !durable { + return fmt.Errorf("verify reserved turn %s dispatch attempt: marker missing", turn.ID) + } + turn.Metadata = persistedMetadata + turn.UpdatedAt = updatedAt + return nil +} + +// PublishReservedTurn makes a durably attempted reservation authoritative. +// Recovery metadata stays durable until the event bus accepts turn.started, so +// a failed publication remains discoverable by startup reconciliation. +func (s *Service) PublishReservedTurn(ctx context.Context, turn *models.Turn) error { + if turn == nil { + return errors.New("cannot publish nil reserved turn") + } + if attempted, _ := turn.Metadata[models.TurnMetaKeyPromptDispatchAttempted].(bool); !attempted { + return fmt.Errorf("cannot publish unattempted reserved turn %s", turn.ID) + } + // Re-read under session turn-write authority before revealing anything. A + // no-op patch gives publication a current, active durable snapshot without + // copying the caller's potentially stale metadata over concurrent fields. + updated, persistedMetadata, updatedAt, err := s.turns.UpdateActiveTurnMetadata( + ctx, + turn.TaskSessionID, + turn.ID, + nil, + nil, + ) + if err != nil { + return fmt.Errorf("publish reserved turn %s: %w", turn.ID, err) + } + if !updated { + persisted, getErr := s.turns.GetTurn(ctx, turn.ID) + if errors.Is(getErr, sql.ErrNoRows) { + return fmt.Errorf("publish reserved turn %s: reservation missing: %w", turn.ID, getErr) + } + if getErr != nil { + return fmt.Errorf("inspect unpublished reserved turn %s: %w", turn.ID, getErr) + } + if persisted.CompletedAt == nil { + return fmt.Errorf("publish reserved turn %s: active metadata update was rejected", turn.ID) + } + turn.Metadata = persisted.Metadata + turn.CompletedAt = persisted.CompletedAt + turn.UpdatedAt = persisted.UpdatedAt + return nil + } + if pending, _ := persistedMetadata[models.TurnMetaKeyPromptDispatchPending].(bool); !pending { + return fmt.Errorf("publish reserved turn %s: durable reservation marker missing", turn.ID) + } + if attempted, _ := persistedMetadata[models.TurnMetaKeyPromptDispatchAttempted].(bool); !attempted { + return fmt.Errorf("publish reserved turn %s: durable dispatch attempt marker missing", turn.ID) + } + + publicTurn := *turn + publicTurn.Metadata = maps.Clone(persistedMetadata) + models.ClearPromptDispatchMetadata(publicTurn.Metadata) + publicTurn.UpdatedAt = updatedAt + if err := s.publishTurnEvent(events.TurnStarted, &publicTurn, nil); err != nil { + return fmt.Errorf("publish reserved turn %s start event: %w", turn.ID, err) + } + + cleared, publishedMetadata, publishedAt, err := s.turns.ClearTurnPromptDispatchMetadata( + ctx, + turn.TaskSessionID, + turn.ID, + ) + if err != nil { + return fmt.Errorf("clear published reserved turn %s recovery metadata: %w", turn.ID, err) + } + if !cleared { + return fmt.Errorf("clear published reserved turn %s recovery metadata: reservation missing", turn.ID) + } + turn.Metadata = publishedMetadata + turn.UpdatedAt = publishedAt + return nil +} + +// ReconcileUnpublishedPromptTurns restores unhanded clarification responses +// and claims whose empty successor never reached agentctl, or accepts +// reservations with output proof. +func (s *Service) ReconcileUnpublishedPromptTurns(ctx context.Context) (int, error) { + if s.turns == nil { + return 0, errors.New("cannot reconcile unpublished prompt turns without a turn repository") + } + reconciled, err := s.turns.ReconcileUnpublishedPromptTurns(ctx) + if err != nil { + return reconciled, err + } + turns, err := s.turns.ListTurnsPendingStartEvent(ctx) + if err != nil { + return reconciled, fmt.Errorf("list recovered turns pending start-event replay: %w", err) + } + var replayErrs []error + for _, turn := range turns { + if err := s.replayRecoveredTurnEvents(ctx, turn); err != nil { + replayErrs = append(replayErrs, err) + } + } + return reconciled, errors.Join(replayErrs...) +} + +func (s *Service) replayRecoveredTurnEvents(ctx context.Context, turn *models.Turn) error { + if turn == nil { + return errors.New("cannot replay events for nil recovered turn") + } + if err := s.publishTurnEvent(events.TurnStarted, turn, nil); err != nil { + return fmt.Errorf("replay recovered turn %s start event: %w", turn.ID, err) + } + if turn.CompletedAt != nil { + hadOutput := s.turnHadOutput(ctx, turn) + if err := s.publishTurnEvent(events.TurnCompleted, turn, &hadOutput); err != nil { + return fmt.Errorf("replay recovered turn %s completion event: %w", turn.ID, err) + } + } + cleared, _, _, err := s.turns.ClearTurnPromptDispatchMetadata( + ctx, + turn.TaskSessionID, + turn.ID, + ) + if err != nil { + return fmt.Errorf("clear recovered turn %s event marker: %w", turn.ID, err) + } + if !cleared { + return fmt.Errorf("clear recovered turn %s event marker: turn missing", turn.ID) + } + return nil +} + +// RollbackReservedTurn removes only an empty rejected reservation. If output +// already references the row, the prompt was ambiguously accepted and the +// durable turn is preserved. +func (s *Service) RollbackReservedTurn( + ctx context.Context, + sessionID, turnID string, +) (bool, error) { + return s.turns.DeleteTurnIfUnreferenced(ctx, sessionID, turnID) +} + // createCompletedTurn persists a synthetic turn that is never observable as // active. It is used for lifecycle messages that must belong to a turn without // making an idle task appear to have an active agent turn. @@ -249,7 +448,7 @@ func (s *Service) CompleteTurn(ctx context.Context, turnID string) error { } hadOutput := s.turnHadOutput(ctx, turn) - s.publishTurnEvent(events.TurnCompleted, turn, &hadOutput) + _ = s.publishTurnEvent(events.TurnCompleted, turn, &hadOutput) s.logger.Debug("completed turn", zap.String("turn_id", turnID), @@ -277,6 +476,22 @@ func (s *Service) UpdateTurn(ctx context.Context, turn *models.Turn) error { return s.turns.UpdateTurn(ctx, turn) } +// PatchTurnMetadata atomically merges metadata into an active or completed turn. +func (s *Service) PatchTurnMetadata( + ctx context.Context, + sessionID, turnID string, + updates map[string]interface{}, +) error { + updated, _, err := s.turns.PatchTurnMetadata(ctx, sessionID, turnID, updates) + if err != nil { + return err + } + if !updated { + return fmt.Errorf("patch turn %s metadata: turn not found in session %s", turnID, sessionID) + } + return nil +} + // AbandonOpenTurns closes any open turns for a session by setting their // completed_at = started_at. Used on session resume to bury orphan turns left // behind by a previous crash/restart so a fresh prompt starts a fresh turn @@ -329,7 +544,7 @@ func (s *Service) AbandonOpenTurns(ctx context.Context, sessionID string) error // Report had_output=true so the frontend never shows an "empty turn" // notice for them — only genuine live completions should trigger it. hadOutput := true - s.publishTurnEvent(events.TurnCompleted, refreshed, &hadOutput) + _ = s.publishTurnEvent(events.TurnCompleted, refreshed, &hadOutput) } s.logger.Info("abandoned orphan turn on session resume", zap.String("turn_id", turn.ID), @@ -373,28 +588,40 @@ func (s *Service) getOrStartTurn(ctx context.Context, sessionID string) (*models // turn.completed events (the frontend uses it to surface an "empty turn" // notice). Pass nil for turn.started so the field is omitted entirely rather // than carrying a misleading "false" on a turn that has not completed. -func (s *Service) publishTurnEvent(eventType string, turn *models.Turn, hadOutput *bool) { +func (s *Service) publishTurnEvent(eventType string, turn *models.Turn, hadOutput *bool) error { if s.eventBus == nil { - return + return errors.New("turn event bus is unavailable") } if turn == nil { s.logger.Warn("publishTurnEvent: turn is nil, skipping", zap.String("event_type", eventType)) - return + return nil } + // Prompt-dispatch fields are private recovery state. A fast completion can + // race start-event publication before the durable cleanup commits, so every + // public event must sanitize its own metadata snapshot. + metadata := maps.Clone(turn.Metadata) + models.ClearPromptDispatchMetadata(metadata) payload := map[string]interface{}{ "id": turn.ID, "session_id": turn.TaskSessionID, "task_id": turn.TaskID, "started_at": turn.StartedAt, "completed_at": turn.CompletedAt, - "metadata": turn.Metadata, "created_at": turn.CreatedAt, "updated_at": turn.UpdatedAt, } + payload[turnEventMetadataKey] = metadata if hadOutput != nil { payload["had_output"] = *hadOutput } - _ = s.eventBus.Publish(context.Background(), eventType, bus.NewEvent(eventType, "task-service", payload)) + if err := s.eventBus.Publish(context.Background(), eventType, bus.NewEvent(eventType, "task-service", payload)); err != nil { + s.logger.Error("failed to publish turn event", + zap.String("event_type", eventType), + zap.String("turn_id", turn.ID), + zap.Error(err)) + return err + } + return nil } // turnHadOutput reports whether a completed turn produced any agent output. diff --git a/apps/backend/internal/task/service/service_turns_test.go b/apps/backend/internal/task/service/service_turns_test.go index be200c65d7..735172088a 100644 --- a/apps/backend/internal/task/service/service_turns_test.go +++ b/apps/backend/internal/task/service/service_turns_test.go @@ -2,8 +2,12 @@ package service import ( "context" + "database/sql" + "errors" "fmt" "path/filepath" + "slices" + "strings" "sync" "testing" "time" @@ -11,6 +15,7 @@ import ( "github.com/kandev/kandev/internal/agent/runtime/lifecycle" "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/repository" sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" @@ -22,6 +27,574 @@ type nilTaskSessionRepo struct { repository.SessionRepository } +type failGetAfterPublishMetadataRepo struct { + repository.TurnRepository + activeMetadataUpdates int +} + +type failTurnStartedEventBus struct { + *MockEventBus + err error +} + +func (b *failTurnStartedEventBus) Publish(ctx context.Context, subject string, event *bus.Event) error { + if subject == events.TurnStarted { + return b.err + } + return b.MockEventBus.Publish(ctx, subject, event) +} + +type completeTurnOnStartedEventBus struct { + *MockEventBus + complete func(context.Context) error +} + +func (b *completeTurnOnStartedEventBus) Publish(ctx context.Context, subject string, event *bus.Event) error { + if err := b.MockEventBus.Publish(ctx, subject, event); err != nil { + return err + } + if subject == events.TurnStarted { + return b.complete(ctx) + } + return nil +} + +func (r *failGetAfterPublishMetadataRepo) UpdateActiveTurnMetadata( + ctx context.Context, + sessionID, turnID string, + updates map[string]interface{}, + removeKeys []string, +) (bool, map[string]interface{}, time.Time, error) { + updated, metadata, updatedAt, err := r.TurnRepository.UpdateActiveTurnMetadata( + ctx, sessionID, turnID, updates, removeKeys, + ) + if err == nil && updated { + r.activeMetadataUpdates++ + } + return updated, metadata, updatedAt, err +} + +func (r *failGetAfterPublishMetadataRepo) GetTurn(ctx context.Context, turnID string) (*models.Turn, error) { + if r.activeMetadataUpdates >= 2 { + return nil, errors.New("transient post-commit read failure") + } + return r.TurnRepository.GetTurn(ctx, turnID) +} + +func TestReconcileUnpublishedPromptTurnsRequiresTurnRepository(t *testing.T) { + svc := &Service{} + + reconciled, err := svc.ReconcileUnpublishedPromptTurns(context.Background()) + if err == nil { + t.Fatalf("ReconcileUnpublishedPromptTurns = %d, nil; want repository error", reconciled) + } +} + +func TestReconcileUnpublishedPromptTurnsReplaysStartBeforeClearingRecovery(t *testing.T) { + svc, eventBus, repo := createTestService(t) + ctx := context.Background() + setupTestTask(t, repo) + sessionID := setupTestSession(t, repo) + turn, err := svc.ReserveTurn(ctx, sessionID, &models.PromptDispatchRecovery{}) + if err != nil { + t.Fatalf("ReserveTurn: %v", err) + } + if err := svc.MarkReservedTurnDispatchAttempted(ctx, turn); err != nil { + t.Fatalf("MarkReservedTurnDispatchAttempted: %v", err) + } + eventBus.ClearEvents() + + if _, err := svc.ReconcileUnpublishedPromptTurns(ctx); err != nil { + t.Fatalf("ReconcileUnpublishedPromptTurns: %v", err) + } + started := 0 + for _, event := range eventBus.GetPublishedEvents() { + if event.Type == events.TurnStarted { + started++ + } + } + if started != 1 { + t.Fatalf("replayed turn.started events = %d, want 1", started) + } + persisted, err := repo.GetTurn(ctx, turn.ID) + if err != nil { + t.Fatalf("GetTurn: %v", err) + } + if _, pending := persisted.Metadata[models.TurnMetaKeyPromptDispatchStartEventPending]; pending { + t.Fatalf("replayed turn retained start-event marker: %#v", persisted.Metadata) + } +} + +func TestReconcileUnpublishedPromptTurnsRetainsStartMarkerWhenReplayFails(t *testing.T) { + svc, eventBus, repo := createTestService(t) + ctx := context.Background() + setupTestTask(t, repo) + sessionID := setupTestSession(t, repo) + turn, err := svc.ReserveTurn(ctx, sessionID, &models.PromptDispatchRecovery{}) + if err != nil { + t.Fatalf("ReserveTurn: %v", err) + } + if err := svc.MarkReservedTurnDispatchAttempted(ctx, turn); err != nil { + t.Fatalf("MarkReservedTurnDispatchAttempted: %v", err) + } + svc.eventBus = &failTurnStartedEventBus{MockEventBus: eventBus, err: errors.New("nats unavailable")} + + if _, err := svc.ReconcileUnpublishedPromptTurns(ctx); err == nil { + t.Fatal("ReconcileUnpublishedPromptTurns error = nil, want replay failure") + } + persisted, err := repo.GetTurn(ctx, turn.ID) + if err != nil { + t.Fatalf("GetTurn(failed replay): %v", err) + } + if pending, _ := persisted.Metadata[models.TurnMetaKeyPromptDispatchStartEventPending].(bool); !pending { + t.Fatalf("failed replay lost start-event marker: %#v", persisted.Metadata) + } + + eventBus.ClearEvents() + svc.eventBus = eventBus + if _, err := svc.ReconcileUnpublishedPromptTurns(ctx); err != nil { + t.Fatalf("ReconcileUnpublishedPromptTurns(retry): %v", err) + } + persisted, err = repo.GetTurn(ctx, turn.ID) + if err != nil { + t.Fatalf("GetTurn(replayed): %v", err) + } + if _, pending := persisted.Metadata[models.TurnMetaKeyPromptDispatchStartEventPending]; pending { + t.Fatalf("successful replay retained start-event marker: %#v", persisted.Metadata) + } +} + +func TestReconcileUnpublishedPromptTurnsReplaysCompletedTurnInOrder(t *testing.T) { + svc, eventBus, repo := createTestService(t) + ctx := context.Background() + setupTestTask(t, repo) + sessionID := setupTestSession(t, repo) + turn, err := svc.ReserveTurn(ctx, sessionID, &models.PromptDispatchRecovery{}) + if err != nil { + t.Fatalf("ReserveTurn: %v", err) + } + if err := svc.MarkReservedTurnDispatchAttempted(ctx, turn); err != nil { + t.Fatalf("MarkReservedTurnDispatchAttempted: %v", err) + } + if err := repo.CompleteTurn(ctx, turn.ID); err != nil { + t.Fatalf("CompleteTurn: %v", err) + } + eventBus.ClearEvents() + + if _, err := svc.ReconcileUnpublishedPromptTurns(ctx); err != nil { + t.Fatalf("ReconcileUnpublishedPromptTurns: %v", err) + } + var turnEvents []string + for _, event := range eventBus.GetPublishedEvents() { + if event.Type == events.TurnStarted || event.Type == events.TurnCompleted { + turnEvents = append(turnEvents, event.Type) + } + } + want := []string{events.TurnStarted, events.TurnCompleted} + if !slices.Equal(turnEvents, want) { + t.Fatalf("replayed turn events = %v, want %v", turnEvents, want) + } +} + +func TestReserveTurnRejectsNilSessionResult(t *testing.T) { + svc := &Service{sessions: nilTaskSessionRepo{}} + + turn, err := svc.ReserveTurn(context.Background(), "missing-session", nil) + if err == nil { + t.Fatalf("ReserveTurn = %#v, nil; want missing-session error", turn) + } +} + +func TestReservedTurnPublishesOnlyAfterAcceptanceAndRollsBackWhenEmpty(t *testing.T) { + svc, eventBus, repo := createTestService(t) + ctx := context.Background() + setupTestTask(t, repo) + sessionID := setupTestSession(t, repo) + + recovery := &models.PromptDispatchRecovery{ + PendingID: "pending-reserved", TurnID: "turn-clarification", + MessageIDs: []string{"message-clarification"}, + } + rejected, err := svc.ReserveTurn(ctx, sessionID, recovery) + if err != nil { + t.Fatalf("ReserveTurn(rejected): %v", err) + } + if pending, _ := rejected.Metadata[models.TurnMetaKeyPromptDispatchPending].(bool); !pending { + t.Fatalf("reserved turn metadata = %#v, want dispatch-pending marker", rejected.Metadata) + } + if rejected.Metadata[models.TurnMetaKeyPromptDispatchClarificationPendingID] != recovery.PendingID { + t.Fatalf("reserved turn recovery metadata = %#v", rejected.Metadata) + } + for _, event := range eventBus.GetPublishedEvents() { + if event.Type == events.TurnStarted { + t.Fatal("reserved turn published before dispatch acceptance") + } + } + if err := svc.PublishReservedTurn(ctx, rejected); err == nil { + t.Fatal("PublishReservedTurn(unattempted) error = nil, want rejection") + } + rolledBack, err := svc.RollbackReservedTurn(ctx, sessionID, rejected.ID) + if err != nil || !rolledBack { + t.Fatalf("RollbackReservedTurn: rolledBack=%v err=%v", rolledBack, err) + } + if _, err := repo.GetTurn(ctx, rejected.ID); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("GetTurn(rolled back) error = %v, want sql.ErrNoRows", err) + } + + accepted, err := svc.ReserveTurn(ctx, sessionID, recovery) + if err != nil { + t.Fatalf("ReserveTurn(accepted): %v", err) + } + if err := svc.MarkReservedTurnDispatchAttempted(ctx, accepted); err != nil { + t.Fatalf("MarkReservedTurnDispatchAttempted: %v", err) + } + marked, err := repo.GetTurn(ctx, accepted.ID) + if err != nil { + t.Fatalf("GetTurn(marked accepted): %v", err) + } + if durable, _ := marked.Metadata[models.TurnMetaKeyPromptDispatchAttempted].(bool); !durable { + t.Fatalf("attempted turn metadata = %#v, want durable dispatch marker", marked.Metadata) + } + if err := svc.PublishReservedTurn(ctx, accepted); err != nil { + t.Fatalf("PublishReservedTurn: %v", err) + } + persisted, err := repo.GetTurn(ctx, accepted.ID) + if err != nil { + t.Fatalf("GetTurn(accepted): %v", err) + } + if _, pending := persisted.Metadata[models.TurnMetaKeyPromptDispatchPending]; pending { + t.Fatalf("published turn retained dispatch-pending marker: %#v", persisted.Metadata) + } + if _, attempted := persisted.Metadata[models.TurnMetaKeyPromptDispatchAttempted]; attempted { + t.Fatalf("published turn retained dispatch-attempt marker: %#v", persisted.Metadata) + } + if _, pending := persisted.Metadata[models.TurnMetaKeyPromptDispatchClarificationPendingID]; pending { + t.Fatalf("published turn retained recovery metadata: %#v", persisted.Metadata) + } + started := 0 + for _, event := range eventBus.GetPublishedEvents() { + if event.Type == events.TurnStarted { + started++ + data, _ := event.Data.(map[string]interface{}) + metadata, _ := data["metadata"].(map[string]interface{}) + if _, pending := metadata[models.TurnMetaKeyPromptDispatchPending]; pending { + t.Fatalf("turn.started exposed recovery metadata: %#v", metadata) + } + } + } + if started != 1 { + t.Fatalf("turn.started events = %d, want 1 after acceptance", started) + } +} + +func TestReservedTurnMetadataUpdatesPreserveConcurrentFields(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + setupTestTask(t, repo) + sessionID := setupTestSession(t, repo) + + turn, err := svc.ReserveTurn(ctx, sessionID, &models.PromptDispatchRecovery{}) + if err != nil { + t.Fatalf("ReserveTurn: %v", err) + } + persisted, err := repo.GetTurn(ctx, turn.ID) + if err != nil { + t.Fatalf("GetTurn(reserved): %v", err) + } + persisted.Metadata["concurrent_before_attempt"] = "keep" + if err := repo.UpdateTurn(ctx, persisted); err != nil { + t.Fatalf("add concurrent metadata before attempt: %v", err) + } + + if err := svc.MarkReservedTurnDispatchAttempted(ctx, turn); err != nil { + t.Fatalf("MarkReservedTurnDispatchAttempted: %v", err) + } + marked, err := repo.GetTurn(ctx, turn.ID) + if err != nil { + t.Fatalf("GetTurn(marked): %v", err) + } + if marked.Metadata["concurrent_before_attempt"] != "keep" { + t.Fatalf("attempt update dropped concurrent metadata: %#v", marked.Metadata) + } + marked.Metadata["concurrent_before_publish"] = "keep" + if err := repo.UpdateTurn(ctx, marked); err != nil { + t.Fatalf("add concurrent metadata before publish: %v", err) + } + + if err := svc.PublishReservedTurn(ctx, turn); err != nil { + t.Fatalf("PublishReservedTurn: %v", err) + } + published, err := repo.GetTurn(ctx, turn.ID) + if err != nil { + t.Fatalf("GetTurn(published): %v", err) + } + for _, key := range []string{"concurrent_before_attempt", "concurrent_before_publish"} { + if published.Metadata[key] != "keep" { + t.Fatalf("publish update dropped %s: %#v", key, published.Metadata) + } + } + if _, pending := published.Metadata[models.TurnMetaKeyPromptDispatchPending]; pending { + t.Fatalf("published turn retained dispatch metadata: %#v", published.Metadata) + } + if _, attempted := published.Metadata[models.TurnMetaKeyPromptDispatchAttempted]; attempted { + t.Fatalf("published turn retained dispatch-attempt metadata: %#v", published.Metadata) + } +} + +func TestPublishReservedTurnEmitsStartWithoutPostCommitRead(t *testing.T) { + svc, eventBus, repo := createTestService(t) + svc.turns = &failGetAfterPublishMetadataRepo{TurnRepository: repo} + ctx := context.Background() + setupTestTask(t, repo) + sessionID := setupTestSession(t, repo) + + turn, err := svc.ReserveTurn(ctx, sessionID, &models.PromptDispatchRecovery{}) + if err != nil { + t.Fatalf("ReserveTurn: %v", err) + } + if err := svc.MarkReservedTurnDispatchAttempted(ctx, turn); err != nil { + t.Fatalf("MarkReservedTurnDispatchAttempted: %v", err) + } + eventBus.ClearEvents() + + if err := svc.PublishReservedTurn(ctx, turn); err != nil { + t.Fatalf("PublishReservedTurn: %v", err) + } + for _, event := range eventBus.GetPublishedEvents() { + if event.Type == events.TurnStarted { + return + } + } + t.Fatal("committed turn did not publish turn.started") +} + +func TestPublishReservedTurnRetainsRecoveryStateWhenStartEventFails(t *testing.T) { + svc, eventBus, repo := createTestService(t) + failingBus := &failTurnStartedEventBus{MockEventBus: eventBus, err: errors.New("nats unavailable")} + svc.eventBus = failingBus + ctx := context.Background() + setupTestTask(t, repo) + sessionID := setupTestSession(t, repo) + + turn, err := svc.ReserveTurn(ctx, sessionID, &models.PromptDispatchRecovery{ + PendingID: "pending-recovery", + TurnID: "source-turn", + MessageIDs: []string{"clarification-message"}, + }) + if err != nil { + t.Fatalf("ReserveTurn: %v", err) + } + if err := svc.MarkReservedTurnDispatchAttempted(ctx, turn); err != nil { + t.Fatalf("MarkReservedTurnDispatchAttempted: %v", err) + } + + if err := svc.PublishReservedTurn(ctx, turn); err == nil { + t.Fatal("PublishReservedTurn error = nil, want event publication failure") + } + persisted, err := repo.GetTurn(ctx, turn.ID) + if err != nil { + t.Fatalf("GetTurn: %v", err) + } + for _, key := range []string{ + models.TurnMetaKeyPromptDispatchPending, + models.TurnMetaKeyPromptDispatchAttempted, + models.TurnMetaKeyPromptDispatchClarificationPendingID, + models.TurnMetaKeyPromptDispatchClarificationTurnID, + models.TurnMetaKeyPromptDispatchClarificationMessageIDs, + } { + if _, exists := persisted.Metadata[key]; !exists { + t.Fatalf("failed publication removed recovery key %q: %#v", key, persisted.Metadata) + } + } + for _, event := range eventBus.GetPublishedEvents() { + if event.Type == events.TurnStarted { + t.Fatal("failed publication recorded turn.started") + } + } +} + +func TestPublishReservedTurnClearsRecoveryStateAfterConcurrentCompletion(t *testing.T) { + svc, eventBus, repo := createTestService(t) + ctx := context.Background() + setupTestTask(t, repo) + sessionID := setupTestSession(t, repo) + + turn, err := svc.ReserveTurn(ctx, sessionID, &models.PromptDispatchRecovery{}) + if err != nil { + t.Fatalf("ReserveTurn: %v", err) + } + if err := svc.MarkReservedTurnDispatchAttempted(ctx, turn); err != nil { + t.Fatalf("MarkReservedTurnDispatchAttempted: %v", err) + } + svc.eventBus = &completeTurnOnStartedEventBus{ + MockEventBus: eventBus, + complete: func(ctx context.Context) error { + return svc.CompleteTurn(ctx, turn.ID) + }, + } + + if err := svc.PublishReservedTurn(ctx, turn); err != nil { + t.Fatalf("PublishReservedTurn: %v", err) + } + persisted, err := repo.GetTurn(ctx, turn.ID) + if err != nil { + t.Fatalf("GetTurn: %v", err) + } + if persisted.CompletedAt == nil { + t.Fatal("publication metadata cleanup reopened concurrently completed turn") + } + for _, key := range []string{ + models.TurnMetaKeyPromptDispatchPending, + models.TurnMetaKeyPromptDispatchAttempted, + } { + if _, exists := persisted.Metadata[key]; exists { + t.Fatalf("completed published turn retained recovery key %q: %#v", key, persisted.Metadata) + } + } + for _, event := range eventBus.GetPublishedEvents() { + if event.Type != events.TurnCompleted { + continue + } + data, _ := event.Data.(map[string]interface{}) + metadata, _ := data["metadata"].(map[string]interface{}) + for key := range metadata { + if strings.HasPrefix(key, "prompt_dispatch_") { + t.Fatalf("turn.completed exposed recovery metadata: %#v", metadata) + } + } + return + } + t.Fatal("concurrent completion did not publish turn.completed") +} + +func TestPatchTurnMetadataMergesAfterReservedTurnPublication(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + setupTestTask(t, repo) + sessionID := setupTestSession(t, repo) + turn, err := svc.ReserveTurn(ctx, sessionID, &models.PromptDispatchRecovery{}) + if err != nil { + t.Fatalf("ReserveTurn: %v", err) + } + if err := svc.MarkReservedTurnDispatchAttempted(ctx, turn); err != nil { + t.Fatalf("MarkReservedTurnDispatchAttempted: %v", err) + } + if err := svc.PublishReservedTurn(ctx, turn); err != nil { + t.Fatalf("PublishReservedTurn: %v", err) + } + if err := repo.CompleteTurn(ctx, turn.ID); err != nil { + t.Fatalf("CompleteTurn: %v", err) + } + + usage := map[string]interface{}{"input_tokens": float64(7)} + if err := svc.PatchTurnMetadata(ctx, sessionID, turn.ID, map[string]interface{}{ + "prompt_usage": usage, + "agent_id": "exec-fast", + }); err != nil { + t.Fatalf("PatchTurnMetadata: %v", err) + } + persisted, err := repo.GetTurn(ctx, turn.ID) + if err != nil { + t.Fatalf("GetTurn: %v", err) + } + gotUsage, ok := persisted.Metadata["prompt_usage"].(map[string]interface{}) + if !ok || gotUsage["input_tokens"] != float64(7) || persisted.Metadata["agent_id"] != "exec-fast" { + t.Fatalf("patched metadata = %#v, want usage and agent identity", persisted.Metadata) + } + if _, pending := persisted.Metadata[models.TurnMetaKeyPromptDispatchPending]; pending { + t.Fatalf("late metadata patch restored dispatch marker: %#v", persisted.Metadata) + } +} + +func TestPublishReservedTurnDoesNotReopenCompletedReservation(t *testing.T) { + svc, eventBus, repo := createTestService(t) + ctx := context.Background() + setupTestTask(t, repo) + sessionID := setupTestSession(t, repo) + + turn, err := svc.ReserveTurn(ctx, sessionID, &models.PromptDispatchRecovery{}) + if err != nil { + t.Fatalf("ReserveTurn: %v", err) + } + if err := svc.MarkReservedTurnDispatchAttempted(ctx, turn); err != nil { + t.Fatalf("MarkReservedTurnDispatchAttempted: %v", err) + } + if err := repo.CompleteTurn(ctx, turn.ID); err != nil { + t.Fatalf("CompleteTurn: %v", err) + } + eventBus.ClearEvents() + + if err := svc.PublishReservedTurn(ctx, turn); err != nil { + t.Fatalf("PublishReservedTurn(completed): %v", err) + } + persisted, err := repo.GetTurn(ctx, turn.ID) + if err != nil { + t.Fatalf("GetTurn: %v", err) + } + if persisted.CompletedAt == nil { + t.Fatal("PublishReservedTurn reopened completed reservation") + } + for _, event := range eventBus.GetPublishedEvents() { + if event.Type == events.TurnStarted { + t.Fatal("completed reservation published turn.started") + } + } +} + +func TestPublishReservedTurnRejectsMissingReservationWithoutEvent(t *testing.T) { + svc, eventBus, repo := createTestService(t) + ctx := context.Background() + setupTestTask(t, repo) + sessionID := setupTestSession(t, repo) + + turn, err := svc.ReserveTurn(ctx, sessionID, &models.PromptDispatchRecovery{}) + if err != nil { + t.Fatalf("ReserveTurn: %v", err) + } + if err := svc.MarkReservedTurnDispatchAttempted(ctx, turn); err != nil { + t.Fatalf("MarkReservedTurnDispatchAttempted: %v", err) + } + deleted, err := repo.DeleteTurnIfUnreferenced(ctx, sessionID, turn.ID) + if err != nil || !deleted { + t.Fatalf("DeleteTurnIfUnreferenced: deleted=%v err=%v", deleted, err) + } + eventBus.ClearEvents() + + if err := svc.PublishReservedTurn(ctx, turn); err == nil { + t.Fatal("PublishReservedTurn(missing) error = nil") + } + for _, event := range eventBus.GetPublishedEvents() { + if event.Type == events.TurnStarted { + t.Fatal("missing reservation published turn.started") + } + } +} + +func TestMarkReservedTurnDispatchAttemptedRejectsCompletedReservation(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + setupTestTask(t, repo) + sessionID := setupTestSession(t, repo) + + turn, err := svc.ReserveTurn(ctx, sessionID, &models.PromptDispatchRecovery{}) + if err != nil { + t.Fatalf("ReserveTurn: %v", err) + } + if err := repo.CompleteTurn(ctx, turn.ID); err != nil { + t.Fatalf("CompleteTurn: %v", err) + } + if err := svc.MarkReservedTurnDispatchAttempted(ctx, turn); err == nil { + t.Fatal("MarkReservedTurnDispatchAttempted(completed) error = nil") + } + persisted, err := repo.GetTurn(ctx, turn.ID) + if err != nil { + t.Fatalf("GetTurn: %v", err) + } + if persisted.CompletedAt == nil { + t.Fatal("attempt marker reopened completed reservation") + } +} + func TestStartTurnPersistsImmutableEffectiveRuntimeConfigSnapshot(t *testing.T) { svc, _, repo := createTestService(t) ctx := context.Background() diff --git a/apps/backend/internal/task/statussummary/projector.go b/apps/backend/internal/task/statussummary/projector.go index d723e322c1..6003fa851a 100644 --- a/apps/backend/internal/task/statussummary/projector.go +++ b/apps/backend/internal/task/statussummary/projector.go @@ -37,6 +37,27 @@ type GitObservation struct { // needed when a projector is recreated after a process restart. type GitObservationLoader func(context.Context, string) ([]GitObservation, error) +// PendingActionLoader returns the authoritative pending action for every +// input-capable session belonging to one task. +type PendingActionLoader func(context.Context, string) (map[string]string, error) + +// SessionObservationSnapshot is the authoritative keyed session state needed +// to rebuild aggregates that cannot be recovered from their single summary +// representative after a restart or compare-and-set loss. +type SessionObservationSnapshot struct { + Sessions []RebuildSession + ActivityObserved bool + ErrorsObserved bool +} + +// SessionObservationLoader rehydrates session, activity, and error source +// observations for one task. +type SessionObservationLoader func(context.Context, string) (SessionObservationSnapshot, error) + +// PullRequestLoader rehydrates the keyed PR observations needed to preserve +// sibling pull requests across projector restarts and CAS rebases. +type PullRequestLoader func(context.Context, string) ([]PullRequestInput, error) + // SummaryUpdated is the complete replacement payload sent to workspace // subscribers. It intentionally contains no transcript, file list, or source // event payload. @@ -51,10 +72,13 @@ type SummaryUpdated struct { func (e SummaryUpdated) GetWorkspaceID() string { return e.WorkspaceID } type ProjectorConfig struct { - Store SummaryStore - EventBus bus.EventBus - ResolveWorkspace WorkspaceResolver - LoadGitObservations GitObservationLoader + Store SummaryStore + EventBus bus.EventBus + ResolveWorkspace WorkspaceResolver + LoadGitObservations GitObservationLoader + LoadPendingActions PendingActionLoader + LoadSessionObservations SessionObservationLoader + LoadPullRequests PullRequestLoader // CountQueuedPrompts returns the number of prompts currently en-queued for // a task across all of its sessions (pending semantics identical to // message.queue.get). Wired from the messagequeue service at the @@ -69,13 +93,16 @@ type ProjectorConfig struct { // independent tasks do not block one another during a burst. Raw stream events // are never subscribed to here. type Projector struct { - store SummaryStore - eventBus bus.EventBus - resolveWorkspace WorkspaceResolver - loadGitObservations GitObservationLoader - countQueuedPrompts func(context.Context, string) (int, error) - logger *logger.Logger - now func() time.Time + store SummaryStore + eventBus bus.EventBus + resolveWorkspace WorkspaceResolver + loadGitObservations GitObservationLoader + loadPendingActions PendingActionLoader + loadSessionObservations SessionObservationLoader + loadPullRequests PullRequestLoader + countQueuedPrompts func(context.Context, string) (int, error) + logger *logger.Logger + now func() time.Time mu sync.Mutex state map[string]*projectionState @@ -151,15 +178,18 @@ func NewProjector(cfg ProjectorConfig) *Projector { now = func() time.Time { return time.Now().UTC() } } return &Projector{ - store: cfg.Store, - eventBus: cfg.EventBus, - resolveWorkspace: cfg.ResolveWorkspace, - loadGitObservations: cfg.LoadGitObservations, - countQueuedPrompts: cfg.CountQueuedPrompts, - logger: log.WithFields(zap.String("component", "task-status-summary-projector")), - now: now, - state: make(map[string]*projectionState), - taskLocks: make(map[string]*taskProjectionLock), + store: cfg.Store, + eventBus: cfg.EventBus, + resolveWorkspace: cfg.ResolveWorkspace, + loadGitObservations: cfg.LoadGitObservations, + loadPendingActions: cfg.LoadPendingActions, + loadSessionObservations: cfg.LoadSessionObservations, + loadPullRequests: cfg.LoadPullRequests, + countQueuedPrompts: cfg.CountQueuedPrompts, + logger: log.WithFields(zap.String("component", "task-status-summary-projector")), + now: now, + state: make(map[string]*projectionState), + taskLocks: make(map[string]*taskProjectionLock), } } @@ -274,15 +304,80 @@ func (p *Projector) handleEvent(ctx context.Context, event *bus.Event) error { } if event.Type == events.MessageQueueStatusChanged { + if p.loadPendingActions != nil && !state.pendingObserved { + pendingChanged, refreshErr := p.refreshPendingLocked(ctx, taskID, state) + if refreshErr != nil { + return refreshErr + } + if err := p.persistPendingRefreshLocked(ctx, taskID, state, pendingChanged, "", nil); err != nil { + return err + } + } return p.applyQueueStatusEvent(ctx, state, taskID) } + refreshPending := p.loadPendingActions != nil && + (!state.pendingObserved || isPendingSensitiveEvent(event.Type, data)) + if refreshPending { + pendingChanged, refreshErr := p.refreshPendingLocked(ctx, taskID, state) + if refreshErr != nil { + return refreshErr + } + changed := p.applySourceEventLocked(state, event.Type, data) || pendingChanged + return p.persistPendingRefreshLocked(ctx, taskID, state, changed, event.Type, data) + } + changed := p.applySourceEventLocked(state, event.Type, data) if !changed { return nil } - _, err = p.persistAndPublishLocked(ctx, taskID, state) - return err + return p.persistPendingRefreshLocked(ctx, taskID, state, true, event.Type, data) +} + +const maxPendingPersistAttempts = 3 + +func (p *Projector) persistPendingRefreshLocked( + ctx context.Context, + taskID string, + state *projectionState, + changed bool, + eventType string, + eventData map[string]interface{}, +) error { + if !changed { + return nil + } + for attempt := 0; attempt < maxPendingPersistAttempts; attempt++ { + accepted, err := p.persistAndPublishLocked(ctx, taskID, state) + if err != nil { + return err + } + if accepted { + return nil + } + // The rejected writer reloaded the winning summary into state.current. + // Rebase every derived source before replaying this event; otherwise stale + // observation maps can overwrite unrelated fields from the winner. + if err := p.rebaseProjectionStateFromCurrent(ctx, taskID, state); err != nil { + return err + } + if p.loadPendingActions != nil { + if _, err := p.refreshPendingLocked(ctx, taskID, state); err != nil { + return err + } + } + if eventType != "" { + p.applySourceEventLocked(state, eventType, eventData) + } + } + p.logger.Warn("exhausted CAS retries refreshing pending task status", + zap.String("task_id", taskID), + zap.Int("attempts", maxPendingPersistAttempts)) + return fmt.Errorf( + "exhausted CAS retries refreshing pending task status for task %s after %d attempts", + taskID, + maxPendingPersistAttempts, + ) } // maxQueueCountPersistAttempts bounds the retry loop when a competing writer @@ -330,6 +425,12 @@ func (p *Projector) applyQueueStatusEvent(ctx context.Context, state *projection if accepted { return nil } + // A competing writer may have changed any summary domain. Rebuild every + // keyed source before recounting so the queue retry cannot overwrite the + // winner with stale in-memory observations. + if err := p.rebaseProjectionStateFromCurrent(ctx, taskID, state); err != nil { + return err + } } // The count self-corrects on the next queue event or list load, but a // sustained contention run is worth surfacing so a repeated rejector is not diff --git a/apps/backend/internal/task/statussummary/projector_events.go b/apps/backend/internal/task/statussummary/projector_events.go index 4da8b5519c..66c18edbba 100644 --- a/apps/backend/internal/task/statussummary/projector_events.go +++ b/apps/backend/internal/task/statussummary/projector_events.go @@ -8,7 +8,6 @@ import ( "github.com/kandev/kandev/internal/events" ) - func (p *Projector) applySourceEventLocked(state *projectionState, eventType string, data map[string]interface{}) bool { switch eventType { case events.TaskUpdated: @@ -22,9 +21,27 @@ func (p *Projector) applySourceEventLocked(state *projectionState, eventType str case events.MessageAdded, events.MessageUpdated, events.MessageDeleted: return p.applyMessageEventLocked(state, eventType, data) case events.ClarificationAnswered, events.ClarificationPrimaryAnswered, - events.ClarificationCancelled, events.ClarificationStaleDismissed: + events.ClarificationCancelled: + if p.loadPendingActions != nil { + return false + } return p.clearPendingLocked(state, stringField(data, "session_id")) + case events.ClarificationStaleDismissed: + if p.loadPendingActions != nil { + return false + } + sessionID := stringField(data, "session_id") + dismissedID := stringField(data, "pending_id") + if dismissedID != "" { + if current, ok := state.pendingRequests[sessionID]; ok && current.pendingID != dismissedID { + return false + } + } + return p.clearPendingLocked(state, sessionID) case events.PermissionRequestReceived: + if p.loadPendingActions != nil { + return false + } return applyPermissionEventLocked(state, data) case events.GitEvent: return p.applyGitEventLocked(state, data) @@ -58,6 +75,9 @@ func applyPermissionEventLocked(state *projectionState, data map[string]interfac func (p *Projector) applyTaskUpdatedEventLocked(state *projectionState, data map[string]interface{}) bool { primarySessionID, primaryChanged := p.applyTaskPrimaryUpdateLocked(state, data) + if p.loadPendingActions != nil { + return primaryChanged + } pendingChanged := applyTaskPendingUpdateLocked(state, data, primarySessionID) return primaryChanged || pendingChanged } @@ -206,11 +226,24 @@ func (p *Projector) restorePersistedState(ctx context.Context, taskID string, st return fmt.Errorf("load task status summary %q: %w", taskID, err) } summary := rows[taskID] - if summary == nil { - return nil + if summary != nil { + state.current = cloneSummary(summary) + state.revision = summary.Revision + applySummaryBaseline(state, summary) } - state.current = cloneSummary(summary) - state.revision = summary.Revision + if err := p.restoreSessionObservations(ctx, taskID, state); err != nil { + return err + } + if err := p.restoreGitObservations(ctx, taskID, state); err != nil { + return err + } + if err := p.restorePullRequestObservations(ctx, taskID, state); err != nil { + return err + } + return nil +} + +func applySummaryBaseline(state *projectionState, summary *TaskStatusSummary) { state.queuedCount = summary.QueuedPromptCount state.taskPending = summary.PendingAction if summary.PrimarySession != nil && summary.PrimarySession.ID != "" { @@ -227,14 +260,90 @@ func (p *Projector) restorePersistedState(ctx context.Context, taskID string, st if summary.Git != nil { copy := *summary.Git state.gitBaseline = © - if err := p.restoreGitObservations(ctx, taskID, state); err != nil { - return err - } } if summary.PullRequest != nil { copy := *summary.PullRequest state.prBaseline = © } +} + +// rebaseProjectionStateFromCurrent rebuilds all derived source state from the +// summary that won a rejected compare-and-set. Pending authority and the +// triggering source event are refreshed by the caller before retrying. +func (p *Projector) rebaseProjectionStateFromCurrent( + ctx context.Context, + taskID string, + state *projectionState, +) error { + if state.current == nil { + return nil + } + current := state.current + state.sessions = make(map[string]sessionObservation) + state.activityObserved = false + state.pending = make(map[string]string) + state.pendingRequests = make(map[string]pendingRequestIdentity) + state.pendingObserved = false + state.errors = make(map[string]*ActiveErrorSummary) + state.errorsObserved = false + state.git = make(map[string]GitSummary) + state.gitBaseline = nil + state.gitObserved = false + state.prs = make(map[string]pullRequestObservation) + state.prBaseline = nil + state.prObserved = false + applySummaryBaseline(state, current) + if err := p.restoreSessionObservations(ctx, taskID, state); err != nil { + return err + } + if err := p.restoreGitObservations(ctx, taskID, state); err != nil { + return err + } + if err := p.restorePullRequestObservations(ctx, taskID, state); err != nil { + return err + } + return nil +} + +func (p *Projector) restoreSessionObservations( + ctx context.Context, + taskID string, + state *projectionState, +) error { + if p.loadSessionObservations == nil { + return nil + } + snapshot, err := p.loadSessionObservations(ctx, taskID) + if err != nil { + return fmt.Errorf("load session observations for task status summary %q: %w", taskID, err) + } + sessions := make(map[string]sessionObservation, len(snapshot.Sessions)) + errorsBySession := make(map[string]*ActiveErrorSummary, len(snapshot.Sessions)) + for _, input := range snapshot.Sessions { + sessionID := strings.TrimSpace(input.ID) + if sessionID == "" { + continue + } + sessions[sessionID] = sessionObservation{ + id: sessionID, + state: input.State, + isPrimary: input.IsPrimary, + foregroundActivity: input.ForegroundActivity, + activeSubagentCount: maxInt(input.ActiveSubagentCount, 0), + } + activeError := normalizeRebuildError(input.ActiveError, p.now().UTC()) + if activeError == nil || state.clearedErrorStamps[sessionID] == activeError.Stamp { + continue + } + activeError.SessionID = sessionID + errorsBySession[sessionID] = activeError + } + state.sessions = sessions + state.activityObserved = snapshot.ActivityObserved + if snapshot.ErrorsObserved { + state.errors = errorsBySession + state.errorsObserved = true + } return nil } @@ -256,6 +365,25 @@ func (p *Projector) restoreGitObservations(ctx context.Context, taskID string, s return nil } +func (p *Projector) restorePullRequestObservations( + ctx context.Context, + taskID string, + state *projectionState, +) error { + if p.loadPullRequests == nil { + return nil + } + pullRequests, err := p.loadPullRequests(ctx, taskID) + if err != nil { + return fmt.Errorf("load PR observations for task status summary %q: %w", taskID, err) + } + state.prs = make(map[string]pullRequestObservation, len(pullRequests)) + state.prBaseline = nil + state.prObserved = true + applyPullRequestInputs(state, pullRequests) + return nil +} + func (p *Projector) applySessionEventLocked(state *projectionState, data map[string]interface{}) bool { sessionID := firstString(data, "session_id", "primary_session_id") if sessionID == "" { @@ -392,6 +520,9 @@ func (p *Projector) clearSupersededErrorFromMessageLocked( } func (p *Projector) applyPendingMessageLocked(state *projectionState, eventType string, data map[string]interface{}, sessionID string) bool { + if p.loadPendingActions != nil { + return false + } messageType := strings.ToLower(stringField(data, "type")) metadata, _ := data["metadata"].(map[string]interface{}) status := strings.ToLower(stringField(metadata, "status")) diff --git a/apps/backend/internal/task/statussummary/projector_helpers.go b/apps/backend/internal/task/statussummary/projector_helpers.go index 9f428ff1cf..269678bfb3 100644 --- a/apps/backend/internal/task/statussummary/projector_helpers.go +++ b/apps/backend/internal/task/statussummary/projector_helpers.go @@ -1,13 +1,75 @@ package statussummary import ( + "context" "encoding/json" "fmt" "strconv" + "strings" "time" "unicode/utf8" + + "github.com/kandev/kandev/internal/events" ) +func isPendingSensitiveEvent(eventType string, data map[string]interface{}) bool { + switch eventType { + case events.TaskUpdated, + events.TaskSessionStateChanged, + events.MessageAdded, + events.ClarificationAnswered, + events.ClarificationPrimaryAnswered, + events.ClarificationCancelled, + events.ClarificationStaleDismissed, + events.PermissionRequestReceived: + return true + case events.MessageUpdated, events.MessageDeleted: + messageType := strings.ToLower(stringField(data, "type")) + return pendingActionForMessage(messageType, boolValue(data["requests_input"])) != "" + default: + return false + } +} + +func (p *Projector) refreshPendingLocked( + ctx context.Context, + taskID string, + state *projectionState, +) (bool, error) { + actions, err := p.loadPendingActions(ctx, taskID) + if err != nil { + return false, fmt.Errorf("load pending actions for task status summary %q: %w", taskID, err) + } + next := make(map[string]string, len(actions)) + for sessionID, action := range actions { + sessionID = strings.TrimSpace(sessionID) + action = strings.TrimSpace(action) + if sessionID == "" || (action != pendingClarification && action != pendingPermission) { + continue + } + next[sessionID] = action + } + changed := !pendingActionsEqual(state.pending, next) + previousTaskPending := state.taskPending + state.pendingObserved = true + state.pending = next + state.pendingRequests = make(map[string]pendingRequestIdentity) + recomputeTaskPending(state) + return changed || state.taskPending != previousTaskPending, nil +} + +func pendingActionsEqual(left, right map[string]string) bool { + if len(left) != len(right) { + return false + } + for sessionID, action := range left { + if right[sessionID] != action { + return false + } + } + return true +} + func (p *Projector) clearPendingLocked(state *projectionState, sessionID string) bool { if sessionID == "" { return false @@ -16,6 +78,9 @@ func (p *Projector) clearPendingLocked(state *projectionState, sessionID string) _, existed := state.pending[sessionID] delete(state.pending, sessionID) delete(state.pendingRequests, sessionID) + // A restarted projector has only the aggregate taskPending baseline, not + // the original per-session entry. Recompute before deciding this is a no-op + // so stale-dismissed events still clear and persist that restored value. previousTaskPending := state.taskPending recomputeTaskPending(state) return existed || previousTaskPending != state.taskPending diff --git a/apps/backend/internal/task/statussummary/projector_pending_authority_test.go b/apps/backend/internal/task/statussummary/projector_pending_authority_test.go new file mode 100644 index 0000000000..f84abbf249 --- /dev/null +++ b/apps/backend/internal/task/statussummary/projector_pending_authority_test.go @@ -0,0 +1,484 @@ +package statussummary + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" +) + +type rejectingPendingProjectorStore struct { + *projectorTestStore + mu sync.Mutex + competing StoredTaskStatusSummary + rejected bool +} + +type alwaysRejectingPendingProjectorStore struct { + *projectorTestStore + attempts int +} + +func (s *alwaysRejectingPendingProjectorStore) CompareAndUpdateTaskStatusSummary( + context.Context, + *StoredTaskStatusSummary, +) (bool, error) { + s.attempts++ + return false, nil +} + +func (s *rejectingPendingProjectorStore) CompareAndUpdateTaskStatusSummary( + ctx context.Context, + stored *StoredTaskStatusSummary, +) (bool, error) { + s.mu.Lock() + if !s.rejected { + s.rejected = true + s.mu.Unlock() + if _, err := s.projectorTestStore.CompareAndUpdateTaskStatusSummary(ctx, &s.competing); err != nil { + return false, err + } + return false, nil + } + s.mu.Unlock() + return s.projectorTestStore.CompareAndUpdateTaskStatusSummary(ctx, stored) +} + +func TestProjectorRefreshesPendingFromAuthorityOnRestore(t *testing.T) { + store := newProjectorTestStore() + storedAt := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) + store.rows["task-stale"] = &StoredTaskStatusSummary{ + TaskID: "task-stale", + WorkspaceID: "workspace-1", + Summary: TaskStatusSummary{ + Revision: 4, + UpdatedAt: storedAt, + PendingAction: pendingClarification, + Git: &GitSummary{ChangedFiles: 3}, + }, + } + projector := NewProjector(ProjectorConfig{ + Store: store, + LoadPendingActions: func(context.Context, string) (map[string]string, error) { + return map[string]string{}, nil + }, + Now: func() time.Time { return storedAt.Add(time.Minute) }, + }) + + err := projector.HandleEvent(context.Background(), bus.NewEvent(events.TaskUpdated, "test", map[string]interface{}{ + "task_id": "task-stale", + "workspace_id": "workspace-1", + })) + if err != nil { + t.Fatalf("restore stale summary: %v", err) + } + + got := store.summary("task-stale") + if got == nil || got.PendingAction != "" { + t.Fatalf("pending action after restore = %+v, want cleared", got) + } + if got.Revision != 5 || got.Git == nil || got.Git.ChangedFiles != 3 { + t.Fatalf("repaired summary = %+v, want revision 5 with Git preserved", got) + } +} + +func TestProjectorRefreshesPendingAfterEveryPendingSensitiveMessage(t *testing.T) { + store := newProjectorTestStore() + actions := map[string]string{"session-1": pendingClarification} + projector := NewProjector(ProjectorConfig{ + Store: store, + LoadPendingActions: func(context.Context, string) (map[string]string, error) { + copy := make(map[string]string, len(actions)) + for sessionID, action := range actions { + copy[sessionID] = action + } + return copy, nil + }, + Now: func() time.Time { return time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) }, + }) + + message := func(eventType, messageType string) *bus.Event { + return bus.NewEvent(eventType, "test", map[string]interface{}{ + "task_id": "task-current", + "workspace_id": "workspace-1", + "session_id": "session-1", + "author_type": "user", + "type": messageType, + }) + } + if err := projector.HandleEvent(context.Background(), message(events.MessageAdded, messageTypeClarificationRequest)); err != nil { + t.Fatalf("arm current clarification: %v", err) + } + if got := store.summary("task-current"); got == nil || got.PendingAction != pendingClarification { + t.Fatalf("armed summary = %+v", got) + } + + for _, event := range []struct { + eventType string + messageType string + }{ + {eventType: events.MessageAdded, messageType: "text"}, + {eventType: events.MessageDeleted, messageType: messageTypeClarificationRequest}, + } { + actions = map[string]string{} + if err := projector.HandleEvent(context.Background(), message(event.eventType, event.messageType)); err != nil { + t.Fatalf("refresh after %s: %v", event.eventType, err) + } + if got := store.summary("task-current"); got == nil || got.PendingAction != "" { + t.Fatalf("pending after %s = %+v, want cleared", event.eventType, got) + } + + actions = map[string]string{"session-1": pendingClarification} + if err := projector.HandleEvent(context.Background(), message(events.MessageAdded, messageTypeClarificationRequest)); err != nil { + t.Fatalf("re-arm after %s: %v", event.eventType, err) + } + } +} + +func TestProjectorSkipsPendingRefreshForStreamingMessageUpdates(t *testing.T) { + store := newProjectorTestStore() + actions := map[string]string{} + loaderCalls := 0 + projector := NewProjector(ProjectorConfig{ + Store: store, + LoadPendingActions: func(context.Context, string) (map[string]string, error) { + loaderCalls++ + return actions, nil + }, + }) + ctx := context.Background() + event := func(messageType string) *bus.Event { + return bus.NewEvent(events.MessageUpdated, "test", map[string]interface{}{ + "task_id": "task-streaming", + "workspace_id": "workspace-1", + "session_id": "session-1", + "author_type": "agent", + "type": messageType, + }) + } + if err := projector.HandleEvent(ctx, bus.NewEvent(events.TaskUpdated, "test", map[string]interface{}{ + "task_id": "task-streaming", "workspace_id": "workspace-1", + })); err != nil { + t.Fatalf("initialize projection: %v", err) + } + loaderCalls = 0 + + if err := projector.HandleEvent(ctx, event("message")); err != nil { + t.Fatalf("streaming message update: %v", err) + } + if loaderCalls != 0 { + t.Fatalf("pending loader calls after streaming update = %d, want 0", loaderCalls) + } + + actions = map[string]string{"session-1": pendingClarification} + if err := projector.HandleEvent(ctx, event(messageTypeClarificationRequest)); err != nil { + t.Fatalf("clarification update: %v", err) + } + if loaderCalls != 1 { + t.Fatalf("pending loader calls after clarification update = %d, want 1", loaderCalls) + } +} + +func TestProjectorPendingLoaderFailureRetainsStoredState(t *testing.T) { + store := newProjectorTestStore() + storedAt := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) + store.rows["task-load-error"] = &StoredTaskStatusSummary{ + TaskID: "task-load-error", + WorkspaceID: "workspace-1", + Summary: TaskStatusSummary{ + Revision: 2, + UpdatedAt: storedAt, + PendingAction: pendingPermission, + }, + } + projector := NewProjector(ProjectorConfig{ + Store: store, + LoadPendingActions: func(context.Context, string) (map[string]string, error) { + return nil, errors.New("pending lookup failed") + }, + Now: func() time.Time { return storedAt.Add(time.Minute) }, + }) + + err := projector.HandleEvent(context.Background(), bus.NewEvent(events.MessageAdded, "test", map[string]interface{}{ + "task_id": "task-load-error", + "workspace_id": "workspace-1", + "session_id": "session-1", + "author_type": "user", + "type": "text", + })) + if err == nil { + t.Fatal("pending loader error = nil, want surfaced error") + } + got := store.summary("task-load-error") + if got == nil || got.Revision != 2 || got.PendingAction != pendingPermission { + t.Fatalf("stored summary after loader failure = %+v", got) + } +} + +func TestProjectorPendingRefreshSurfacesExhaustedCASRetries(t *testing.T) { + base := newProjectorTestStore() + base.rows["task-cas-exhausted"] = &StoredTaskStatusSummary{ + TaskID: "task-cas-exhausted", + WorkspaceID: "workspace-1", + Summary: TaskStatusSummary{ + Revision: 1, + }, + } + store := &alwaysRejectingPendingProjectorStore{projectorTestStore: base} + projector := NewProjector(ProjectorConfig{ + Store: store, + LoadPendingActions: func(context.Context, string) (map[string]string, error) { + return map[string]string{"session-1": pendingClarification}, nil + }, + }) + + err := projector.HandleEvent(context.Background(), bus.NewEvent(events.MessageUpdated, "test", map[string]interface{}{ + "task_id": "task-cas-exhausted", + "workspace_id": "workspace-1", + "session_id": "session-1", + "author_type": "agent", + "type": messageTypeClarificationRequest, + })) + if err == nil { + t.Fatal("exhausted pending CAS retries returned nil") + } + if store.attempts != maxPendingPersistAttempts { + t.Fatalf("CAS attempts = %d, want %d", store.attempts, maxPendingPersistAttempts) + } + if got := base.summary("task-cas-exhausted"); got == nil || got.PendingAction != "" { + t.Fatalf("stored summary after exhausted CAS retries = %+v, want unchanged", got) + } +} + +func TestProjectorPendingRefreshRetriesAfterCASRejection(t *testing.T) { + base := newProjectorTestStore() + storedAt := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) + base.rows["task-cas"] = &StoredTaskStatusSummary{ + TaskID: "task-cas", + WorkspaceID: "workspace-1", + Summary: TaskStatusSummary{ + Revision: 5, + UpdatedAt: storedAt, + PendingAction: pendingClarification, + Git: &GitSummary{ChangedFiles: 1}, + }, + } + store := &rejectingPendingProjectorStore{ + projectorTestStore: base, + competing: StoredTaskStatusSummary{ + TaskID: "task-cas", + WorkspaceID: "workspace-1", + Summary: TaskStatusSummary{ + Revision: 6, + UpdatedAt: storedAt.Add(time.Second), + PendingAction: pendingClarification, + QueuedPromptCount: 7, + Git: &GitSummary{ChangedFiles: 2}, + }, + }, + } + loaderCalls := 0 + sessionLoaderCalls := 0 + gitLoaderCalls := 0 + prLoaderCalls := 0 + projector := NewProjector(ProjectorConfig{ + Store: store, + LoadSessionObservations: func(context.Context, string) (SessionObservationSnapshot, error) { + sessionLoaderCalls++ + return SessionObservationSnapshot{ + ActivityObserved: true, + ErrorsObserved: true, + Sessions: []RebuildSession{ + { + ID: "session-current", + State: sessionStateRunning, + IsPrimary: true, + ForegroundActivity: activityGenerating, + ActiveSubagentCount: 1, + ActiveError: &ActiveErrorSummary{ + SessionID: "session-current", Stamp: "error-current", + OccurredAt: storedAt.Add(2 * time.Minute), Preview: "current failed", + }, + }, + { + ID: "session-sibling", + State: sessionStateRunning, + ForegroundActivity: activityBackground, + ActiveSubagentCount: 2, + ActiveError: &ActiveErrorSummary{ + SessionID: "session-sibling", Stamp: "error-sibling", + OccurredAt: storedAt.Add(time.Minute), Preview: "sibling failed", + }, + }, + }, + }, nil + }, + LoadPendingActions: func(context.Context, string) (map[string]string, error) { + loaderCalls++ + return map[string]string{}, nil + }, + LoadGitObservations: func(context.Context, string) ([]GitObservation, error) { + gitLoaderCalls++ + return []GitObservation{ + {Repository: "repo-a", Summary: GitSummary{ChangedFiles: 1}}, + {Repository: "repo-b", Summary: GitSummary{ChangedFiles: 1}}, + }, nil + }, + LoadPullRequests: func(context.Context, string) ([]PullRequestInput, error) { + prLoaderCalls++ + return []PullRequestInput{ + {Key: "repo-a#41", State: prStateOpen, Number: 41, URL: "https://example.test/41"}, + {Key: "repo-b#42", State: prStateOpen, Number: 42, URL: "https://example.test/42"}, + }, nil + }, + Now: func() time.Time { return storedAt.Add(2 * time.Second) }, + }) + + err := projector.HandleEvent(context.Background(), bus.NewEvent(events.TaskUpdated, "test", map[string]interface{}{ + "task_id": "task-cas", + "workspace_id": "workspace-1", + "primary_session_id": "session-current", + "primary_session_state": sessionStateRunning, + })) + if err != nil { + t.Fatalf("refresh after CAS rejection: %v", err) + } + got := base.summary("task-cas") + if got == nil || got.Revision != 7 || got.PendingAction != "" || got.QueuedPromptCount != 7 || + got.Git == nil || got.Git.ChangedFiles != 2 || got.PrimarySession == nil || + got.PrimarySession.ID != "session-current" || got.PullRequest == nil || got.PullRequest.Count != 2 { + t.Fatalf("summary after CAS retry = %+v", got) + } + if loaderCalls != 2 { + t.Fatalf("pending loader calls = %d, want reload after rejection", loaderCalls) + } + if sessionLoaderCalls != 2 { + t.Fatalf("session loader calls = %d, want reload after rejection", sessionLoaderCalls) + } + if gitLoaderCalls != 2 { + t.Fatalf("Git loader calls = %d, want reload after rejection", gitLoaderCalls) + } + if prLoaderCalls != 2 { + t.Fatalf("PR loader calls = %d, want reload after rejection", prLoaderCalls) + } + + err = projector.HandleEvent(context.Background(), bus.NewEvent(events.GitEvent, "test", map[string]interface{}{ + "task_id": "task-cas", + "workspace_id": "workspace-1", + "session_id": "session-current", + "type": "status_update", + "status": map[string]interface{}{ + "repository_name": "repo-a", + "changed_files": 3, + }, + })) + if err != nil { + t.Fatalf("Git replay after CAS rejection: %v", err) + } + got = base.summary("task-cas") + if got == nil || got.Git == nil || got.Git.ChangedFiles != 4 { + t.Fatalf("Git summary after CAS replay = %+v, want both repositories (4 changed files)", got) + } + + err = projector.HandleEvent(context.Background(), bus.NewEvent(events.GitHubTaskPRUpdated, "test", map[string]interface{}{ + "task_id": "task-cas", + "workspace_id": "workspace-1", + "repository_id": "repo-a", + "state": prStateOpen, + "pr_number": 41, + "pr_url": "https://example.test/41", + "checks_state": prStateFailure, + })) + if err != nil { + t.Fatalf("PR replay after CAS rejection: %v", err) + } + got = base.summary("task-cas") + if got == nil || got.PullRequest == nil || got.PullRequest.Count != 2 { + t.Fatalf("PR summary after CAS replay = %+v, want both pull requests", got) + } + + err = projector.HandleEvent(context.Background(), bus.NewEvent(events.TaskSessionActivityChanged, "test", map[string]interface{}{ + "task_id": "task-cas", + "workspace_id": "workspace-1", + "session_id": "session-current", + "foreground_activity": activityBackground, + "active_subagent_count": 0, + })) + if err != nil { + t.Fatalf("activity replay after CAS rejection: %v", err) + } + got = base.summary("task-cas") + if got == nil || got.ForegroundActivity != activityBackground || got.ActiveSubagentCount != 2 { + t.Fatalf("activity after CAS replay = %+v, want sibling activity/count preserved", got) + } + + err = projector.HandleEvent(context.Background(), bus.NewEvent(events.TaskSessionErrorChanged, "test", map[string]interface{}{ + "task_id": "task-cas", + "workspace_id": "workspace-1", + "session_id": "session-current", + "active": false, + })) + if err != nil { + t.Fatalf("error recovery after CAS rejection: %v", err) + } + got = base.summary("task-cas") + if got == nil || got.ActiveError == nil || got.ActiveError.SessionID != "session-sibling" { + t.Fatalf("error after representative recovery = %+v, want sibling error preserved", got) + } +} + +func TestProjectorSourceEventRetriesAfterCASRejectionWithoutPendingLoader(t *testing.T) { + const taskID = "task-source-cas" + base := newProjectorTestStore() + base.rows[taskID] = &StoredTaskStatusSummary{ + TaskID: taskID, + WorkspaceID: "workspace-1", + Summary: TaskStatusSummary{ + Revision: 5, + Git: &GitSummary{ChangedFiles: 2}, + }, + } + store := &rejectingPendingProjectorStore{ + projectorTestStore: base, + competing: StoredTaskStatusSummary{ + TaskID: taskID, + WorkspaceID: "workspace-1", + Summary: TaskStatusSummary{ + Revision: 6, + Git: &GitSummary{ChangedFiles: 2}, + PullRequest: &PullRequestSummary{Count: 1, OpenCount: 1, State: prStateOpen}, + }, + }, + } + projector := NewProjector(ProjectorConfig{ + Store: store, + LoadGitObservations: func(context.Context, string) ([]GitObservation, error) { + return []GitObservation{{Repository: "repo-b", Summary: GitSummary{ChangedFiles: 2}}}, nil + }, + }) + + err := projector.HandleEvent(context.Background(), bus.NewEvent(events.GitEvent, "test", map[string]interface{}{ + "task_id": taskID, + "workspace_id": "workspace-1", + "session_id": "session-current", + "type": "status_update", + "status": map[string]interface{}{ + "repository_name": "repo-a", + "changed_files": 3, + }, + })) + if err != nil { + t.Fatalf("Git event after CAS rejection: %v", err) + } + got := base.summary(taskID) + if got == nil || got.Revision != 7 || got.Git == nil || got.Git.ChangedFiles != 5 { + t.Fatalf("summary after source replay = %+v, want revision 7 with both Git observations", got) + } + if got.PullRequest == nil || got.PullRequest.Count != 1 { + t.Fatalf("pull request after source replay = %+v, want competing writer preserved", got.PullRequest) + } +} diff --git a/apps/backend/internal/task/statussummary/projector_pr.go b/apps/backend/internal/task/statussummary/projector_pr.go index 98f075fab8..c8227f53a8 100644 --- a/apps/backend/internal/task/statussummary/projector_pr.go +++ b/apps/backend/internal/task/statussummary/projector_pr.go @@ -1,10 +1,39 @@ package statussummary import ( + "fmt" "sort" "strings" ) +func applyPullRequestInputs(state *projectionState, inputs []PullRequestInput) { + for index, input := range inputs { + key := strings.TrimSpace(input.Key) + if key == "" { + key = strings.TrimSpace(input.URL) + } + if key == "" && input.Number > 0 { + key = fmt.Sprintf("#%d", input.Number) + } + if key == "" { + key = fmt.Sprintf("index:%d", index) + } + state.prs[key] = pullRequestObservation{ + state: input.State, + number: maxInt(input.Number, 0), + url: input.URL, + reviewState: input.ReviewState, + checksState: input.ChecksState, + mergeableState: input.MergeableState, + unresolvedReviewCount: maxInt(input.UnresolvedReviewCount, 0), + pendingReviewCount: maxInt(input.PendingReviewCount, 0), + requiredReviews: maxInt(input.RequiredReviews, 0), + checksTotal: maxInt(input.ChecksTotal, 0), + checksPassing: maxInt(input.ChecksPassing, 0), + } + } +} + func derivePullRequestSummary(state *projectionState) *PullRequestSummary { if !state.prObserved && state.prBaseline != nil { copy := *state.prBaseline diff --git a/apps/backend/internal/task/statussummary/projector_queued_test.go b/apps/backend/internal/task/statussummary/projector_queued_test.go index 024b0980d0..d95ccccf77 100644 --- a/apps/backend/internal/task/statussummary/projector_queued_test.go +++ b/apps/backend/internal/task/statussummary/projector_queued_test.go @@ -125,8 +125,9 @@ func TestProjectorQueueEventWithoutTaskIDIsIgnored(t *testing.T) { // another writer persisting between the projector's count query and its // write), then accepts subsequent writes. type competingWriterStore struct { - base *projectorTestStore - rejected bool + base *projectorTestStore + competingGit *GitSummary + rejected bool } func (s *competingWriterStore) LoadTaskStatusSummaries( @@ -146,6 +147,7 @@ func (s *competingWriterStore) CompareAndUpdateTaskStatusSummary( if row := rows[stored.TaskID]; row != nil { competing := *row competing.Revision = stored.Summary.Revision + 1 // beat the projector's attempt + competing.Git = s.competingGit _, _ = s.base.CompareAndUpdateTaskStatusSummary(ctx, &StoredTaskStatusSummary{ TaskID: stored.TaskID, WorkspaceID: stored.WorkspaceID, @@ -158,7 +160,10 @@ func (s *competingWriterStore) CompareAndUpdateTaskStatusSummary( func TestProjectorQueueEventRetriesAfterRejectedWrite(t *testing.T) { const taskID = "task-queued-retry" - store := &competingWriterStore{base: newProjectorTestStore()} + store := &competingWriterStore{ + base: newProjectorTestStore(), + competingGit: &GitSummary{ChangedFiles: 9}, + } store.base.rows[taskID] = &StoredTaskStatusSummary{ TaskID: taskID, WorkspaceID: "workspace-1", @@ -210,6 +215,9 @@ func TestProjectorQueueEventRetriesAfterRejectedWrite(t *testing.T) { if got := updates.Load(); got != 1 { t.Fatalf("publishes = %d, want exactly 1 (the retried write)", got) } + if summary.Git == nil || summary.Git.ChangedFiles != 9 { + t.Fatalf("Git summary = %+v, want competing writer's observation preserved", summary.Git) + } } func TestProjectorQueueEventForMissingTaskIsNoop(t *testing.T) { diff --git a/apps/backend/internal/task/statussummary/projector_restart_rehydration_test.go b/apps/backend/internal/task/statussummary/projector_restart_rehydration_test.go new file mode 100644 index 0000000000..7f42d64e0e --- /dev/null +++ b/apps/backend/internal/task/statussummary/projector_restart_rehydration_test.go @@ -0,0 +1,163 @@ +package statussummary + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" +) + +func TestProjectorRehydratesGitObservationsAfterRestartWithoutAggregate(t *testing.T) { + store := newProjectorTestStore() + store.rows["task-git-restart-empty"] = &StoredTaskStatusSummary{ + TaskID: "task-git-restart-empty", + WorkspaceID: "workspace-1", + Summary: TaskStatusSummary{Revision: 1}, + } + loaderCalls := 0 + projector := NewProjector(ProjectorConfig{ + Store: store, + ResolveWorkspace: func(context.Context, string) (string, error) { + return "workspace-1", nil + }, + LoadGitObservations: func(context.Context, string) ([]GitObservation, error) { + loaderCalls++ + return []GitObservation{ + {Repository: "repo-a", Summary: GitSummary{Additions: 5, ChangedFiles: 2}}, + {Repository: "repo-b", Summary: GitSummary{Additions: 2, ChangedFiles: 1}}, + }, nil + }, + Now: func() time.Time { return time.Date(2026, 8, 16, 0, 30, 0, 0, time.UTC) }, + }) + + err := projector.HandleEvent(context.Background(), bus.NewEvent(events.GitEvent, "test", map[string]interface{}{ + "task_id": "task-git-restart-empty", + "workspace_id": "workspace-1", + "session_id": "session-1", + "type": "status_update", + "status": map[string]interface{}{ + "repository_name": "repo-a", + "branch_additions": 6, + "changed_files": 2, + }, + })) + if err != nil { + t.Fatalf("replay Git event: %v", err) + } + + got := store.summary("task-git-restart-empty") + if got == nil || got.Git == nil || got.Git.Additions != 8 || got.Git.ChangedFiles != 3 { + t.Fatalf("Git summary after empty-baseline restart = %+v, want additions=8 changed_files=3", got) + } + if loaderCalls != 1 { + t.Fatalf("Git loader calls = %d, want 1", loaderCalls) + } +} + +func TestProjectorRehydratesPullRequestObservationsAfterRestartWithoutAggregate(t *testing.T) { + store := newProjectorTestStore() + store.rows["task-pr-restart-empty"] = &StoredTaskStatusSummary{ + TaskID: "task-pr-restart-empty", + WorkspaceID: "workspace-1", + Summary: TaskStatusSummary{Revision: 1}, + } + loaderCalls := 0 + projector := NewProjector(ProjectorConfig{ + Store: store, + ResolveWorkspace: func(context.Context, string) (string, error) { + return "workspace-1", nil + }, + LoadPullRequests: func(context.Context, string) ([]PullRequestInput, error) { + loaderCalls++ + return []PullRequestInput{ + {Key: "repo-a#41", State: prStateOpen, Number: 41, URL: "https://example.test/41"}, + {Key: "repo-b#42", State: prStateOpen, Number: 42, URL: "https://example.test/42"}, + }, nil + }, + Now: func() time.Time { return time.Date(2026, 8, 16, 0, 30, 0, 0, time.UTC) }, + }) + + err := projector.HandleEvent(context.Background(), bus.NewEvent(events.GitHubTaskPRUpdated, "test", map[string]interface{}{ + "task_id": "task-pr-restart-empty", + "workspace_id": "workspace-1", + "repository_id": "repo-a", + "state": prStateOpen, + "pr_number": 41, + "pr_url": "https://example.test/41", + "checks_state": prStateFailure, + })) + if err != nil { + t.Fatalf("replay PR event: %v", err) + } + + got := store.summary("task-pr-restart-empty") + if got == nil || got.PullRequest == nil || got.PullRequest.Count != 2 || + got.PullRequest.OpenCount != 2 || got.PullRequest.AggregateState != prStateFailure { + t.Fatalf("PR summary after empty-baseline restart = %+v, want two open PRs with failure", got) + } + if loaderCalls != 1 { + t.Fatalf("PR loader calls = %d, want 1", loaderCalls) + } +} + +func TestProjectorRehydratesSourceObservationsWithoutPersistedSummary(t *testing.T) { + store := newProjectorTestStore() + sessionLoaderCalls := 0 + gitLoaderCalls := 0 + prLoaderCalls := 0 + projector := NewProjector(ProjectorConfig{ + Store: store, + ResolveWorkspace: func(context.Context, string) (string, error) { + return "workspace-1", nil + }, + LoadSessionObservations: func(context.Context, string) (SessionObservationSnapshot, error) { + sessionLoaderCalls++ + return SessionObservationSnapshot{Sessions: []RebuildSession{{ + ID: "session-primary", State: sessionStateRunning, IsPrimary: true, + }}}, nil + }, + LoadGitObservations: func(context.Context, string) ([]GitObservation, error) { + gitLoaderCalls++ + return []GitObservation{ + {Repository: "repo-a", Summary: GitSummary{Additions: 5, ChangedFiles: 2}}, + {Repository: "repo-b", Summary: GitSummary{Additions: 2, ChangedFiles: 1}}, + }, nil + }, + LoadPullRequests: func(context.Context, string) ([]PullRequestInput, error) { + prLoaderCalls++ + return []PullRequestInput{ + {Key: "repo-a#41", State: prStateOpen, Number: 41, URL: "https://example.test/41"}, + {Key: "repo-b#42", State: prStateOpen, Number: 42, URL: "https://example.test/42"}, + }, nil + }, + Now: func() time.Time { return time.Date(2026, 8, 16, 1, 0, 0, 0, time.UTC) }, + }) + + err := projector.HandleEvent(context.Background(), bus.NewEvent(events.GitEvent, "test", map[string]interface{}{ + "task_id": "task-without-summary", + "workspace_id": "workspace-1", + "session_id": "session-primary", + "type": "status_update", + "status": map[string]interface{}{ + "repository_name": "repo-a", + "branch_additions": 6, + "changed_files": 2, + }, + })) + if err != nil { + t.Fatalf("project first source event: %v", err) + } + + got := store.summary("task-without-summary") + if got == nil || got.PrimarySession == nil || got.PrimarySession.ID != "session-primary" || + got.Git == nil || got.Git.Additions != 8 || got.Git.ChangedFiles != 3 || + got.PullRequest == nil || got.PullRequest.Count != 2 { + t.Fatalf("summary after missing-row hydration = %+v, want all keyed source siblings", got) + } + if sessionLoaderCalls != 1 || gitLoaderCalls != 1 || prLoaderCalls != 1 { + t.Fatalf("loader calls = session:%d git:%d pr:%d, want one each", + sessionLoaderCalls, gitLoaderCalls, prLoaderCalls) + } +} diff --git a/apps/backend/internal/task/statussummary/projector_test.go b/apps/backend/internal/task/statussummary/projector_test.go index 5361fea473..ff40a4c87d 100644 --- a/apps/backend/internal/task/statussummary/projector_test.go +++ b/apps/backend/internal/task/statussummary/projector_test.go @@ -605,6 +605,72 @@ func TestProjectorRetainsStoredDomainsUntilTheirFirstObservation(t *testing.T) { } } +func TestProjectorStaleDismissedClearsRestoredPendingAction(t *testing.T) { + store := newProjectorTestStore() + storedAt := time.Date(2026, 8, 1, 17, 59, 0, 0, time.UTC) + store.rows["task-stale-dismiss-restart"] = &StoredTaskStatusSummary{ + TaskID: "task-stale-dismiss-restart", + WorkspaceID: "workspace-1", + Summary: TaskStatusSummary{ + Revision: 8, + UpdatedAt: storedAt, + PendingAction: pendingClarification, + }, + } + projector := NewProjector(ProjectorConfig{ + Store: store, + Now: func() time.Time { return storedAt.Add(time.Minute) }, + }) + + err := projector.HandleEvent(context.Background(), bus.NewEvent( + events.ClarificationStaleDismissed, + "test", + map[string]interface{}{ + "task_id": "task-stale-dismiss-restart", + "workspace_id": "workspace-1", + "session_id": "session-stale-dismiss-restart", + }, + )) + if err != nil { + t.Fatalf("project stale dismissal after restart: %v", err) + } + + got := store.summary("task-stale-dismiss-restart") + if got == nil || got.PendingAction != "" || got.Revision != 9 { + t.Fatalf("summary after stale dismissal = %+v, want empty pending action at revision 9", got) + } +} + +func TestProjectorStaleDismissedKeepsDifferentPendingClarification(t *testing.T) { + _, store, eventBus, _, _ := newProjectorTest(t) + const taskID = "task-stale-dismiss-current" + const sessionID = "session-stale-dismiss-current" + + publishSessionState(t, eventBus, taskID, sessionID, nil) + publishProjectorEvent(t, eventBus, events.MessageAdded, events.MessageAdded, map[string]interface{}{ + "task_id": taskID, + "workspace_id": "workspace-1", + "session_id": sessionID, + "type": messageTypeClarificationRequest, + "requests_input": true, + "metadata": map[string]interface{}{ + "status": statusPending, + "pending_id": "pending-current", + }, + }) + publishProjectorEvent(t, eventBus, events.ClarificationStaleDismissed, events.ClarificationStaleDismissed, map[string]interface{}{ + "task_id": taskID, + "workspace_id": "workspace-1", + "session_id": sessionID, + "pending_id": "pending-older", + }) + + got := store.summary(taskID) + if got == nil || got.PendingAction != pendingClarification { + t.Fatalf("pending action after different stale dismissal = %+v, want clarification", got) + } +} + func TestProjectorKeepsPRsWithTheSameRepositoryDistinct(t *testing.T) { _, store, eventBus, _, _ := newProjectorTest(t) const taskID = "task-pr-identity" diff --git a/apps/backend/internal/task/statussummary/rebuild.go b/apps/backend/internal/task/statussummary/rebuild.go index 0d507b1a68..291056ccbe 100644 --- a/apps/backend/internal/task/statussummary/rebuild.go +++ b/apps/backend/internal/task/statussummary/rebuild.go @@ -1,7 +1,6 @@ package statussummary import ( - "strconv" "strings" "time" ) @@ -111,31 +110,7 @@ func BuildFromAuthoritative(input RebuildInput) TaskStatusSummary { } state.git[repository] = git.Summary } - for index, pullRequest := range input.PullRequests { - key := strings.TrimSpace(pullRequest.Key) - if key == "" { - key = strings.TrimSpace(pullRequest.URL) - } - if key == "" && pullRequest.Number > 0 { - key = strconv.Itoa(pullRequest.Number) - } - if key == "" { - key = strconv.Itoa(index) - } - state.prs[key] = pullRequestObservation{ - state: pullRequest.State, - number: maxInt(pullRequest.Number, 0), - url: pullRequest.URL, - reviewState: pullRequest.ReviewState, - checksState: pullRequest.ChecksState, - mergeableState: pullRequest.MergeableState, - unresolvedReviewCount: maxInt(pullRequest.UnresolvedReviewCount, 0), - pendingReviewCount: maxInt(pullRequest.PendingReviewCount, 0), - requiredReviews: maxInt(pullRequest.RequiredReviews, 0), - checksTotal: maxInt(pullRequest.ChecksTotal, 0), - checksPassing: maxInt(pullRequest.ChecksPassing, 0), - } - } + applyPullRequestInputs(state, input.PullRequests) state.queuedCount = maxInt(input.QueuedPromptCount, 0) return deriveSummary(state) } diff --git a/apps/web/components/task/chat/messages/clarification-request-message.tsx b/apps/web/components/task/chat/messages/clarification-request-message.tsx index 39b2091561..3fea145682 100644 --- a/apps/web/components/task/chat/messages/clarification-request-message.tsx +++ b/apps/web/components/task/chat/messages/clarification-request-message.tsx @@ -11,8 +11,8 @@ type ClarificationRequestMessageProps = { }; /** - * Displays a resolved clarification request in the chat history. - * Pending clarifications are shown in the input area instead. + * Displays a resolved or superseded clarification request in the chat history. + * The active pending clarification is shown in the input area instead. */ export function ClarificationRequestMessage({ comment }: ClarificationRequestMessageProps) { const { t } = useTranslation(); diff --git a/apps/web/components/task/chat/use-chat-panel-state.ts b/apps/web/components/task/chat/use-chat-panel-state.ts index 245784e79c..372e68b2ce 100644 --- a/apps/web/components/task/chat/use-chat-panel-state.ts +++ b/apps/web/components/task/chat/use-chat-panel-state.ts @@ -38,6 +38,7 @@ import type { import type { ActiveDocument } from "@/lib/state/slices/ui/types"; import type { BuiltInPreset } from "@/lib/state/layout-manager/presets"; import { readLastAgentError } from "@/lib/session-last-agent-error"; +import { clarificationTurnIdForSession } from "@/lib/utils/pending-clarification"; const EMPTY_CONTEXT_FILES: ContextFile[] = []; const PLAN_CONTEXT_PATH = "plan:context"; @@ -433,10 +434,19 @@ function useSessionData( isInitialMessagesLoading, hasMore: hasOlderMessages, } = useSessionMessages(resolvedSessionId); + const turns = useAppStore((state) => + resolvedSessionId ? state.turns.bySession[resolvedSessionId] : undefined, + ); + const currentTurnId = useMemo( + () => clarificationTurnIdForSession(session?.state, turns), + [session?.state, turns], + ); const lastAgentError = useMemo(() => readLastAgentError(session?.metadata), [session?.metadata]); const processed = useProcessedMessages(messages, taskId, resolvedSessionId, taskDescription, { hasOlderMessages, lastAgentError, + currentTurnId, + pendingAction: session?.pending_action, }); const { sessionModel, activeModel } = useSessionModel( resolvedSessionId, diff --git a/apps/web/components/task/mobile/mobile-sessions-section.test.tsx b/apps/web/components/task/mobile/mobile-sessions-section.test.tsx index a1d46e8a37..cd3020fdb7 100644 --- a/apps/web/components/task/mobile/mobile-sessions-section.test.tsx +++ b/apps/web/components/task/mobile/mobile-sessions-section.test.tsx @@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({ agentProfiles: [] as AgentProfileOption[], repositoriesByWorkspaceId: {} as Record, messagesBySession: {} as Record, + turnsBySession: {} as Record, setActiveSession: vi.fn(), })); @@ -26,6 +27,7 @@ vi.mock("@/components/state-provider", () => ({ kanban: { tasks: [{ id: "task-1", primarySessionId: "session-a" }] }, repositories: { itemsByWorkspaceId: mocks.repositoriesByWorkspaceId }, messages: { bySession: mocks.messagesBySession }, + turns: { bySession: mocks.turnsBySession }, executors: { items: [] }, setActiveSession: mocks.setActiveSession, }), @@ -121,6 +123,7 @@ beforeEach(() => { ]; mocks.repositoriesByWorkspaceId = {}; mocks.messagesBySession = {}; + mocks.turnsBySession = {}; mocks.setActiveSession.mockReset(); }); diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.test.ts b/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.test.ts index 511215d498..ce0f509f08 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.test.ts +++ b/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.test.ts @@ -1,11 +1,24 @@ -import { describe, expect, it } from "vitest"; -import { toSheetItem } from "./session-task-switcher-sheet-hooks"; +import { describe, expect, it, vi } from "vitest"; +import { toSheetItem } from "./session-task-switcher-sheet-item"; +import { + createTaskSheetSelectionController, + handleTaskSheetOpenChange, + selectPendingTaskFromSheet, + selectTaskFromSheet, +} from "./session-task-switcher-sheet-selection"; +import type { TaskPendingAction, TaskSession } from "@/lib/types/http"; type SheetTask = Parameters[0]; type SheetCtx = Parameters[1]; const UPDATED_AT = "2026-07-22T00:00:00Z"; const ERROR_PREVIEW = "Agent failed"; +async function flushSelection(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + function emptyCtx(): SheetCtx { return { repositoryPathsById: new Map(), @@ -167,3 +180,403 @@ describe("toSheetItem queued prompt count", () => { expect(item.queuedCount).toBeUndefined(); }); }); + +describe("selectPendingTaskFromSheet", () => { + it("waits for the owner session before navigating and closing", async () => { + const order: string[] = []; + const loadTaskSessionsForTask = vi.fn( + async () => + [ + { + id: "secondary", + task_id: "task-1", + state: "WAITING_FOR_INPUT", + pending_action: "clarification", + started_at: UPDATED_AT, + updated_at: UPDATED_AT, + }, + { + id: "primary", + task_id: "task-1", + state: "WAITING_FOR_INPUT", + is_primary: true, + started_at: UPDATED_AT, + updated_at: UPDATED_AT, + }, + ] as TaskSession[], + ); + const setActiveSession = vi.fn((_taskId: string, sessionId: string) => { + order.push(`session:${sessionId}`); + }); + await selectPendingTaskFromSheet({ + taskId: "task-1", + preferredSessionId: "primary", + taskPendingAction: "clarification", + loadTaskSessionsForTask, + setActiveSession, + setActiveTask: vi.fn(), + navigate: () => order.push("navigate"), + onOpenChange: () => order.push("close"), + }); + + expect(setActiveSession).toHaveBeenCalledWith("task-1", "secondary"); + expect(loadTaskSessionsForTask).toHaveBeenCalledWith("task-1", { force: true }); + expect(order).toEqual(["session:secondary", "navigate", "close"]); + }); + + it("falls back safely when session loading fails", async () => { + const order: string[] = []; + await selectPendingTaskFromSheet({ + taskId: "task-1", + preferredSessionId: "primary", + taskPendingAction: "permission", + loadTaskSessionsForTask: vi.fn(async () => { + throw new Error("offline"); + }), + setActiveSession: (_taskId, sessionId) => order.push(`session:${sessionId}`), + setActiveTask: () => order.push("task"), + navigate: () => order.push("navigate"), + onOpenChange: () => order.push("close"), + }); + expect(order).toEqual(["task", "navigate", "close"]); + }); + + it("opens the task when the same action has a newer summary revision", async () => { + const taskId = "task-revision"; + const setActiveSession = vi.fn(); + const setActiveTask = vi.fn(); + const navigate = vi.fn(); + const onOpenChange = vi.fn(); + await selectPendingTaskFromSheet({ + taskId, + preferredSessionId: "primary", + taskPendingAction: "clarification", + pendingSnapshot: { revision: 1, pendingAction: "clarification" }, + getTaskPendingSnapshot: () => ({ revision: 2, pendingAction: "clarification" }), + loadTaskSessionsForTask: vi.fn(async () => [ + { + id: "owner", + task_id: taskId, + state: "WAITING_FOR_INPUT", + pending_action: "clarification", + } as TaskSession, + ]), + setActiveSession, + setActiveTask, + navigate, + onOpenChange, + }); + + expect(setActiveSession).not.toHaveBeenCalled(); + expect(setActiveTask).toHaveBeenCalledWith(taskId); + expect(navigate).toHaveBeenCalledWith(taskId); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); +}); + +describe("selectPendingTaskFromSheet aborts", () => { + it("leaves selection inert when a forced session load is superseded", async () => { + const order: string[] = []; + const abortError = new Error("superseded"); + abortError.name = "AbortError"; + + await selectPendingTaskFromSheet({ + taskId: "task-1", + preferredSessionId: "primary", + taskPendingAction: "permission", + loadTaskSessionsForTask: vi.fn(async () => { + throw abortError; + }), + setActiveSession: (_taskId, sessionId) => order.push(`session:${sessionId}`), + setActiveTask: () => order.push("task"), + navigate: () => order.push("navigate"), + onOpenChange: () => order.push("close"), + }); + + expect(order).toEqual([]); + }); +}); + +describe("selectTaskFromSheet races", () => { + it("keeps simultaneous sheet selection controllers independent", () => { + const first = createTaskSheetSelectionController(); + const second = createTaskSheetSelectionController(); + const firstToken = first.beginSelection(); + const secondToken = second.beginSelection(); + + second.invalidate(); + + expect(first.isCurrent(firstToken)).toBe(true); + expect(second.isCurrent(secondToken)).toBe(false); + }); + + it("ignores an older pending selection that resolves after a newer tap", async () => { + let resolveTaskA: (sessions: TaskSession[]) => void = () => undefined; + let resolveTaskB: (sessions: TaskSession[]) => void = () => undefined; + const loadTaskSessionsForTask = vi.fn( + (taskId: string) => + new Promise((resolve) => { + if (taskId === "task-a") resolveTaskA = resolve; + else resolveTaskB = resolve; + }), + ); + const setActiveSession = vi.fn(); + const navigate = vi.fn(); + const onOpenChange = vi.fn(); + const shared = { + selectionController: createTaskSheetSelectionController(), + state: { + lastSessionByTaskId: {}, + environmentIdBySessionId: {}, + taskSessionsById: {}, + }, + loadTaskSessionsForTask, + setActiveSession, + setActiveTask: vi.fn(), + navigate, + onOpenChange, + }; + + selectTaskFromSheet({ + ...shared, + taskId: "task-a", + task: { primarySessionId: "primary-a", taskPendingAction: "clarification" }, + }); + selectTaskFromSheet({ + ...shared, + taskId: "task-b", + task: { primarySessionId: "primary-b", taskPendingAction: "clarification" }, + }); + + resolveTaskB([ + { + id: "owner-b", + task_id: "task-b", + state: "WAITING_FOR_INPUT", + pending_action: "clarification", + started_at: UPDATED_AT, + updated_at: UPDATED_AT, + } as TaskSession, + ]); + await flushSelection(); + resolveTaskA([ + { + id: "owner-a", + task_id: "task-a", + state: "WAITING_FOR_INPUT", + pending_action: "clarification", + started_at: UPDATED_AT, + updated_at: UPDATED_AT, + } as TaskSession, + ]); + await flushSelection(); + + expect(setActiveSession).toHaveBeenCalledTimes(1); + expect(setActiveSession).toHaveBeenCalledWith("task-b", "owner-b"); + expect(navigate).toHaveBeenCalledTimes(1); + expect(navigate).toHaveBeenCalledWith("task-b"); + expect(onOpenChange).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); +}); + +describe("selectTaskFromSheet aborted loads", () => { + it("leaves a sessionless selection inert when its session load is aborted", async () => { + const abortError = new Error("task removed"); + abortError.name = "AbortError"; + const setActiveSession = vi.fn(); + const setActiveTask = vi.fn(); + const navigate = vi.fn(); + const onOpenChange = vi.fn(); + + selectTaskFromSheet({ + selectionController: createTaskSheetSelectionController(), + taskId: "task-aborted", + task: { primarySessionId: null }, + state: { + lastSessionByTaskId: {}, + environmentIdBySessionId: {}, + taskSessionsById: {}, + }, + loadTaskSessionsForTask: vi.fn(async () => { + throw abortError; + }), + setActiveSession, + setActiveTask, + navigate, + onOpenChange, + }); + await flushSelection(); + + expect(setActiveSession).not.toHaveBeenCalled(); + expect(setActiveTask).not.toHaveBeenCalled(); + expect(navigate).not.toHaveBeenCalled(); + expect(onOpenChange).not.toHaveBeenCalled(); + }); +}); + +describe("selectTaskFromSheet summary races", () => { + it("opens the task and closes the sheet when the pending action changes", async () => { + const taskId = "task-summary-race"; + let resolveLoad: (sessions: TaskSession[]) => void = () => undefined; + const loadTaskSessionsForTask = vi.fn( + () => + new Promise((resolve) => { + resolveLoad = resolve; + }), + ); + let currentSnapshot: { + revision: number; + pendingAction: TaskPendingAction; + } = { revision: 1, pendingAction: "clarification" }; + const setActiveSession = vi.fn(); + const setActiveTask = vi.fn(); + const navigate = vi.fn(); + const onOpenChange = vi.fn(); + + selectTaskFromSheet({ + selectionController: createTaskSheetSelectionController(), + taskId, + task: { + primarySessionId: "primary", + statusSummary: { + revision: 1, + updated_at: UPDATED_AT, + pending_action: "clarification", + }, + }, + state: { + lastSessionByTaskId: {}, + environmentIdBySessionId: {}, + taskSessionsById: {}, + }, + loadTaskSessionsForTask, + setActiveSession, + setActiveTask, + navigate, + onOpenChange, + getTaskPendingSnapshot: () => currentSnapshot, + }); + currentSnapshot = { revision: 2, pendingAction: "permission" }; + resolveLoad([ + { + id: "old-owner", + task_id: taskId, + state: "WAITING_FOR_INPUT", + pending_action: "clarification", + } as TaskSession, + ]); + await flushSelection(); + + expect(loadTaskSessionsForTask).toHaveBeenCalledWith(taskId, { force: true }); + expect(setActiveSession).not.toHaveBeenCalled(); + expect(setActiveTask).toHaveBeenCalledWith(taskId); + expect(navigate).toHaveBeenCalledWith(taskId); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); +}); + +describe("selectTaskFromSheet deleted-task race", () => { + it("leaves the sheet open when the pending task projection disappears", async () => { + const taskId = "task-deleted-race"; + let resolveLoad: (sessions: TaskSession[]) => void = () => undefined; + const loadTaskSessionsForTask = vi.fn( + () => + new Promise((resolve) => { + resolveLoad = resolve; + }), + ); + const setActiveSession = vi.fn(); + const setActiveTask = vi.fn(); + const navigate = vi.fn(); + const onOpenChange = vi.fn(); + + selectTaskFromSheet({ + selectionController: createTaskSheetSelectionController(), + taskId, + task: { + primarySessionId: "primary", + statusSummary: { + revision: 1, + updated_at: UPDATED_AT, + pending_action: "clarification", + }, + }, + state: { + lastSessionByTaskId: {}, + environmentIdBySessionId: {}, + taskSessionsById: {}, + }, + loadTaskSessionsForTask, + setActiveSession, + setActiveTask, + navigate, + onOpenChange, + getTaskPendingSnapshot: () => undefined, + }); + resolveLoad([ + { + id: "deleted-owner", + task_id: taskId, + state: "WAITING_FOR_INPUT", + pending_action: "clarification", + } as TaskSession, + ]); + await flushSelection(); + + expect(setActiveSession).not.toHaveBeenCalled(); + expect(setActiveTask).not.toHaveBeenCalled(); + expect(navigate).not.toHaveBeenCalled(); + expect(onOpenChange).not.toHaveBeenCalled(); + }); +}); + +describe("selectTaskFromSheet sheet lifecycle", () => { + it("invalidates a pending selection when the sheet closes and reopens", async () => { + let resolveLoad: (sessions: TaskSession[]) => void = () => undefined; + const loadTaskSessionsForTask = vi.fn( + () => + new Promise((resolve) => { + resolveLoad = resolve; + }), + ); + const setActiveSession = vi.fn(); + const navigate = vi.fn(); + const onOpenChange = vi.fn(); + const selectionController = createTaskSheetSelectionController(); + + selectTaskFromSheet({ + selectionController, + taskId: "task-stale", + task: { primarySessionId: "primary", taskPendingAction: "clarification" }, + state: { + lastSessionByTaskId: {}, + environmentIdBySessionId: {}, + taskSessionsById: {}, + }, + loadTaskSessionsForTask, + setActiveSession, + setActiveTask: vi.fn(), + navigate, + onOpenChange, + }); + handleTaskSheetOpenChange(selectionController, false, onOpenChange); + handleTaskSheetOpenChange(selectionController, true, onOpenChange); + + resolveLoad([ + { + id: "owner-stale", + task_id: "task-stale", + state: "WAITING_FOR_INPUT", + pending_action: "clarification", + started_at: UPDATED_AT, + updated_at: UPDATED_AT, + } as TaskSession, + ]); + await flushSelection(); + + expect(setActiveSession).not.toHaveBeenCalled(); + expect(navigate).not.toHaveBeenCalled(); + expect(onOpenChange.mock.calls).toEqual([[false], [true]]); + }); +}); diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts b/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts index c680c7d18d..f5d7bc935f 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts +++ b/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts @@ -4,89 +4,24 @@ import { useCallback, useMemo, useState } from "react"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { replaceTaskUrl } from "@/lib/links"; import { fetchWorkflowSnapshot, listWorkflows } from "@/lib/api"; -import { launchSession } from "@/lib/services/session-launch-service"; -import { buildPrepareRequest } from "@/lib/services/session-launch-helpers"; import { useWorkspaceSidebarTasks } from "@/hooks/domains/kanban/use-workspace-sidebar-tasks"; import { useTaskActions, useArchiveAndSwitchTask } from "@/hooks/use-task-actions"; import { useTaskDetachDialog } from "@/hooks/use-detach-task"; import { useNestTaskByDrag } from "@/hooks/use-nest-task"; import { useTaskRemoval } from "@/hooks/use-task-removal"; import { workspaceModeFromMetadata } from "@/lib/kanban/map-task"; -import { - repositoryId as toRepositoryId, - type TaskState, - type TaskSessionState, - type Repository, - type Task, -} from "@/lib/types/http"; +import { type Repository, type Task } from "@/lib/types/http"; import type { KanbanState } from "@/lib/state/slices"; import { findTaskInSnapshots } from "@/lib/kanban/find-task"; import { repositorySlug } from "@/lib/repository-slug"; -import { statusSummaryActiveErrorPreview } from "@/lib/task-status-summary"; -import { resolvePreferredSessionId } from "../task-select-helpers"; import { mapSnapshotToKanban, sortByUpdatedAtDesc } from "./session-task-switcher-sheet-helpers"; +import { toSheetItem, type SheetItemCtx } from "./session-task-switcher-sheet-item"; +import { + selectTaskFromSheet, + type TaskSheetSelectionController, +} from "./session-task-switcher-sheet-selection"; +import { taskPendingSelectionSnapshot } from "../task-select-helpers"; import { useTranslation } from "react-i18next"; -import type { WipQueueStatus } from "@/lib/kanban/wip-queue"; - -type SheetItemCtx = { - repositoryPathsById: Map; - workflowNameById: Map; - stepTitleById: Map; - wipQueueByTaskId?: Map; - acknowledgedAgentErrors?: Record; - dismissedAgentErrors?: Record; -}; - -function sheetDiffStats(summary: KanbanState["tasks"][number]["statusSummary"]) { - const git = summary?.git; - if (!git || ((git.additions ?? 0) <= 0 && (git.deletions ?? 0) <= 0)) return undefined; - return { additions: git.additions ?? 0, deletions: git.deletions ?? 0 }; -} - -function sheetRepositoryPath( - task: KanbanState["tasks"][number], - ctx: SheetItemCtx, -): string | undefined { - return task.repositoryId - ? ctx.repositoryPathsById.get(toRepositoryId(task.repositoryId)) - : undefined; -} - -function sheetPendingFlags( - summary: KanbanState["tasks"][number]["statusSummary"], - fallback?: string | null, -) { - const action = summary != null ? summary.pending_action : fallback; - return { - clarification: action === "clarification", - permission: action === "permission", - }; -} - -function sheetStatus(task: KanbanState["tasks"][number], ctx: SheetItemCtx) { - const summary = task.statusSummary; - const hasSummary = summary != null; - const pending = sheetPendingFlags(hasSummary ? summary : undefined, task.taskPendingAction); - return { - sessionState: hasSummary - ? summary?.primary_session?.state - : (task.primarySessionState as TaskSessionState | undefined), - foregroundActivity: hasSummary ? summary?.foreground_activity : task.foregroundActivity, - repositoryPath: sheetRepositoryPath(task, ctx), - diffStats: sheetDiffStats(summary), - updatedAt: hasSummary ? summary?.updated_at : task.updatedAt, - primarySessionId: hasSummary - ? (summary?.primary_session?.id ?? null) - : (task.primarySessionId ?? null), - hasPendingClarification: pending.clarification, - hasPendingPermission: pending.permission, - agentErrorMessage: statusSummaryActiveErrorPreview( - summary, - ctx.acknowledgedAgentErrors, - ctx.dismissedAgentErrors, - ), - }; -} function findSheetTask( state: ReturnType["getState"]>, @@ -101,40 +36,6 @@ function findSheetTask( return undefined; } -export function toSheetItem( - task: KanbanState["tasks"][number] & { _workflowId: string }, - ctx: SheetItemCtx, -) { - const status = sheetStatus(task, ctx); - return { - id: task.id, - title: task.title, - autopilot: task.autopilot, - // Carry the parent link so the mobile task switcher nests subtasks the same - // way the desktop sidebar does (applyView/TaskSwitcher read parentTaskId). - parentTaskId: task.parentTaskId ?? undefined, - workspaceMode: task.workspaceMode, - state: task.state as TaskState | undefined, - ...status, - // Same interruption marker the desktop sidebar reads — the mobile - // task-switcher row shares TaskItem rendering. - interrupted: task.interrupted, - description: task.description, - workflowId: task._workflowId, - workflowName: ctx.workflowNameById.get(task._workflowId), - workflowStepId: task.workflowStepId, - workflowStepTitle: ctx.stepTitleById.get(task.workflowStepId), - isArchived: task.isArchived === true, - isRemoteExecutor: task.isRemoteExecutor, - remoteExecutorType: task.primaryExecutorType ?? undefined, - remoteExecutorName: task.primaryExecutorName ?? undefined, - // Queued prompt count badge — same status summary source as the desktop - // sidebar mapper (buildSidebarItem) so both surfaces agree. - queuedCount: task.statusSummary?.queued_prompt_count, - wipQueue: ctx.wipQueueByTaskId?.get(task.id), - }; -} - export function useSheetData(workspaceId: string | null) { const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); const { @@ -210,6 +111,7 @@ type SheetNavOptions = { store: ReturnType; loadTaskSessionsForTask: ( taskId: string, + options?: { force?: boolean }, ) => Promise>; setActiveSession: (taskId: string, sessionId: string) => void; setActiveTask: (taskId: string) => void; @@ -433,47 +335,6 @@ function useWorkspaceAndTaskCreatedActions(opts: SheetNavOptions) { return { handleWorkspaceChange, handleTaskCreated }; } -type SelectTaskOptions = { - setActiveTask: (taskId: string) => void; - setActiveSession: (taskId: string, sessionId: string) => void; - loadTaskSessionsForTask: SheetNavOptions["loadTaskSessionsForTask"]; - onOpenChange: (open: boolean) => void; -}; - -async function selectTaskWithoutPrimarySession(taskId: string, opts: SelectTaskOptions) { - const { setActiveTask, setActiveSession, loadTaskSessionsForTask, onOpenChange } = opts; - try { - const sessions = await loadTaskSessionsForTask(taskId); - const sessionId = sessions[0]?.id ?? null; - if (sessionId) { - setActiveSession(taskId, sessionId); - replaceTaskUrl(taskId); - onOpenChange(false); - return; - } - // No session — prepare workspace. - const { request } = buildPrepareRequest(taskId); - try { - const resp = await launchSession(request); - if (resp.session_id) { - setActiveSession(taskId, resp.session_id); - replaceTaskUrl(taskId); - onOpenChange(false); - return; - } - } catch { - // Fall through to default navigation. - } - } catch (error) { - // Loading sessions can reject (network / 5xx). Don't strand the user; - // fall back to plain task navigation so URL + state still align with tap. - console.error("Failed to load sessions for task:", error); - } - setActiveTask(taskId); - replaceTaskUrl(taskId); - onOpenChange(false); -} - function useSheetDeleteActions( store: ReturnType, removeTaskFromBoard: ReturnType["removeTaskFromBoard"], @@ -542,7 +403,11 @@ function useSheetNestTask() { return useNestTaskByDrag(); } -export function useSheetActions(workspaceId: string | null, onOpenChange: (open: boolean) => void) { +export function useSheetActions( + workspaceId: string | null, + onOpenChange: (open: boolean) => void, + selection: TaskSheetSelectionController, +) { const { t } = useTranslation(); const setActiveTask = useAppStore((state) => state.setActiveTask); const setActiveSession = useAppStore((state) => state.setActiveSession); @@ -552,39 +417,30 @@ export function useSheetActions(workspaceId: string | null, onOpenChange: (open: const deleteActions = useSheetDeleteActions(store, removeTaskFromBoard); const detachActions = useTaskDetachDialog(store); const handleNestTask = useSheetNestTask(); - const handleSelectTask = useCallback( (taskId: string) => { const state = store.getState(); - const task = findSheetTask(state, taskId); - if (task?.isArchived) { - setActiveTask(taskId); - replaceTaskUrl(taskId); - onOpenChange(false); - return; - } - if (task?.primarySessionId) { - const targetSessionId = resolvePreferredSessionId({ - taskId, - primarySessionId: task.primarySessionId, + selectTaskFromSheet({ + taskId, + selectionController: selection, + task: findSheetTask(state, taskId), + state: { lastSessionByTaskId: state.tasks.lastSessionByTaskId, environmentIdBySessionId: state.environmentIdBySessionId, taskSessionsById: state.taskSessions.items, - }); - setActiveSession(taskId, targetSessionId); - loadTaskSessionsForTask(taskId); - replaceTaskUrl(taskId); - onOpenChange(false); - return; - } - void selectTaskWithoutPrimarySession(taskId, { + }, setActiveTask, setActiveSession, loadTaskSessionsForTask, + getTaskPendingSnapshot: (selectedTaskId) => { + const selectedTask = findSheetTask(store.getState(), selectedTaskId); + return selectedTask ? taskPendingSelectionSnapshot(selectedTask) : undefined; + }, + navigate: replaceTaskUrl, onOpenChange, }); }, - [loadTaskSessionsForTask, setActiveSession, setActiveTask, store, onOpenChange], + [loadTaskSessionsForTask, setActiveSession, setActiveTask, store, onOpenChange, selection], ); const [archivingTask, setArchivingTask] = useState<{ diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet-item.ts b/apps/web/components/task/mobile/session-task-switcher-sheet-item.ts new file mode 100644 index 0000000000..dc09387286 --- /dev/null +++ b/apps/web/components/task/mobile/session-task-switcher-sheet-item.ts @@ -0,0 +1,94 @@ +import { + repositoryId as toRepositoryId, + type TaskSessionState, + type TaskState, +} from "@/lib/types/http"; +import type { KanbanState } from "@/lib/state/slices"; +import { statusSummaryActiveErrorPreview } from "@/lib/task-status-summary"; +import type { WipQueueStatus } from "@/lib/kanban/wip-queue"; +import { effectiveTaskPendingAction } from "../task-select-helpers"; + +export type SheetItemCtx = { + repositoryPathsById: Map; + workflowNameById: Map; + stepTitleById: Map; + wipQueueByTaskId?: Map; + acknowledgedAgentErrors?: Record; + dismissedAgentErrors?: Record; +}; + +function sheetDiffStats(summary: KanbanState["tasks"][number]["statusSummary"]) { + const git = summary?.git; + if (!git || ((git.additions ?? 0) <= 0 && (git.deletions ?? 0) <= 0)) return undefined; + return { additions: git.additions ?? 0, deletions: git.deletions ?? 0 }; +} + +function sheetRepositoryPath( + task: KanbanState["tasks"][number], + ctx: SheetItemCtx, +): string | undefined { + return task.repositoryId + ? ctx.repositoryPathsById.get(toRepositoryId(task.repositoryId)) + : undefined; +} + +function sheetPendingFlags(task: KanbanState["tasks"][number]) { + const action = effectiveTaskPendingAction(task); + return { + clarification: action === "clarification", + permission: action === "permission", + }; +} + +function sheetStatus(task: KanbanState["tasks"][number], ctx: SheetItemCtx) { + const summary = task.statusSummary; + const hasSummary = summary != null; + const pending = sheetPendingFlags(task); + return { + sessionState: hasSummary + ? summary?.primary_session?.state + : (task.primarySessionState as TaskSessionState | undefined), + foregroundActivity: hasSummary ? summary?.foreground_activity : task.foregroundActivity, + repositoryPath: sheetRepositoryPath(task, ctx), + diffStats: sheetDiffStats(summary), + updatedAt: hasSummary ? summary?.updated_at : task.updatedAt, + primarySessionId: hasSummary + ? (summary?.primary_session?.id ?? null) + : (task.primarySessionId ?? null), + hasPendingClarification: pending.clarification, + hasPendingPermission: pending.permission, + agentErrorMessage: statusSummaryActiveErrorPreview( + summary, + ctx.acknowledgedAgentErrors, + ctx.dismissedAgentErrors, + ), + }; +} + +export function toSheetItem( + task: KanbanState["tasks"][number] & { _workflowId: string }, + ctx: SheetItemCtx, +) { + const status = sheetStatus(task, ctx); + return { + id: task.id, + title: task.title, + autopilot: task.autopilot, + parentTaskId: task.parentTaskId ?? undefined, + workspaceMode: task.workspaceMode, + state: task.state as TaskState | undefined, + ...status, + interrupted: task.interrupted, + description: task.description, + workflowId: task._workflowId, + workflowName: ctx.workflowNameById.get(task._workflowId), + workflowStepId: task.workflowStepId, + workflowStepTitle: ctx.stepTitleById.get(task.workflowStepId), + isArchived: task.isArchived === true, + isRemoteExecutor: task.isRemoteExecutor, + remoteExecutorType: task.primaryExecutorType ?? undefined, + remoteExecutorName: task.primaryExecutorName ?? undefined, + queuedCount: task.statusSummary?.queued_prompt_count, + wipQueue: ctx.wipQueueByTaskId?.get(task.id), + }; +} diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet-selection.ts b/apps/web/components/task/mobile/session-task-switcher-sheet-selection.ts new file mode 100644 index 0000000000..6d0ecc8ebf --- /dev/null +++ b/apps/web/components/task/mobile/session-task-switcher-sheet-selection.ts @@ -0,0 +1,218 @@ +import { replaceTaskUrl } from "@/lib/links"; +import { launchSession } from "@/lib/services/session-launch-service"; +import { buildPrepareRequest } from "@/lib/services/session-launch-helpers"; +import type { TaskPendingAction, TaskSession } from "@/lib/types/http"; +import { isAbortError } from "@/lib/utils/abort-error"; +import { + effectiveTaskPendingAction, + resolvePreferredSessionId, + resolveTaskSessionId, + taskPendingSelectionMatches, + taskPendingSelectionSnapshot, + type TaskPendingSelectionSnapshot, +} from "../task-select-helpers"; + +type SelectionActions = { + loadTaskSessionsForTask: ( + taskId: string, + options?: { force?: boolean }, + ) => Promise; + setActiveSession: (taskId: string, sessionId: string) => void; + setActiveTask: (taskId: string) => void; + navigate?: (taskId: string) => void; + onOpenChange: (open: boolean) => void; + isSelectionCurrent?: () => boolean; + getTaskPendingSnapshot?: (taskId: string) => TaskPendingSelectionSnapshot | undefined; +}; + +type SelectableTask = { + isArchived?: boolean; + primarySessionId?: string | null; + taskPendingAction?: TaskPendingAction | null; + statusSummary?: { + revision?: number; + updated_at?: string; + pending_action?: TaskPendingAction | null; + } | null; +}; + +type SelectionState = { + lastSessionByTaskId: Record; + environmentIdBySessionId: Record; + taskSessionsById: Record; +}; + +export type TaskSheetSelectionController = { + beginSelection: () => number; + invalidate: () => void; + isCurrent: (token: number) => boolean; +}; + +export function createTaskSheetSelectionController(): TaskSheetSelectionController { + let sequence = 0; + return { + beginSelection: () => { + sequence += 1; + return sequence; + }, + invalidate: () => { + sequence += 1; + }, + isCurrent: (token) => token === sequence, + }; +} + +export function handleTaskSheetOpenChange( + selectionController: TaskSheetSelectionController, + open: boolean, + onOpenChange: (open: boolean) => void, +): void { + if (!open) selectionController.invalidate(); + onOpenChange(open); +} + +function selectionIsCurrent(actions: SelectionActions): boolean { + return actions.isSelectionCurrent?.() ?? true; +} + +function pendingActionState( + actions: SelectionActions, + taskId: string, + initial: TaskPendingSelectionSnapshot, +): "current" | "changed" | "missing" { + if (!actions.getTaskPendingSnapshot) return "current"; + const current = actions.getTaskPendingSnapshot(taskId); + if (!current) return "missing"; + return taskPendingSelectionMatches(initial, current) ? "current" : "changed"; +} + +export async function selectPendingTaskFromSheet( + params: { + taskId: string; + preferredSessionId: string; + taskPendingAction: TaskPendingAction; + pendingSnapshot?: TaskPendingSelectionSnapshot; + } & SelectionActions, +): Promise { + const navigate = params.navigate ?? replaceTaskUrl; + let targetSessionId = ""; + try { + const sessions = await params.loadTaskSessionsForTask(params.taskId, { force: true }); + targetSessionId = resolveTaskSessionId({ + sessions, + preferredSessionId: params.preferredSessionId, + taskPendingAction: params.taskPendingAction, + }); + } catch (error) { + if (isAbortError(error)) return; + console.error("Failed to load pending task sessions:", error); + } + const initialSnapshot = params.pendingSnapshot ?? { + revision: null, + pendingAction: params.taskPendingAction, + }; + if (!selectionIsCurrent(params)) return; + const pendingState = pendingActionState(params, params.taskId, initialSnapshot); + if (pendingState === "missing") { + return; + } + if (pendingState === "changed") { + params.setActiveTask(params.taskId); + navigate(params.taskId); + params.onOpenChange(false); + return; + } + if (targetSessionId) { + params.setActiveSession(params.taskId, targetSessionId); + } else { + params.setActiveTask(params.taskId); + } + navigate(params.taskId); + params.onOpenChange(false); +} + +async function selectTaskWithoutPrimarySession(taskId: string, actions: SelectionActions) { + const navigate = actions.navigate ?? replaceTaskUrl; + try { + const sessions = await actions.loadTaskSessionsForTask(taskId); + if (!selectionIsCurrent(actions)) return; + const sessionId = sessions[0]?.id ?? null; + if (sessionId) { + actions.setActiveSession(taskId, sessionId); + navigate(taskId); + actions.onOpenChange(false); + return; + } + const { request } = buildPrepareRequest(taskId); + try { + const response = await launchSession(request); + if (!selectionIsCurrent(actions)) return; + if (response.session_id) { + actions.setActiveSession(taskId, response.session_id); + navigate(taskId); + actions.onOpenChange(false); + return; + } + } catch { + // Fall through to default navigation. + } + } catch (error) { + if (isAbortError(error)) return; + console.error("Failed to load sessions for task:", error); + } + if (!selectionIsCurrent(actions)) return; + actions.setActiveTask(taskId); + navigate(taskId); + actions.onOpenChange(false); +} + +export function selectTaskFromSheet( + params: { + taskId: string; + task?: SelectableTask; + state: SelectionState; + selectionController: TaskSheetSelectionController; + } & SelectionActions, +): void { + const { taskId, task, state } = params; + const selectionToken = params.selectionController.beginSelection(); + const guardedParams = { + ...params, + isSelectionCurrent: () => params.selectionController.isCurrent(selectionToken), + }; + const navigate = params.navigate ?? replaceTaskUrl; + if (task?.isArchived) { + params.setActiveTask(taskId); + navigate(taskId); + params.onOpenChange(false); + return; + } + const pendingAction = effectiveTaskPendingAction(task); + const pendingSnapshot = taskPendingSelectionSnapshot(task); + const preferredSessionId = task?.primarySessionId + ? resolvePreferredSessionId({ + taskId, + primarySessionId: task.primarySessionId, + lastSessionByTaskId: state.lastSessionByTaskId, + environmentIdBySessionId: state.environmentIdBySessionId, + taskSessionsById: state.taskSessionsById, + }) + : ""; + if (pendingAction) { + void selectPendingTaskFromSheet({ + ...guardedParams, + preferredSessionId, + taskPendingAction: pendingAction, + pendingSnapshot, + }); + return; + } + if (preferredSessionId) { + params.setActiveSession(taskId, preferredSessionId); + void params.loadTaskSessionsForTask(taskId).catch(() => undefined); + navigate(taskId); + params.onOpenChange(false); + return; + } + void selectTaskWithoutPrimarySession(taskId, guardedParams); +} diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet.test.tsx b/apps/web/components/task/mobile/session-task-switcher-sheet.test.tsx index b41a6f045b..9793c3a0a8 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet.test.tsx +++ b/apps/web/components/task/mobile/session-task-switcher-sheet.test.tsx @@ -1,9 +1,14 @@ -import { fireEvent, render, screen, within } from "@testing-library/react"; +import { act, fireEvent, render, renderHook, screen, within } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { ToastProvider } from "@/components/toast-provider"; -import { MobileTaskList } from "@/components/task/mobile/session-task-switcher-sheet"; +import { + MobileTaskList, + useTaskSheetSelectionController, +} from "@/components/task/mobile/session-task-switcher-sheet"; +import { selectTaskFromSheet } from "@/components/task/mobile/session-task-switcher-sheet-selection"; import type { TaskSwitcherItem } from "@/components/task/task-switcher"; +import type { TaskSession } from "@/lib/types/http"; const mocks = vi.hoisted(() => ({ toggleSidebarGroupCollapsed: vi.fn(), @@ -159,3 +164,86 @@ describe("MobileTaskList", () => { expect(onEditTask).toHaveBeenCalledWith(editableTask); }); }); + +describe("SessionTaskSwitcherSheet lifecycle", () => { + it("invalidates a deferred selection when responsive remount replaces the sheet", async () => { + const phoneTaskId = "task-phone"; + const tabletTaskId = "task-tablet"; + let resolveOldLoad: (sessions: TaskSession[]) => void = () => undefined; + let resolveNewLoad: (sessions: TaskSession[]) => void = () => undefined; + const oldLoad = vi.fn( + () => + new Promise((resolve) => { + resolveOldLoad = resolve; + }), + ); + const newLoad = vi.fn( + () => + new Promise((resolve) => { + resolveNewLoad = resolve; + }), + ); + const setActiveSession = vi.fn(); + const navigate = vi.fn(); + const onOpenChange = vi.fn(); + const state = { + lastSessionByTaskId: {}, + environmentIdBySessionId: {}, + taskSessionsById: {}, + }; + const oldSheet = renderHook(() => useTaskSheetSelectionController()); + + selectTaskFromSheet({ + selectionController: oldSheet.result.current, + taskId: phoneTaskId, + task: { primarySessionId: "primary-phone", taskPendingAction: "clarification" }, + state, + loadTaskSessionsForTask: oldLoad, + setActiveSession, + setActiveTask: vi.fn(), + navigate, + onOpenChange, + }); + oldSheet.unmount(); + + const tabletSheet = renderHook(() => useTaskSheetSelectionController()); + selectTaskFromSheet({ + selectionController: tabletSheet.result.current, + taskId: tabletTaskId, + task: { primarySessionId: "primary-tablet", taskPendingAction: "clarification" }, + state, + loadTaskSessionsForTask: newLoad, + setActiveSession, + setActiveTask: vi.fn(), + navigate, + onOpenChange, + }); + await act(async () => { + resolveNewLoad([ + { + id: "owner-tablet", + task_id: tabletTaskId, + state: "WAITING_FOR_INPUT", + pending_action: "clarification", + } as TaskSession, + ]); + }); + await act(async () => { + resolveOldLoad([ + { + id: "owner-phone", + task_id: phoneTaskId, + state: "WAITING_FOR_INPUT", + pending_action: "clarification", + } as TaskSession, + ]); + }); + + expect(setActiveSession).toHaveBeenCalledTimes(1); + expect(setActiveSession).toHaveBeenCalledWith(tabletTaskId, "owner-tablet"); + expect(navigate).toHaveBeenCalledTimes(1); + expect(navigate).toHaveBeenCalledWith(tabletTaskId); + expect(onOpenChange).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx index 7a3d3a63f6..c9f58e594e 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx +++ b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useMemo, useState, memo } from "react"; +import { useCallback, useEffect, useMemo, useState, memo } from "react"; import { useTranslation } from "react-i18next"; import { IconCheck, IconMessageCircle, IconNetwork, IconPlus } from "@tabler/icons-react"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@kandev/ui/sheet"; @@ -30,6 +30,10 @@ import { } from "../task-session-sidebar-link-actions"; import { useSidebarTaskLinking } from "../task-session-sidebar-task-linking"; import { useSheetData, useSheetActions } from "./session-task-switcher-sheet-hooks"; +import { + createTaskSheetSelectionController, + handleTaskSheetOpenChange, +} from "./session-task-switcher-sheet-selection"; import { useQuickChatLauncher } from "@/hooks/use-quick-chat-launcher"; import { useMobileTaskRename } from "./use-mobile-task-rename"; import { SidebarTaskEditDialog, useSidebarTaskEdit } from "../task-session-sidebar-edit"; @@ -43,6 +47,17 @@ type SessionTaskSwitcherSheetProps = { presentation?: "sheet" | "drawer"; }; +export function useTaskSheetSelectionController() { + const [selectionController] = useState(createTaskSheetSelectionController); + useEffect( + () => () => { + selectionController.invalidate(); + }, + [selectionController], + ); + return selectionController; +} + function useMobileTaskLinking(workspaceId: string | null) { const store = useAppStoreApi(); const actions = useSidebarLinkActions(store); @@ -534,32 +549,37 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({ const [dialogOpen, setDialogOpen] = useState(false); const [subtaskTarget, setSubtaskTarget] = useState<{ id: string; title: string } | null>(null); const data = useSheetData(workspaceId); - const actions = useSheetActions(workspaceId, onOpenChange); + const selectionController = useTaskSheetSelectionController(); + const handleOpenChange = useCallback( + (nextOpen: boolean) => handleTaskSheetOpenChange(selectionController, nextOpen, onOpenChange), + [onOpenChange, selectionController], + ); + const actions = useSheetActions(workspaceId, handleOpenChange, selectionController); const rename = useMobileTaskRename(); const edit = useSidebarTaskEdit(); const linking = useMobileTaskLinking(workspaceId); const openQuickChat = useQuickChatLauncher(workspaceId); const handleQuickChat = useCallback(() => { - onOpenChange(false); + handleOpenChange(false); openQuickChat(); - }, [onOpenChange, openQuickChat]); + }, [handleOpenChange, openQuickChat]); const handleCreateSubtask = useCallback( (taskId: string, taskTitle: string) => { - onOpenChange(false); + handleOpenChange(false); setSubtaskTarget({ id: taskId, title: taskTitle }); }, - [onOpenChange], + [handleOpenChange], ); const surfaceContent = ( { - if (presentation === "drawer") onOpenChange(false); + if (presentation === "drawer") handleOpenChange(false); setDialogOpen(true); }} data={data} @@ -572,13 +592,13 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({ const surface = presentation === "drawer" ? ( - + {surfaceContent} ) : ( - + { + const sessions = [ + { + id: SECONDARY, + task_id: TASK_ID, + state: "WAITING_FOR_INPUT", + pending_action: "clarification", + updated_at: NEWER_AT, + }, + { + id: "primary", + task_id: TASK_ID, + state: "WAITING_FOR_INPUT", + is_primary: true, + updated_at: OLDER_AT, + }, + ] as TaskSession[]; + + it("selects the newest input-capable session that owns the task action", () => { + expect( + resolveTaskSessionId({ + sessions, + preferredSessionId: "primary", + taskPendingAction: "clarification", + }), + ).toBe(SECONDARY); + }); + + it("does not guess a fallback when the only matching owner is terminal", () => { + expect( + resolveTaskSessionId({ + sessions: [{ ...sessions[0], state: "COMPLETED" }, sessions[1]], + preferredSessionId: "primary", + taskPendingAction: "clarification", + }), + ).toBe(""); + }); + + it("preserves remembered and primary fallback for a clean task", () => { + expect( + resolveTaskSessionId({ + sessions, + preferredSessionId: SECONDARY, + taskPendingAction: null, + }), + ).toBe(SECONDARY); + }); +}); + const { dockviewState } = vi.hoisted(() => ({ dockviewState: { api: null as unknown, @@ -72,14 +131,6 @@ function makeEnvStore( } as unknown as StoreApi; } -const TASK_ID = "task-A"; -const PRIMARY = "sess-primary"; -const ORIGINAL_TASK_ID = "task-original"; -const PENDING_TASK_ID = "task-pending"; -const ORIGINAL_SESSION_ID = "sess-original"; -const PENDING_SESSION_ID = "sess-pending"; -const ORIGINAL_ENV_ID = "env-original"; - function makeKanbanStore(args: { activeTaskId?: string | null; activeSessionId: string | null; @@ -128,66 +179,245 @@ async function flushTaskSelection() { await new Promise((resolve) => setTimeout(resolve, 0)); } -function makeSelectionHarness(args: { - activeTaskId: string; - activeSessionId: string | null; - envIds?: Record; - sessions?: Record; -}) { - const listeners: Array<(state: AppState, previousState: AppState) => void> = []; - const state = { - tasks: { - activeTaskId: args.activeTaskId, - activeSessionId: args.activeSessionId, - lastSessionByTaskId: {}, - }, - taskPRs: { byTaskId: {} as Record }, - environmentIdBySessionId: args.envIds ?? {}, - taskSessions: { items: (args.sessions ?? {}) as Record }, - }; - const snapshot = () => - ({ - ...state, - tasks: { ...state.tasks }, - }) as unknown as AppState; - const notify = (previousState: AppState) => { - for (const listener of listeners) { - listener(state as unknown as AppState, previousState); - } - }; - const store = { - getState: () => state as unknown as AppState, - setState: vi.fn(), - subscribe: vi.fn((listener: (state: AppState, previousState: AppState) => void) => { - listeners.push(listener); - return () => { - const index = listeners.indexOf(listener); - if (index >= 0) listeners.splice(index, 1); - }; - }), - } as unknown as StoreApi; - const setActiveTask = vi.fn((taskId: string) => { - const previousState = snapshot(); - state.tasks.activeTaskId = taskId; - state.tasks.activeSessionId = null; - notify(previousState); - }); - return { state, store, setActiveTask, getListenerCount: () => listeners.length }; -} +describe("selectTaskWithLayout pending owner", () => { + it("loads sessions before switching even when the primary environment is known", async () => { + const store = makeKanbanStore({ + activeSessionId: OTHER_SESSION_ID, + envIds: { [PRIMARY]: PRIMARY_ENV_ID, [SECONDARY]: SECONDARY_ENV_ID }, + }); + const switchToSession = vi.fn(); + const loadTaskSessionsForTask = vi.fn( + async () => + [ + { + id: SECONDARY, + task_id: TASK_ID, + state: "WAITING_FOR_INPUT", + pending_action: "clarification", + updated_at: NEWER_AT, + }, + { + id: PRIMARY, + task_id: TASK_ID, + state: "WAITING_FOR_INPUT", + is_primary: true, + updated_at: OLDER_AT, + }, + ] as TaskSession[], + ); -function makeDeferredSessionLoader() { - let resolveLoad: (sessions: TaskSession[]) => void = () => {}; - const loadTaskSessionsForTask = vi.fn( - () => - new Promise((resolve) => { - resolveLoad = resolve; + selectTaskWithLayout({ + taskId: TASK_ID, + task: { primarySessionId: PRIMARY, taskPendingAction: "clarification" }, + store, + switchToSession, + loadTaskSessionsForTask, + setActiveTask: vi.fn(), + setPreparingTaskId: vi.fn(), + }); + + expect(switchToSession).not.toHaveBeenCalled(); + await flushTaskSelection(); + expect(loadTaskSessionsForTask).toHaveBeenCalledWith(TASK_ID, { force: true }); + expect(switchToSession).toHaveBeenCalledWith(TASK_ID, SECONDARY, OTHER_SESSION_ID); + }); + + it("uses the status-summary pending owner when the legacy field is empty", async () => { + const store = makeKanbanStore({ + activeSessionId: OTHER_SESSION_ID, + envIds: { [PRIMARY]: PRIMARY_ENV_ID, [SECONDARY]: SECONDARY_ENV_ID }, + }); + const switchToSession = vi.fn(); + const loadTaskSessionsForTask = vi.fn( + async () => + [ + { + id: SECONDARY, + task_id: TASK_ID, + state: "WAITING_FOR_INPUT", + pending_action: "clarification", + started_at: NEWER_AT, + updated_at: NEWER_AT, + }, + { + id: PRIMARY, + task_id: TASK_ID, + state: "WAITING_FOR_INPUT", + is_primary: true, + started_at: OLDER_AT, + updated_at: OLDER_AT, + }, + ] as TaskSession[], + ); + + selectTaskWithLayout({ + taskId: TASK_ID, + task: { + primarySessionId: PRIMARY, + statusSummary: { pending_action: "clarification" }, + }, + store, + switchToSession, + loadTaskSessionsForTask, + setActiveTask: vi.fn(), + setPreparingTaskId: vi.fn(), + }); + + expect(switchToSession).not.toHaveBeenCalled(); + await flushTaskSelection(); + expect(switchToSession).toHaveBeenCalledWith(TASK_ID, SECONDARY, OTHER_SESSION_ID); + }); +}); + +describe("selectTaskWithLayout pending summary authority", () => { + it("lets an explicit summary clear override a stale legacy pending field", () => { + const store = makeKanbanStore({ + activeSessionId: OTHER_SESSION_ID, + envIds: { [PRIMARY]: PRIMARY_ENV_ID }, + }); + const switchToSession = vi.fn(); + + selectTaskWithLayout({ + taskId: TASK_ID, + task: { + primarySessionId: PRIMARY, + taskPendingAction: "clarification", + statusSummary: {}, + }, + store, + switchToSession, + loadTaskSessionsForTask: vi.fn(async () => []), + setActiveTask: vi.fn(), + setPreparingTaskId: vi.fn(), + }); + + expect(switchToSession).toHaveBeenCalledWith(TASK_ID, PRIMARY, OTHER_SESSION_ID); + }); + + it("handles a rejected background refresh after an immediate selection", async () => { + const store = makeKanbanStore({ + activeSessionId: OTHER_SESSION_ID, + envIds: { [PRIMARY]: PRIMARY_ENV_ID }, + }); + const switchToSession = vi.fn(); + + selectTaskWithLayout({ + taskId: TASK_ID, + task: { primarySessionId: PRIMARY }, + store, + switchToSession, + loadTaskSessionsForTask: vi.fn(async () => { + throw new Error("refresh rejected"); }), - ); - return { - loadTaskSessionsForTask, - resolveLoad: (sessions: TaskSession[]) => resolveLoad(sessions), - }; -} + setActiveTask: vi.fn(), + setPreparingTaskId: vi.fn(), + }); + + await flushTaskSelection(); + expect(switchToSession).toHaveBeenCalledWith(TASK_ID, PRIMARY, OTHER_SESSION_ID); + }); + + it("does not choose a fallback session when pending-owner loading fails", async () => { + const store = makeKanbanStore({ + activeSessionId: OTHER_SESSION_ID, + envIds: { [PRIMARY]: PRIMARY_ENV_ID }, + }); + const switchToSession = vi.fn(); + const setActiveTask = vi.fn(); + + selectTaskWithLayout({ + taskId: TASK_ID, + task: { primarySessionId: PRIMARY, taskPendingAction: "clarification" }, + store, + switchToSession, + loadTaskSessionsForTask: vi.fn(async () => { + throw new Error("offline"); + }), + setActiveTask, + setPreparingTaskId: vi.fn(), + }); + + await flushTaskSelection(); + expect(switchToSession).not.toHaveBeenCalled(); + expect(setActiveTask).toHaveBeenCalledWith(TASK_ID); + expect(replaceTaskUrl).toHaveBeenCalledWith(TASK_ID); + }); + + it("uses the task-only fallback when pending-owner loading fails without a primary", async () => { + const store = makeKanbanStore({ activeSessionId: OTHER_SESSION_ID, envIds: {} }); + const setActiveTask = vi.fn(); + + selectTaskWithLayout({ + taskId: TASK_ID, + task: { taskPendingAction: "clarification" }, + store, + switchToSession: vi.fn(), + loadTaskSessionsForTask: vi.fn(async () => { + throw new Error("offline"); + }), + setActiveTask, + setPreparingTaskId: vi.fn(), + }); + + await flushTaskSelection(); + expect(setActiveTask).toHaveBeenCalledWith(TASK_ID); + expect(replaceTaskUrl).toHaveBeenCalledWith(TASK_ID); + }); +}); + +describe("selectTaskWithLayout sessionless layout cleanup", () => { + beforeEach(() => vi.clearAllMocks()); + + it("navigates without launching when a pending task has no loaded owner", async () => { + const outgoingEnvId = "env-outgoing"; + const store = makeKanbanStore({ + activeSessionId: OTHER_SESSION_ID, + envIds: { [OTHER_SESSION_ID]: outgoingEnvId }, + }); + const setActiveTask = vi.fn(); + + selectTaskWithLayout({ + taskId: TASK_ID, + task: { taskPendingAction: "clarification" }, + store, + switchToSession: vi.fn(), + loadTaskSessionsForTask: vi.fn(async () => []), + setActiveTask, + setPreparingTaskId: vi.fn(), + }); + + await flushTaskSelection(); + expect(launchSession).not.toHaveBeenCalled(); + expect(releaseLayoutToDefault).toHaveBeenCalledWith(outgoingEnvId); + expect(setActiveTask).toHaveBeenCalledWith(TASK_ID); + expect(vi.mocked(releaseLayoutToDefault).mock.invocationCallOrder[0]).toBeLessThan( + setActiveTask.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, + ); + expect(replaceTaskUrl).toHaveBeenCalledWith(TASK_ID); + }); + + it("uses the task-only fallback when sessionless loading fails", async () => { + const store = makeKanbanStore({ activeSessionId: OTHER_SESSION_ID, envIds: {} }); + const setActiveTask = vi.fn(); + + selectTaskWithLayout({ + taskId: TASK_ID, + task: undefined, + store, + switchToSession: vi.fn(), + loadTaskSessionsForTask: vi.fn(async () => { + throw new Error("offline"); + }), + setActiveTask, + setPreparingTaskId: vi.fn(), + }); + + await flushTaskSelection(); + expect(launchSession).not.toHaveBeenCalled(); + expect(setActiveTask).toHaveBeenCalledWith(TASK_ID); + expect(replaceTaskUrl).toHaveBeenCalledWith(TASK_ID); + }); +}); describe("prepareAndSwitchTask — outgoing-env panel cleanup", () => { beforeEach(() => { @@ -447,252 +677,3 @@ describe("selectTaskWithLayout — archived tasks", () => { expect(launchSession).not.toHaveBeenCalled(); }); }); - -describe("selectTaskWithLayout — pending selection races", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("does not switch back to a sessionless task when its prepare failure resolves after another task was selected", async () => { - const SESSIONLESS = "task-sessionless"; - const OTHER = "task-other"; - const { state, store, setActiveTask } = makeSelectionHarness({ - activeTaskId: SESSIONLESS, - activeSessionId: null, - envIds: { "sess-other": "env-other" }, - sessions: { "sess-other": { id: "sess-other", task_id: OTHER } }, - }); - const { loadTaskSessionsForTask, resolveLoad } = makeDeferredSessionLoader(); - - selectTaskWithLayout({ - taskId: SESSIONLESS, - task: undefined, - store, - switchToSession: vi.fn(), - loadTaskSessionsForTask, - setActiveTask, - setPreparingTaskId: vi.fn(), - }); - - state.tasks.activeTaskId = OTHER; - state.tasks.activeSessionId = "sess-other"; - selectTaskWithLayout({ - taskId: OTHER, - task: { primarySessionId: "sess-other" }, - store, - switchToSession: vi.fn(), - loadTaskSessionsForTask: vi.fn(async () => []), - setActiveTask, - setPreparingTaskId: vi.fn(), - }); - resolveLoad([]); - await flushTaskSelection(); - - expect(setActiveTask).not.toHaveBeenCalledWith(SESSIONLESS); - expect(releaseLayoutToDefault).not.toHaveBeenCalled(); - expect(replaceTaskUrl).not.toHaveBeenCalledWith(SESSIONLESS); - }); - - it("does not switch to an old pending selection after the user clicks back to the original task", async () => { - const { store, setActiveTask } = makeSelectionHarness({ - activeTaskId: ORIGINAL_TASK_ID, - activeSessionId: ORIGINAL_SESSION_ID, - envIds: { [ORIGINAL_SESSION_ID]: ORIGINAL_ENV_ID }, - sessions: { - [ORIGINAL_SESSION_ID]: { id: ORIGINAL_SESSION_ID, task_id: ORIGINAL_TASK_ID }, - }, - }); - const { loadTaskSessionsForTask, resolveLoad } = makeDeferredSessionLoader(); - - selectTaskWithLayout({ - taskId: PENDING_TASK_ID, - task: undefined, - store, - switchToSession: vi.fn(), - loadTaskSessionsForTask, - setActiveTask, - setPreparingTaskId: vi.fn(), - }); - - selectTaskWithLayout({ - taskId: ORIGINAL_TASK_ID, - task: { primarySessionId: ORIGINAL_SESSION_ID }, - store, - switchToSession: vi.fn(), - loadTaskSessionsForTask: vi.fn(async () => []), - setActiveTask, - setPreparingTaskId: vi.fn(), - }); - resolveLoad([]); - await flushTaskSelection(); - - expect(setActiveTask).not.toHaveBeenCalledWith(PENDING_TASK_ID); - expect(replaceTaskUrl).not.toHaveBeenCalledWith(PENDING_TASK_ID); - }); -}); - -describe("selectTaskWithLayout — external active-task changes", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("does not apply a pending selection after an external active-task change", async () => { - const OTHER = "task-other"; - const switchToSession = vi.fn(); - const { state, store, setActiveTask } = makeSelectionHarness({ - activeTaskId: ORIGINAL_TASK_ID, - activeSessionId: ORIGINAL_SESSION_ID, - envIds: { [ORIGINAL_SESSION_ID]: ORIGINAL_ENV_ID }, - sessions: { - [PENDING_SESSION_ID]: { id: PENDING_SESSION_ID, task_id: PENDING_TASK_ID }, - [ORIGINAL_SESSION_ID]: { id: ORIGINAL_SESSION_ID, task_id: ORIGINAL_TASK_ID }, - }, - }); - const { loadTaskSessionsForTask, resolveLoad } = makeDeferredSessionLoader(); - - selectTaskWithLayout({ - taskId: PENDING_TASK_ID, - task: { primarySessionId: PENDING_SESSION_ID }, - store, - switchToSession, - loadTaskSessionsForTask, - setActiveTask, - setPreparingTaskId: vi.fn(), - }); - - state.tasks.activeTaskId = OTHER; - state.tasks.activeSessionId = null; - resolveLoad([ - { id: PENDING_SESSION_ID, task_id: PENDING_TASK_ID, is_primary: true } as TaskSession, - ]); - await flushTaskSelection(); - - expect(switchToSession).not.toHaveBeenCalled(); - expect(replaceTaskUrl).not.toHaveBeenCalledWith(PENDING_TASK_ID); - }); - - it("does not apply a pending selection after an external task switch returns to the original task", async () => { - const OTHER = "task-other"; - const switchToSession = vi.fn(); - const { store, setActiveTask } = makeSelectionHarness({ - activeTaskId: ORIGINAL_TASK_ID, - activeSessionId: ORIGINAL_SESSION_ID, - envIds: { [ORIGINAL_SESSION_ID]: ORIGINAL_ENV_ID }, - sessions: { - [PENDING_SESSION_ID]: { id: PENDING_SESSION_ID, task_id: PENDING_TASK_ID }, - [ORIGINAL_SESSION_ID]: { id: ORIGINAL_SESSION_ID, task_id: ORIGINAL_TASK_ID }, - }, - }); - const { loadTaskSessionsForTask, resolveLoad } = makeDeferredSessionLoader(); - - selectTaskWithLayout({ - taskId: PENDING_TASK_ID, - task: { primarySessionId: PENDING_SESSION_ID }, - store, - switchToSession, - loadTaskSessionsForTask, - setActiveTask, - setPreparingTaskId: vi.fn(), - }); - - setActiveTask(OTHER); - setActiveTask(ORIGINAL_TASK_ID); - resolveLoad([ - { id: PENDING_SESSION_ID, task_id: PENDING_TASK_ID, is_primary: true } as TaskSession, - ]); - await flushTaskSelection(); - - expect(switchToSession).not.toHaveBeenCalled(); - expect(replaceTaskUrl).not.toHaveBeenCalledWith(PENDING_TASK_ID); - }); -}); - -describe("selectTaskWithLayout — selection guard cleanup", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("disposes the selection guard when loading a primary task's sessions rejects", async () => { - const { store, setActiveTask, getListenerCount } = makeSelectionHarness({ - activeTaskId: ORIGINAL_TASK_ID, - activeSessionId: ORIGINAL_SESSION_ID, - envIds: { [ORIGINAL_SESSION_ID]: ORIGINAL_ENV_ID }, - }); - - selectTaskWithLayout({ - taskId: PENDING_TASK_ID, - task: { primarySessionId: PENDING_SESSION_ID }, - store, - switchToSession: vi.fn(), - loadTaskSessionsForTask: vi.fn(async () => { - throw new Error("load failed"); - }), - setActiveTask, - setPreparingTaskId: vi.fn(), - }); - - expect(getListenerCount()).toBe(1); - await flushTaskSelection(); - - expect(getListenerCount()).toBe(0); - }); - - it("disposes the selection guard when loading a sessionless task rejects", async () => { - const { store, setActiveTask, getListenerCount } = makeSelectionHarness({ - activeTaskId: ORIGINAL_TASK_ID, - activeSessionId: ORIGINAL_SESSION_ID, - envIds: { [ORIGINAL_SESSION_ID]: ORIGINAL_ENV_ID }, - }); - - selectTaskWithLayout({ - taskId: PENDING_TASK_ID, - task: undefined, - store, - switchToSession: vi.fn(), - loadTaskSessionsForTask: vi.fn(async () => { - throw new Error("load failed"); - }), - setActiveTask, - setPreparingTaskId: vi.fn(), - }); - - expect(getListenerCount()).toBe(1); - await flushTaskSelection(); - - expect(getListenerCount()).toBe(0); - }); -}); - -describe("selectTaskWithLayout — pending old-session changes", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("keeps a pending selection alive when only the old task's session id changes", async () => { - const OLD = "task-old"; - const SESSIONLESS = "task-sessionless"; - vi.mocked(launchSession).mockResolvedValue({} as never); - const { state, store, setActiveTask } = makeSelectionHarness({ - activeTaskId: OLD, - activeSessionId: "sess-old", - }); - const { loadTaskSessionsForTask, resolveLoad } = makeDeferredSessionLoader(); - - selectTaskWithLayout({ - taskId: SESSIONLESS, - task: undefined, - store, - switchToSession: vi.fn(), - loadTaskSessionsForTask, - setActiveTask, - setPreparingTaskId: vi.fn(), - }); - - state.tasks.activeSessionId = "sess-old-replaced"; - resolveLoad([]); - await flushTaskSelection(); - - expect(setActiveTask).toHaveBeenCalledWith(SESSIONLESS); - expect(replaceTaskUrl).toHaveBeenCalledWith(SESSIONLESS); - }); -}); diff --git a/apps/web/components/task/task-select-helpers.ts b/apps/web/components/task/task-select-helpers.ts index 624a532197..5945527a53 100644 --- a/apps/web/components/task/task-select-helpers.ts +++ b/apps/web/components/task/task-select-helpers.ts @@ -6,12 +6,15 @@ import type { StoreApi } from "zustand"; import type { AppState } from "@/lib/state/store"; -import type { TaskSession } from "@/lib/types/http"; +import type { TaskPendingAction, TaskSession } from "@/lib/types/http"; import { performLayoutSwitch, releaseLayoutToDefault } from "@/lib/state/dockview-store"; import { replaceTaskUrl } from "@/lib/links"; import { launchSession } from "@/lib/services/session-launch-service"; import { buildPrepareRequest } from "@/lib/services/session-launch-helpers"; import { createDebugLogger, isDebug } from "@/lib/debug/log"; +import { isInputCapableSessionState } from "@/lib/utils/task-pending-input"; +import { isAbortError } from "@/lib/utils/abort-error"; +import { findTaskInSnapshots } from "@/lib/kanban/find-task"; const debug = createDebugLogger("dockview:task-select"); let taskSelectionSequence = 0; @@ -22,6 +25,87 @@ export type SwitchToSessionFn = ( oldSessionId: string | null | undefined, ) => void; +type TaskSessionLoader = ( + taskId: string, + options?: { force?: boolean; signal?: AbortSignal }, +) => Promise; + +type PendingTask = { + taskPendingAction?: TaskPendingAction | null; + statusSummary?: { + revision?: number; + updated_at?: string; + pending_action?: TaskPendingAction | null; + } | null; +}; + +export type TaskPendingSelectionSnapshot = { + revision: number | null; + pendingAction: TaskPendingAction | null | undefined; +}; + +export function taskPendingSelectionMatches( + initial: TaskPendingSelectionSnapshot, + current: TaskPendingSelectionSnapshot, +): boolean { + return current.revision === initial.revision && current.pendingAction === initial.pendingAction; +} + +export function effectiveTaskPendingAction( + task: PendingTask | undefined, +): TaskPendingAction | null | undefined { + return task?.statusSummary != null ? task.statusSummary.pending_action : task?.taskPendingAction; +} + +export function taskPendingSelectionSnapshot( + task: PendingTask | undefined, +): TaskPendingSelectionSnapshot { + return { + revision: task?.statusSummary?.revision ?? null, + pendingAction: effectiveTaskPendingAction(task), + }; +} + +function handlePendingSelectionOwnerChange( + store: StoreApi, + taskId: string, + initial: TaskPendingSelectionSnapshot, + taskProjectionExisted: boolean, + onChanged: () => void, +): boolean { + if (!initial.pendingAction) return false; + const state = store.getState(); + const currentTask = findTaskInSnapshots( + taskId, + state.kanbanMulti?.snapshots ?? {}, + state.kanban?.tasks ?? [], + ); + if (!currentTask) { + // Callers without an initial store projection cannot revalidate existence. + // A projected task must fail closed if it disappears during session loading. + return taskProjectionExisted; + } + const current = taskPendingSelectionSnapshot(currentTask); + if (taskPendingSelectionMatches(initial, current)) return false; + onChanged(); + return true; +} + +function pendingOwnerGuard( + store: StoreApi, + taskId: string, + task: PendingTask | undefined, + onChanged: () => void, +): () => boolean { + const state = store.getState(); + const initial = taskPendingSelectionSnapshot(task); + const taskProjectionExisted = Boolean( + findTaskInSnapshots(taskId, state.kanbanMulti?.snapshots ?? {}, state.kanban?.tasks ?? []), + ); + return () => + handlePendingSelectionOwnerChange(store, taskId, initial, taskProjectionExisted, onChanged); +} + function getTaskSessionIds(state: AppState, taskId: string): string[] { return (state.taskSessionsByTask?.itemsByTaskId?.[taskId] ?? []).map((session) => session.id); } @@ -38,6 +122,22 @@ export function resolveLoadedSessionId( ); } +export function resolveTaskSessionId(args: { + sessions: TaskSession[]; + preferredSessionId: string; + taskPendingAction?: TaskPendingAction | null; +}): string { + const { sessions, preferredSessionId, taskPendingAction } = args; + if (taskPendingAction) { + const owner = sessions.find( + (session) => + isInputCapableSessionState(session.state) && session.pending_action === taskPendingAction, + ); + return owner?.id ?? ""; + } + return resolveLoadedSessionId(sessions, preferredSessionId); +} + /** * Pick the session to re-open when the user navigates back to a task. * @@ -118,6 +218,39 @@ function taskSelectionWasSuperseded(selectionToken: number): boolean { return selectionToken !== taskSelectionSequence; } +function createTaskSelectionGuard( + store: StoreApi, + taskId: string, + selectionToken: number, + selectionSignal?: AbortSignal, +) { + const startActiveTaskId = store.getState().tasks.activeTaskId ?? null; + let activeTaskChangedExternally = false; + const unsubscribe = store.subscribe((current, previous) => { + const currentTaskId = current.tasks.activeTaskId ?? null; + const previousTaskId = previous.tasks.activeTaskId ?? null; + if (currentTaskId !== previousTaskId && currentTaskId !== taskId) { + activeTaskChangedExternally = true; + } + }); + return { + dispose: () => { + if (typeof unsubscribe === "function") unsubscribe(); + }, + wasSuperseded: () => { + if ( + selectionSignal?.aborted || + taskSelectionWasSuperseded(selectionToken) || + activeTaskChangedExternally + ) { + return true; + } + const activeTaskId = store.getState().tasks.activeTaskId ?? null; + return activeTaskId !== startActiveTaskId && activeTaskId !== taskId; + }, + }; +} + export async function prepareAndSwitchTask( taskId: string, store: StoreApi, @@ -159,49 +292,86 @@ export async function prepareAndSwitchTask( } } -export function selectTaskWithLayout(params: { +type SelectTaskWithLayoutParams = { taskId: string; - task: { primarySessionId?: string | null; isArchived?: boolean } | undefined; + task: + | { + primarySessionId?: string | null; + taskPendingAction?: TaskPendingAction | null; + statusSummary?: { + revision?: number; + updated_at?: string; + pending_action?: TaskPendingAction | null; + } | null; + isArchived?: boolean; + } + | undefined; store: StoreApi; switchToSession: SwitchToSessionFn; - loadTaskSessionsForTask: (taskId: string) => Promise; + loadTaskSessionsForTask: TaskSessionLoader; setActiveTask: (taskId: string) => void; setPreparingTaskId: (id: string | null) => void; -}): void { + navigateToTask?: (taskId: string) => void; + selectionSignal?: AbortSignal; +}; + +function loadTaskSessionsForSelection( + params: SelectTaskWithLayoutParams, + hasPendingAction: boolean, +): Promise { + if (!hasPendingAction && !params.selectionSignal) { + return params.loadTaskSessionsForTask(params.taskId); + } + const options = { force: hasPendingAction || undefined, signal: params.selectionSignal }; + return params.loadTaskSessionsForTask(params.taskId, options); +} + +function openTaskWithoutSession( + params: SelectTaskWithLayoutParams, + navigateToTask: (taskId: string) => void, +): void { + const state = params.store.getState(); + const oldSessionId = state.tasks.activeSessionId; + const oldEnvId = oldSessionId ? (state.environmentIdBySessionId[oldSessionId] ?? null) : null; + releaseLayoutToDefault(oldEnvId); + params.setActiveTask(params.taskId); + navigateToTask(params.taskId); +} + +function logTaskSelection( + taskId: string, + primarySessionId: string | null | undefined, + oldSessionId: string | null | undefined, + previousTaskId: string | null | undefined, +): void { + if (!isDebug()) return; + debug("selectTaskWithLayout: entry", { + taskId, + primarySessionId: primarySessionId ?? null, + oldSessionId: oldSessionId ?? null, + prevActiveTaskId: previousTaskId ?? null, + }); +} + +export function selectTaskWithLayout(params: SelectTaskWithLayoutParams): void { const { taskId, task, store, switchToSession, loadTaskSessionsForTask } = params; const state = store.getState(); - const selectionToken = nextTaskSelectionToken(); - const startActiveTaskId = state.tasks.activeTaskId ?? null; const oldSessionId = state.tasks.activeSessionId; - let activeTaskChangedExternally = false; - const unsubscribeSelectionGuard = store.subscribe((current, previous) => { - const currentTaskId = current.tasks.activeTaskId ?? null; - const previousTaskId = previous.tasks.activeTaskId ?? null; - if (currentTaskId !== previousTaskId && currentTaskId !== taskId) { - activeTaskChangedExternally = true; - } - }); - const disposeSelectionGuard = () => { - if (typeof unsubscribeSelectionGuard === "function") unsubscribeSelectionGuard(); - }; - const selectionWasSuperseded = () => { - if (taskSelectionWasSuperseded(selectionToken)) return true; - if (activeTaskChangedExternally) return true; - const activeTaskId = store.getState().tasks.activeTaskId ?? null; - return activeTaskId !== startActiveTaskId && activeTaskId !== taskId; - }; - if (isDebug()) { - debug("selectTaskWithLayout: entry", { - taskId, - primarySessionId: task?.primarySessionId ?? null, - oldSessionId: oldSessionId ?? null, - prevActiveTaskId: state.tasks.activeTaskId ?? null, - }); - } + const navigateToTask = params.navigateToTask ?? replaceTaskUrl; + const openWithoutSession = () => openTaskWithoutSession(params, navigateToTask); + const taskPendingAction = effectiveTaskPendingAction(task); + const pendingOwnerHandled = pendingOwnerGuard(store, taskId, task, openWithoutSession); + const selectionGuard = createTaskSelectionGuard( + store, + taskId, + nextTaskSelectionToken(), + params.selectionSignal, + ); + logTaskSelection(taskId, task?.primarySessionId, oldSessionId, state.tasks.activeTaskId); if (task?.isArchived) { - disposeSelectionGuard(); + selectionGuard.dispose(); params.setActiveTask(taskId); - replaceTaskUrl(taskId); + navigateToTask(taskId); return; } if (task?.primarySessionId) { @@ -212,58 +382,78 @@ export function selectTaskWithLayout(params: { environmentIdBySessionId: state.environmentIdBySessionId, taskSessionsById: state.taskSessions.items, }); - const hasEnvId = !!state.environmentIdBySessionId[targetSessionId]; - if (hasEnvId) { - disposeSelectionGuard(); + if (state.environmentIdBySessionId[targetSessionId] && !taskPendingAction) { + selectionGuard.dispose(); switchToSession(taskId, targetSessionId, oldSessionId); - loadTaskSessionsForTask(taskId); - replaceTaskUrl(taskId); + void loadTaskSessionsForTask(taskId).catch(() => undefined); + navigateToTask(taskId); return; } - void loadTaskSessionsForTask(taskId) + void loadTaskSessionsForSelection(params, !!taskPendingAction) .then((sessions) => { - if (selectionWasSuperseded()) return; - switchToSession(taskId, resolveLoadedSessionId(sessions, targetSessionId), oldSessionId); - replaceTaskUrl(taskId); + if (selectionGuard.wasSuperseded()) return; + if (pendingOwnerHandled()) return; + const currentOldSessionId = store.getState().tasks.activeSessionId; + const resolvedSessionId = resolveTaskSessionId({ + sessions, + preferredSessionId: targetSessionId, + taskPendingAction, + }); + if (!resolvedSessionId) return openWithoutSession(); + switchToSession(taskId, resolvedSessionId, currentOldSessionId); + navigateToTask(taskId); }) - .finally(disposeSelectionGuard) - .catch(() => undefined); + .catch((error) => { + if (isAbortError(error)) return; + if (selectionGuard.wasSuperseded()) return; + if (pendingOwnerHandled()) return; + if (taskPendingAction) return openWithoutSession(); + switchToSession(taskId, targetSessionId, store.getState().tasks.activeSessionId); + navigateToTask(taskId); + }) + .finally(selectionGuard.dispose); return; } - - void loadTaskSessionsForTask(taskId) + void loadTaskSessionsForSelection(params, !!taskPendingAction) .then(async (sessions) => { - if (selectionWasSuperseded()) return; + if (selectionGuard.wasSuperseded()) return; + if (pendingOwnerHandled()) return; const currentOldSessionId = store.getState().tasks.activeSessionId; - const primary = sessions.find((s) => s.is_primary); - const sessionId = primary?.id ?? sessions[0]?.id ?? null; + const sessionId = resolveTaskSessionId({ + sessions, + preferredSessionId: "", + taskPendingAction, + }); if (sessionId) { switchToSession(taskId, sessionId, currentOldSessionId); - replaceTaskUrl(taskId); + navigateToTask(taskId); return; } + if (taskPendingAction) return openWithoutSession(); const switched = await prepareAndSwitchTask( taskId, store, switchToSession, params.setPreparingTaskId, - () => !selectionWasSuperseded(), + () => !selectionGuard.wasSuperseded(), ); if (switched) { - replaceTaskUrl(taskId); + navigateToTask(taskId); return; } - if (selectionWasSuperseded()) return; + if (selectionGuard.wasSuperseded()) return; - // Failure path: prepareAndSwitchTask already called releaseLayoutToDefault - // before awaiting, so the outgoing env's layout is already saved and the - // dockview is showing the default layout. A second release here would - // overwrite the just-saved env layout with `api.toJSON()` (the default), - // losing the user's real layout for the originating task. + // prepareAndSwitchTask already saved and released the outgoing layout. + // Releasing again would overwrite it with the current default layout. params.setActiveTask(taskId); - replaceTaskUrl(taskId); + navigateToTask(taskId); }) - .finally(disposeSelectionGuard) - .catch(() => undefined); + .catch((error) => { + if (isAbortError(error)) return; + if (selectionGuard.wasSuperseded()) return; + if (pendingOwnerHandled()) return; + openWithoutSession(); + }) + .finally(selectionGuard.dispose); } diff --git a/apps/web/components/task/task-select-races.test.ts b/apps/web/components/task/task-select-races.test.ts new file mode 100644 index 0000000000..8042001b7c --- /dev/null +++ b/apps/web/components/task/task-select-races.test.ts @@ -0,0 +1,606 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { StoreApi } from "zustand"; +import type { AppState } from "@/lib/state/store"; +import type { TaskPendingAction, TaskSession } from "@/lib/types/http"; +import { launchSession } from "@/lib/services/session-launch-service"; +import { releaseLayoutToDefault } from "@/lib/state/dockview-store"; +import { replaceTaskUrl } from "@/lib/links"; +import { selectTaskWithLayout } from "./task-select-helpers"; + +vi.mock("@/lib/services/session-launch-service", () => ({ launchSession: vi.fn() })); +vi.mock("@/lib/services/session-launch-helpers", () => ({ + buildPrepareRequest: vi.fn(() => ({ request: { taskId: "task-new" } })), +})); +vi.mock("@/lib/state/dockview-store", () => ({ + performLayoutSwitch: vi.fn(), + releaseLayoutToDefault: vi.fn(), +})); +vi.mock("@/lib/links", () => ({ replaceTaskUrl: vi.fn() })); + +const ORIGINAL_TASK_ID = "task-original"; +const PENDING_TASK_ID = "task-pending"; +const ORIGINAL_SESSION_ID = "sess-original"; +const PENDING_SESSION_ID = "sess-pending"; +const ORIGINAL_ENV_ID = "env-original"; +const SUMMARY_UPDATED_AT = "2026-08-15T15:00:00Z"; +const NEWER_SUMMARY_UPDATED_AT = "2026-08-15T15:00:01Z"; + +function makeSelectionHarness(args: { + activeTaskId: string; + activeSessionId: string | null; + envIds?: Record; + sessions?: Record; + taskRows?: Array<{ + id: string; + primarySessionId?: string | null; + taskPendingAction?: TaskPendingAction | null; + statusSummary?: { + revision: number; + updated_at: string; + pending_action?: TaskPendingAction | null; + } | null; + }>; +}) { + const listeners: Array<(state: AppState, previousState: AppState) => void> = []; + const state = { + tasks: { + activeTaskId: args.activeTaskId, + activeSessionId: args.activeSessionId, + lastSessionByTaskId: {}, + }, + taskPRs: { byTaskId: {} as Record }, + environmentIdBySessionId: args.envIds ?? {}, + taskSessions: { items: (args.sessions ?? {}) as Record }, + kanban: { tasks: args.taskRows ?? [] }, + kanbanMulti: { snapshots: {} }, + }; + const snapshot = () => ({ ...state, tasks: { ...state.tasks } }) as unknown as AppState; + const notify = (previousState: AppState) => { + for (const listener of listeners) listener(state as unknown as AppState, previousState); + }; + const store = { + getState: () => state as unknown as AppState, + setState: vi.fn(), + subscribe: vi.fn((listener: (state: AppState, previousState: AppState) => void) => { + listeners.push(listener); + return () => { + const index = listeners.indexOf(listener); + if (index >= 0) listeners.splice(index, 1); + }; + }), + } as unknown as StoreApi; + const setActiveTask = vi.fn((taskId: string) => { + const previousState = snapshot(); + state.tasks.activeTaskId = taskId; + state.tasks.activeSessionId = null; + notify(previousState); + }); + return { state, store, setActiveTask, getListenerCount: () => listeners.length }; +} + +function makeDeferredSessionLoader() { + let resolveLoad: (sessions: TaskSession[]) => void = () => {}; + let rejectLoad: (error: Error) => void = () => {}; + const loadTaskSessionsForTask = vi.fn( + () => + new Promise((resolve, reject) => { + resolveLoad = resolve; + rejectLoad = reject; + }), + ); + return { + loadTaskSessionsForTask, + resolveLoad: (sessions: TaskSession[]) => resolveLoad(sessions), + rejectLoad: (error: Error) => rejectLoad(error), + }; +} + +async function flushTaskSelection() { + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe("selectTaskWithLayout pending selection races", () => { + beforeEach(() => vi.clearAllMocks()); + + it("ignores a sessionless selection after another task is selected", async () => { + const sessionlessTaskId = "task-sessionless"; + const otherTaskId = "task-other"; + const { state, store, setActiveTask } = makeSelectionHarness({ + activeTaskId: sessionlessTaskId, + activeSessionId: null, + envIds: { "sess-other": "env-other" }, + sessions: { "sess-other": { id: "sess-other", task_id: otherTaskId } }, + }); + const { loadTaskSessionsForTask, resolveLoad } = makeDeferredSessionLoader(); + + selectTaskWithLayout({ + taskId: sessionlessTaskId, + task: undefined, + store, + switchToSession: vi.fn(), + loadTaskSessionsForTask, + setActiveTask, + setPreparingTaskId: vi.fn(), + }); + state.tasks.activeTaskId = otherTaskId; + state.tasks.activeSessionId = "sess-other"; + selectTaskWithLayout({ + taskId: otherTaskId, + task: { primarySessionId: "sess-other" }, + store, + switchToSession: vi.fn(), + loadTaskSessionsForTask: vi.fn(async () => []), + setActiveTask, + setPreparingTaskId: vi.fn(), + }); + resolveLoad([]); + await flushTaskSelection(); + + expect(setActiveTask).not.toHaveBeenCalledWith(sessionlessTaskId); + expect(releaseLayoutToDefault).not.toHaveBeenCalled(); + expect(replaceTaskUrl).not.toHaveBeenCalledWith(sessionlessTaskId); + }); + + it("ignores an old pending selection after returning to the original task", async () => { + const { store, setActiveTask } = makeSelectionHarness({ + activeTaskId: ORIGINAL_TASK_ID, + activeSessionId: ORIGINAL_SESSION_ID, + envIds: { [ORIGINAL_SESSION_ID]: ORIGINAL_ENV_ID }, + sessions: { + [ORIGINAL_SESSION_ID]: { id: ORIGINAL_SESSION_ID, task_id: ORIGINAL_TASK_ID }, + }, + }); + const { loadTaskSessionsForTask, resolveLoad } = makeDeferredSessionLoader(); + + selectTaskWithLayout({ + taskId: PENDING_TASK_ID, + task: undefined, + store, + switchToSession: vi.fn(), + loadTaskSessionsForTask, + setActiveTask, + setPreparingTaskId: vi.fn(), + }); + selectTaskWithLayout({ + taskId: ORIGINAL_TASK_ID, + task: { primarySessionId: ORIGINAL_SESSION_ID }, + store, + switchToSession: vi.fn(), + loadTaskSessionsForTask: vi.fn(async () => []), + setActiveTask, + setPreparingTaskId: vi.fn(), + }); + resolveLoad([]); + await flushTaskSelection(); + + expect(setActiveTask).not.toHaveBeenCalledWith(PENDING_TASK_ID); + expect(replaceTaskUrl).not.toHaveBeenCalledWith(PENDING_TASK_ID); + }); +}); + +describe("selectTaskWithLayout pending summary races", () => { + beforeEach(() => vi.clearAllMocks()); + + it("falls back to the task route when the pending action changes", async () => { + const initialTask = { + id: PENDING_TASK_ID, + primarySessionId: PENDING_SESSION_ID, + statusSummary: { + revision: 1, + updated_at: SUMMARY_UPDATED_AT, + pending_action: "clarification" as const, + }, + }; + const { state, store, setActiveTask } = makeSelectionHarness({ + activeTaskId: ORIGINAL_TASK_ID, + activeSessionId: ORIGINAL_SESSION_ID, + taskRows: [initialTask], + }); + const switchToSession = vi.fn(); + const { loadTaskSessionsForTask, resolveLoad } = makeDeferredSessionLoader(); + + selectTaskWithLayout({ + taskId: PENDING_TASK_ID, + task: initialTask, + store, + switchToSession, + loadTaskSessionsForTask, + setActiveTask, + setPreparingTaskId: vi.fn(), + }); + state.kanban.tasks[0] = { + ...initialTask, + statusSummary: { + revision: 2, + updated_at: NEWER_SUMMARY_UPDATED_AT, + pending_action: "permission", + }, + }; + resolveLoad([ + { + id: PENDING_SESSION_ID, + task_id: PENDING_TASK_ID, + state: "WAITING_FOR_INPUT", + pending_action: "clarification", + } as TaskSession, + ]); + await vi.waitFor(() => expect(setActiveTask).toHaveBeenCalledWith(PENDING_TASK_ID)); + expect(loadTaskSessionsForTask).toHaveBeenCalledWith(PENDING_TASK_ID, { force: true }); + expect(switchToSession).not.toHaveBeenCalled(); + expect(replaceTaskUrl).toHaveBeenCalledWith(PENDING_TASK_ID); + }); +}); + +describe("selectTaskWithLayout same-action summary race", () => { + beforeEach(() => vi.clearAllMocks()); + + it("falls back to the task route when a same-action revision can have a new owner", async () => { + const initialTask = { + id: PENDING_TASK_ID, + primarySessionId: PENDING_SESSION_ID, + statusSummary: { + revision: 1, + updated_at: SUMMARY_UPDATED_AT, + pending_action: "clarification" as const, + }, + }; + const { state, store, setActiveTask } = makeSelectionHarness({ + activeTaskId: ORIGINAL_TASK_ID, + activeSessionId: ORIGINAL_SESSION_ID, + taskRows: [initialTask], + }); + const switchToSession = vi.fn(); + const { loadTaskSessionsForTask, resolveLoad } = makeDeferredSessionLoader(); + + selectTaskWithLayout({ + taskId: PENDING_TASK_ID, + task: initialTask, + store, + switchToSession, + loadTaskSessionsForTask, + setActiveTask, + setPreparingTaskId: vi.fn(), + }); + state.kanban.tasks[0] = { + ...initialTask, + statusSummary: { + revision: 2, + updated_at: NEWER_SUMMARY_UPDATED_AT, + pending_action: "clarification", + }, + }; + resolveLoad([ + { + id: PENDING_SESSION_ID, + task_id: PENDING_TASK_ID, + state: "WAITING_FOR_INPUT", + pending_action: "clarification", + } as TaskSession, + ]); + await vi.waitFor(() => expect(setActiveTask).toHaveBeenCalledWith(PENDING_TASK_ID)); + expect(switchToSession).not.toHaveBeenCalled(); + expect(replaceTaskUrl).toHaveBeenCalledWith(PENDING_TASK_ID); + }); +}); + +describe("selectTaskWithLayout deleted-task race", () => { + beforeEach(() => vi.clearAllMocks()); + + it("leaves selection inert when the pending task projection disappears", async () => { + const initialTask = { + id: PENDING_TASK_ID, + primarySessionId: PENDING_SESSION_ID, + statusSummary: { + revision: 1, + updated_at: SUMMARY_UPDATED_AT, + pending_action: "clarification" as const, + }, + }; + const { state, store, setActiveTask } = makeSelectionHarness({ + activeTaskId: ORIGINAL_TASK_ID, + activeSessionId: ORIGINAL_SESSION_ID, + taskRows: [initialTask], + }); + const switchToSession = vi.fn(); + const { loadTaskSessionsForTask, resolveLoad } = makeDeferredSessionLoader(); + + selectTaskWithLayout({ + taskId: PENDING_TASK_ID, + task: initialTask, + store, + switchToSession, + loadTaskSessionsForTask, + setActiveTask, + setPreparingTaskId: vi.fn(), + }); + state.kanban.tasks = []; + resolveLoad([ + { + id: PENDING_SESSION_ID, + task_id: PENDING_TASK_ID, + state: "WAITING_FOR_INPUT", + pending_action: "clarification", + } as TaskSession, + ]); + await flushTaskSelection(); + + expect(switchToSession).not.toHaveBeenCalled(); + expect(setActiveTask).not.toHaveBeenCalled(); + expect(replaceTaskUrl).not.toHaveBeenCalledWith(PENDING_TASK_ID); + }); +}); + +describe("selectTaskWithLayout rejected pending-load races", () => { + beforeEach(() => vi.clearAllMocks()); + + for (const primarySessionId of [PENDING_SESSION_ID, null]) { + it(`falls back after a rejected ${primarySessionId ? "primary" : "sessionless"} load when the summary changes`, async () => { + const initialTask = { + id: PENDING_TASK_ID, + primarySessionId, + statusSummary: { + revision: 1, + updated_at: SUMMARY_UPDATED_AT, + pending_action: "clarification" as const, + }, + }; + const { state, store, setActiveTask } = makeSelectionHarness({ + activeTaskId: ORIGINAL_TASK_ID, + activeSessionId: ORIGINAL_SESSION_ID, + taskRows: [initialTask], + }); + const switchToSession = vi.fn(); + const { loadTaskSessionsForTask, rejectLoad } = makeDeferredSessionLoader(); + + selectTaskWithLayout({ + taskId: PENDING_TASK_ID, + task: initialTask, + store, + switchToSession, + loadTaskSessionsForTask, + setActiveTask, + setPreparingTaskId: vi.fn(), + }); + state.kanban.tasks[0] = { + ...initialTask, + statusSummary: { + revision: 2, + updated_at: NEWER_SUMMARY_UPDATED_AT, + pending_action: "permission", + }, + }; + rejectLoad(new Error("load failed")); + await vi.waitFor(() => expect(setActiveTask).toHaveBeenCalledWith(PENDING_TASK_ID)); + expect(switchToSession).not.toHaveBeenCalled(); + expect(replaceTaskUrl).toHaveBeenCalledWith(PENDING_TASK_ID); + }); + } +}); + +describe("selectTaskWithLayout external active-task changes", () => { + beforeEach(() => vi.clearAllMocks()); + + function pendingHarness() { + return makeSelectionHarness({ + activeTaskId: ORIGINAL_TASK_ID, + activeSessionId: ORIGINAL_SESSION_ID, + envIds: { [ORIGINAL_SESSION_ID]: ORIGINAL_ENV_ID }, + sessions: { + [PENDING_SESSION_ID]: { id: PENDING_SESSION_ID, task_id: PENDING_TASK_ID }, + [ORIGINAL_SESSION_ID]: { id: ORIGINAL_SESSION_ID, task_id: ORIGINAL_TASK_ID }, + }, + }); + } + + it("ignores a pending selection after an external active-task change", async () => { + const switchToSession = vi.fn(); + const { state, store, setActiveTask } = pendingHarness(); + const { loadTaskSessionsForTask, resolveLoad } = makeDeferredSessionLoader(); + selectTaskWithLayout({ + taskId: PENDING_TASK_ID, + task: { primarySessionId: PENDING_SESSION_ID }, + store, + switchToSession, + loadTaskSessionsForTask, + setActiveTask, + setPreparingTaskId: vi.fn(), + }); + + state.tasks.activeTaskId = "task-other"; + state.tasks.activeSessionId = null; + resolveLoad([ + { id: PENDING_SESSION_ID, task_id: PENDING_TASK_ID, is_primary: true } as TaskSession, + ]); + await flushTaskSelection(); + + expect(switchToSession).not.toHaveBeenCalled(); + expect(replaceTaskUrl).not.toHaveBeenCalledWith(PENDING_TASK_ID); + }); + + it("remembers an external switch even when it returns to the original task", async () => { + const switchToSession = vi.fn(); + const { store, setActiveTask } = pendingHarness(); + const { loadTaskSessionsForTask, resolveLoad } = makeDeferredSessionLoader(); + selectTaskWithLayout({ + taskId: PENDING_TASK_ID, + task: { primarySessionId: PENDING_SESSION_ID }, + store, + switchToSession, + loadTaskSessionsForTask, + setActiveTask, + setPreparingTaskId: vi.fn(), + }); + + setActiveTask("task-other"); + setActiveTask(ORIGINAL_TASK_ID); + resolveLoad([ + { id: PENDING_SESSION_ID, task_id: PENDING_TASK_ID, is_primary: true } as TaskSession, + ]); + await flushTaskSelection(); + + expect(switchToSession).not.toHaveBeenCalled(); + expect(replaceTaskUrl).not.toHaveBeenCalledWith(PENDING_TASK_ID); + }); + + it("ignores an off-route pending selection after unrelated navigation", async () => { + const switchToSession = vi.fn(); + const navigateToTask = vi.fn(); + const selectionController = new AbortController(); + const { store, setActiveTask } = pendingHarness(); + const { loadTaskSessionsForTask, resolveLoad } = makeDeferredSessionLoader(); + selectTaskWithLayout({ + taskId: PENDING_TASK_ID, + task: { + primarySessionId: PENDING_SESSION_ID, + taskPendingAction: "clarification", + }, + store, + switchToSession, + loadTaskSessionsForTask, + setActiveTask, + setPreparingTaskId: vi.fn(), + navigateToTask, + selectionSignal: selectionController.signal, + }); + + expect(loadTaskSessionsForTask).toHaveBeenCalledWith(PENDING_TASK_ID, { + force: true, + signal: selectionController.signal, + }); + + selectionController.abort(); + resolveLoad([ + { + id: PENDING_SESSION_ID, + task_id: PENDING_TASK_ID, + state: "WAITING_FOR_INPUT", + pending_action: "clarification", + } as TaskSession, + ]); + await flushTaskSelection(); + + expect(switchToSession).not.toHaveBeenCalled(); + expect(setActiveTask).not.toHaveBeenCalledWith(PENDING_TASK_ID); + expect(navigateToTask).not.toHaveBeenCalled(); + }); +}); + +describe("selectTaskWithLayout selection guard cleanup", () => { + beforeEach(() => vi.clearAllMocks()); + + for (const task of [ + { label: "primary", value: { primarySessionId: PENDING_SESSION_ID } }, + { label: "sessionless", value: undefined }, + ]) { + it(`disposes the guard when loading a ${task.label} task rejects`, async () => { + const { store, setActiveTask, getListenerCount } = makeSelectionHarness({ + activeTaskId: ORIGINAL_TASK_ID, + activeSessionId: ORIGINAL_SESSION_ID, + envIds: { [ORIGINAL_SESSION_ID]: ORIGINAL_ENV_ID }, + }); + selectTaskWithLayout({ + taskId: PENDING_TASK_ID, + task: task.value, + store, + switchToSession: vi.fn(), + loadTaskSessionsForTask: vi.fn(async () => { + throw new Error("load failed"); + }), + setActiveTask, + setPreparingTaskId: vi.fn(), + }); + + expect(getListenerCount()).toBe(1); + await flushTaskSelection(); + expect(getListenerCount()).toBe(0); + }); + } +}); + +describe("selectTaskWithLayout old-session changes", () => { + beforeEach(() => vi.clearAllMocks()); + + it("keeps a pending selection alive when only the old session id changes", async () => { + vi.mocked(launchSession).mockResolvedValue({} as never); + const sessionlessTaskId = "task-sessionless"; + const { state, store, setActiveTask } = makeSelectionHarness({ + activeTaskId: "task-old", + activeSessionId: "sess-old", + }); + const { loadTaskSessionsForTask, resolveLoad } = makeDeferredSessionLoader(); + selectTaskWithLayout({ + taskId: sessionlessTaskId, + task: undefined, + store, + switchToSession: vi.fn(), + loadTaskSessionsForTask, + setActiveTask, + setPreparingTaskId: vi.fn(), + }); + + state.tasks.activeSessionId = "sess-old-replaced"; + resolveLoad([]); + await vi.waitFor(() => expect(setActiveTask).toHaveBeenCalledWith(sessionlessTaskId)); + expect(replaceTaskUrl).toHaveBeenCalledWith(sessionlessTaskId); + }); + + it("uses the current old session after a primary-session load resolves", async () => { + const replacementSessionId = "sess-old-replaced"; + const switchToSession = vi.fn(); + const { state, store, setActiveTask } = makeSelectionHarness({ + activeTaskId: ORIGINAL_TASK_ID, + activeSessionId: ORIGINAL_SESSION_ID, + }); + const { loadTaskSessionsForTask, resolveLoad } = makeDeferredSessionLoader(); + selectTaskWithLayout({ + taskId: PENDING_TASK_ID, + task: { primarySessionId: PENDING_SESSION_ID }, + store, + switchToSession, + loadTaskSessionsForTask, + setActiveTask, + setPreparingTaskId: vi.fn(), + }); + + state.tasks.activeSessionId = replacementSessionId; + resolveLoad([{ id: PENDING_SESSION_ID, task_id: PENDING_TASK_ID } as TaskSession]); + await vi.waitFor(() => + expect(switchToSession).toHaveBeenCalledWith( + PENDING_TASK_ID, + PENDING_SESSION_ID, + replacementSessionId, + ), + ); + }); + + it("uses the current old session after a primary-session load rejects", async () => { + const replacementSessionId = "sess-old-replaced"; + const switchToSession = vi.fn(); + const { state, store, setActiveTask } = makeSelectionHarness({ + activeTaskId: ORIGINAL_TASK_ID, + activeSessionId: ORIGINAL_SESSION_ID, + }); + const { loadTaskSessionsForTask, rejectLoad } = makeDeferredSessionLoader(); + selectTaskWithLayout({ + taskId: PENDING_TASK_ID, + task: { primarySessionId: PENDING_SESSION_ID }, + store, + switchToSession, + loadTaskSessionsForTask, + setActiveTask, + setPreparingTaskId: vi.fn(), + }); + + state.tasks.activeSessionId = replacementSessionId; + rejectLoad(new Error("load failed")); + await vi.waitFor(() => + expect(switchToSession).toHaveBeenCalledWith( + PENDING_TASK_ID, + PENDING_SESSION_ID, + replacementSessionId, + ), + ); + }); +}); diff --git a/apps/web/components/task/task-select-review-races.test.ts b/apps/web/components/task/task-select-review-races.test.ts new file mode 100644 index 0000000000..54b9a62b56 --- /dev/null +++ b/apps/web/components/task/task-select-review-races.test.ts @@ -0,0 +1,143 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { StoreApi } from "zustand"; +import type { AppState } from "@/lib/state/store"; +import type { TaskSession } from "@/lib/types/http"; +import { replaceTaskUrl } from "@/lib/links"; +import { selectTaskWithLayout } from "./task-select-helpers"; + +vi.mock("@/lib/state/dockview-store", () => ({ + performLayoutSwitch: vi.fn(), + releaseLayoutToDefault: vi.fn(), +})); +vi.mock("@/lib/links", () => ({ replaceTaskUrl: vi.fn() })); + +const TASK_ID = "task-pending"; +const SESSION_ID = "session-pending"; +const ORIGINAL_TASK_ID = "task-original"; +const ORIGINAL_SESSION_ID = "session-original"; + +type PendingTask = { + id: string; + primarySessionId: string | null; + taskPendingAction: "clarification"; +}; + +function makeHarness(task: PendingTask) { + const state = { + tasks: { + activeTaskId: ORIGINAL_TASK_ID, + activeSessionId: ORIGINAL_SESSION_ID as string | null, + lastSessionByTaskId: {}, + }, + taskPRs: { byTaskId: {} }, + environmentIdBySessionId: {}, + taskSessions: { items: {} }, + kanban: { tasks: [task] }, + kanbanMulti: { snapshots: {} }, + }; + const store = { + getState: () => state as unknown as AppState, + setState: vi.fn(), + subscribe: vi.fn(() => vi.fn()), + } as unknown as StoreApi; + const setActiveTask = vi.fn((taskId: string) => { + state.tasks.activeTaskId = taskId; + state.tasks.activeSessionId = null; + }); + return { state, store, setActiveTask }; +} + +function deferredSessionLoad() { + let resolveLoad: (sessions: TaskSession[]) => void = () => undefined; + let rejectLoad: (error: Error) => void = () => undefined; + const loadTaskSessionsForTask = vi.fn( + () => + new Promise((resolve, reject) => { + resolveLoad = resolve; + rejectLoad = reject; + }), + ); + return { + loadTaskSessionsForTask, + resolveLoad: (sessions: TaskSession[]) => resolveLoad(sessions), + rejectLoad: (error: Error) => rejectLoad(error), + }; +} + +async function flushSelection() { + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe("selectTaskWithLayout review races", () => { + beforeEach(() => vi.clearAllMocks()); + + it("leaves a legacy pending selection inert when its task projection disappears", async () => { + const task: PendingTask = { + id: TASK_ID, + primarySessionId: SESSION_ID, + taskPendingAction: "clarification", + }; + const { state, store, setActiveTask } = makeHarness(task); + const switchToSession = vi.fn(); + const { loadTaskSessionsForTask, resolveLoad } = deferredSessionLoad(); + + selectTaskWithLayout({ + taskId: TASK_ID, + task, + store, + switchToSession, + loadTaskSessionsForTask, + setActiveTask, + setPreparingTaskId: vi.fn(), + }); + state.kanban.tasks = []; + resolveLoad([ + { + id: SESSION_ID, + task_id: TASK_ID, + state: "WAITING_FOR_INPUT", + pending_action: "clarification", + } as TaskSession, + ]); + await flushSelection(); + + expect(switchToSession).not.toHaveBeenCalled(); + expect(setActiveTask).not.toHaveBeenCalled(); + expect(replaceTaskUrl).not.toHaveBeenCalledWith(TASK_ID); + }); + + for (const primarySessionId of [SESSION_ID, null]) { + it(`ignores an aborted ${primarySessionId ? "primary" : "sessionless"} forced load`, async () => { + const task: PendingTask = { + id: TASK_ID, + primarySessionId, + taskPendingAction: "clarification", + }; + const { state, store, setActiveTask } = makeHarness(task); + const switchToSession = vi.fn(); + const { loadTaskSessionsForTask, rejectLoad } = deferredSessionLoad(); + + selectTaskWithLayout({ + taskId: TASK_ID, + task, + store, + switchToSession, + loadTaskSessionsForTask, + setActiveTask, + setPreparingTaskId: vi.fn(), + }); + state.tasks.activeTaskId = TASK_ID; + state.tasks.activeSessionId = SESSION_ID; + const abortError = new Error("superseded"); + abortError.name = "AbortError"; + rejectLoad(abortError); + await flushSelection(); + + expect(switchToSession).not.toHaveBeenCalled(); + expect(setActiveTask).not.toHaveBeenCalled(); + expect(replaceTaskUrl).not.toHaveBeenCalledWith(TASK_ID); + }); + } +}); diff --git a/apps/web/components/task/task-session-sidebar.tsx b/apps/web/components/task/task-session-sidebar.tsx index d8d5c9aeb1..faccb0a6b2 100644 --- a/apps/web/components/task/task-session-sidebar.tsx +++ b/apps/web/components/task/task-session-sidebar.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useMemo, useState, memo } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, memo } from "react"; import { useTranslation } from "react-i18next"; import { usePathname, useRouter } from "@/lib/routing/client-router"; import { linkToTask, replaceTaskUrl } from "@/lib/links"; @@ -22,7 +22,11 @@ import { useSidebarSelection, SidebarBulkDialogs } from "./task-session-sidebar- import { useTaskRemoval } from "@/hooks/use-task-removal"; import { findTaskInSnapshots } from "@/lib/kanban/find-task"; import { repositorySlug } from "@/lib/repository-slug"; -import { buildSwitchToSession, selectTaskWithLayout } from "./task-select-helpers"; +import { + buildSwitchToSession, + effectiveTaskPendingAction, + selectTaskWithLayout, +} from "./task-select-helpers"; import { useArchivedTaskState } from "./task-archived-context"; import { useRepositories } from "@/hooks/domains/workspace/use-repositories"; import { useGroupedSidebarView } from "./task-session-sidebar-grouped-view"; @@ -294,6 +298,82 @@ function useDeleteActions( }; } +function useSidebarTaskSelection(params: { + store: StoreApi; + pathname: string | null; + router: ReturnType; + loadTaskSessionsForTask: ReturnType["loadTaskSessionsForTask"]; + setActiveSession: (taskId: string, sessionId: string) => void; + setActiveTask: (taskId: string) => void; + setPreparingTaskId: (taskId: string | null) => void; +}) { + const { + store, + pathname, + router, + loadTaskSessionsForTask, + setActiveSession, + setActiveTask, + setPreparingTaskId, + } = params; + const switchToSession = useMemo( + () => buildSwitchToSession(store, setActiveSession), + [store, setActiveSession], + ); + const selectionControllerRef = useRef(null); + useEffect(() => { + const controller = new AbortController(); + selectionControllerRef.current = controller; + return () => { + controller.abort(); + if (selectionControllerRef.current === controller) selectionControllerRef.current = null; + }; + }, [pathname]); + return useCallback( + (taskId: string) => { + const state = store.getState(); + const task = findSidebarTask(state, taskId); + const onTaskRoute = + !!pathname && (pathname.startsWith("/t/") || pathname.startsWith("/office/tasks/")); + if (!onTaskRoute && (!effectiveTaskPendingAction(task) || task?.isArchived)) { + setActiveTask(taskId); + router.push(linkToTask(taskId)); + return; + } + if (task?.isArchived) { + setActiveTask(taskId); + replaceTaskUrl(taskId); + return; + } + selectTaskWithLayout({ + taskId, + task: task ?? undefined, + store, + switchToSession: onTaskRoute + ? switchToSession + : (selectedTaskId, sessionId) => setActiveSession(selectedTaskId, sessionId), + loadTaskSessionsForTask, + setActiveTask, + setPreparingTaskId, + navigateToTask: onTaskRoute + ? replaceTaskUrl + : (selectedTaskId) => router.push(linkToTask(selectedTaskId)), + selectionSignal: selectionControllerRef.current?.signal, + }); + }, + [ + loadTaskSessionsForTask, + pathname, + router, + setActiveSession, + setActiveTask, + setPreparingTaskId, + store, + switchToSession, + ], + ); +} + export function useSidebarActions(store: StoreApi) { const setActiveTask = useAppStore((state) => state.setActiveTask); const setActiveSession = useAppStore((state) => state.setActiveSession); @@ -306,44 +386,15 @@ export function useSidebarActions(store: StoreApi) { useLayoutSwitch: true, }); - const switchToSession = useMemo( - () => buildSwitchToSession(store, setActiveSession), - [store, setActiveSession], - ); - - const handleSelectTask = useCallback( - (taskId: string) => { - // The AppSidebar is mounted globally. On a non-task route the dockview - // isn't mounted, so the in-place layout switch (which only rewrites the - // URL via history.replaceState) would change the address bar without - // ever showing the task. Navigate to the task page in that case; the - // in-place fast-switch is only correct once the dockview is on screen. - const onTaskRoute = - !!pathname && (pathname.startsWith("/t/") || pathname.startsWith("/office/tasks/")); - if (!onTaskRoute) { - setActiveTask(taskId); - router.push(linkToTask(taskId)); - return; - } - const state = store.getState(); - const task = findSidebarTask(state, taskId); - if (task?.isArchived) { - setActiveTask(taskId); - replaceTaskUrl(taskId); - return; - } - selectTaskWithLayout({ - taskId, - task: task ?? undefined, - store, - switchToSession, - loadTaskSessionsForTask, - setActiveTask, - setPreparingTaskId, - }); - }, - [loadTaskSessionsForTask, switchToSession, setActiveTask, store, router, pathname], - ); + const handleSelectTask = useSidebarTaskSelection({ + store, + pathname, + router, + loadTaskSessionsForTask, + setActiveSession, + setActiveTask, + setPreparingTaskId, + }); const archiveActions = useArchiveActions(store); const deleteActions = useDeleteActions(store, removeTaskFromBoard); diff --git a/apps/web/e2e/helpers/clarification.ts b/apps/web/e2e/helpers/clarification.ts index 621ee45652..401f46ca90 100644 --- a/apps/web/e2e/helpers/clarification.ts +++ b/apps/web/e2e/helpers/clarification.ts @@ -1,7 +1,8 @@ -import { type Page } from "@playwright/test"; +import { expect, type Page } from "@playwright/test"; import type { SeedData } from "../fixtures/test-base"; import type { ApiClient } from "./api-client"; import { SessionPage } from "../pages/session-page"; +import { waitForSessionDone, waitForSessionState } from "./session"; type SeedOptions = { /** Mock-agent scenario slug, e.g. "clarification" or "simple-message". */ @@ -49,3 +50,95 @@ export async function seedClarificationSession( return session; } + +export type SecondaryClarificationTask = { + id: string; + title: string; + primarySessionId: string; + clarificationSessionId: string; +}; + +/** + * Create a task with a clean primary session and a newer secondary session + * blocked on clarification. The original session is restored as primary so + * task navigation must follow pending ownership instead of its normal default. + */ +export async function seedSecondaryClarificationTask( + apiClient: ApiClient, + seedData: SeedData, + title: string, +): Promise { + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + title, + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + executor_profile_id: seedData.worktreeExecutorProfileId, + }, + ); + if (!task.session_id) throw new Error("secondary clarification setup has no primary session"); + + await waitForSessionDone( + apiClient, + task.id, + task.session_id, + "primary session should finish before secondary clarification starts", + 60_000, + ); + const secondary = await apiClient.launchSession( + { + task_id: task.id, + agent_profile_id: seedData.agentProfileId, + executor_profile_id: seedData.worktreeExecutorProfileId, + workflow_step_id: seedData.startStepId, + prompt: "/e2e:clarification", + }, + 60_000, + ); + await waitForSessionState(apiClient, { + taskId: task.id, + sessionId: secondary.session_id, + expectedState: "WAITING_FOR_INPUT", + message: "secondary session should wait on clarification", + timeout: 60_000, + }); + + await apiClient.setPrimarySession(task.session_id); + await expect + .poll(async () => (await apiClient.getTask(task.id)).primary_session_id ?? null, { + message: "clean original session should remain primary", + timeout: 30_000, + }) + .toBe(task.session_id); + await expect + .poll( + async () => { + const { tasks } = await apiClient.listTasks(seedData.workspaceId); + return tasks.find((candidate) => candidate.id === task.id)?.status_summary?.pending_action; + }, + { message: "task summary should advertise secondary clarification", timeout: 30_000 }, + ) + .toBe("clarification"); + + return { + id: task.id, + title, + primarySessionId: task.session_id, + clarificationSessionId: secondary.session_id, + }; +} + +export async function activeSessionId(page: Page): Promise { + return page.evaluate( + () => + ( + window as unknown as { + __KANDEV_E2E_STORE__?: { getState: () => { tasks: { activeSessionId: string | null } } }; + } + ).__KANDEV_E2E_STORE__?.getState().tasks.activeSessionId ?? null, + ); +} diff --git a/apps/web/e2e/tests/task/mobile-sidebar-task-actions.spec.ts b/apps/web/e2e/tests/task/mobile-sidebar-task-actions.spec.ts index fc156037d8..22351403e7 100644 --- a/apps/web/e2e/tests/task/mobile-sidebar-task-actions.spec.ts +++ b/apps/web/e2e/tests/task/mobile-sidebar-task-actions.spec.ts @@ -2,10 +2,55 @@ import { execSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { test, expect } from "../../fixtures/test-base"; +import { activeSessionId, seedSecondaryClarificationTask } from "../../helpers/clarification"; import { makeGitEnv } from "../../helpers/git-helper"; import { SessionPage } from "../../pages/session-page"; test.describe("Mobile sidebar task actions", () => { + test("opens the secondary session that owns clarification from the phone drawer", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(180_000); + const target = await seedSecondaryClarificationTask( + apiClient, + seedData, + "Mobile secondary clarification owner", + ); + const source = await apiClient.createTask(seedData.workspaceId, "Mobile pending owner source", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + + await testPage.goto(`/t/${source.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await testPage.getByTestId("mobile-session-menu").tap(); + const drawer = testPage.getByRole("dialog", { name: "Tasks" }); + const targetRow = drawer.getByTestId("sidebar-task-item").filter({ hasText: target.title }); + await expect(targetRow).toBeVisible(); + await expect(targetRow.getByTestId("task-state-waiting-for-input")).toBeVisible(); + await targetRow.tap(); + + await expect(drawer).toBeHidden(); + await expect(testPage).toHaveURL(new RegExp(`/t/${target.id}$`)); + await expect.poll(() => activeSessionId(testPage)).toBe(target.clarificationSessionId); + await expect(session.clarificationOverlay()).toBeVisible(); + await expect(session.clarificationOverlay()).toContainText( + "Which database should we use for this project?", + ); + await expect + .poll( + () => + testPage.evaluate( + () => document.documentElement.scrollWidth <= document.documentElement.clientWidth, + ), + { message: "pending clarification task should not overflow the phone viewport" }, + ) + .toBe(true); + }); + test("switches to the selected task and its chat from the phone task drawer", async ({ testPage, apiClient, diff --git a/apps/web/e2e/tests/task/sidebar-pending-question.spec.ts b/apps/web/e2e/tests/task/sidebar-pending-question.spec.ts index b69af15cea..08baa35cdb 100644 --- a/apps/web/e2e/tests/task/sidebar-pending-question.spec.ts +++ b/apps/web/e2e/tests/task/sidebar-pending-question.spec.ts @@ -5,8 +5,26 @@ */ import { expect, test } from "../../fixtures/test-base"; import type { ApiClient } from "../../helpers/api-client"; +import { activeSessionId, seedSecondaryClarificationTask } from "../../helpers/clarification"; +import { waitForSessionDone, waitForSessionState } from "../../helpers/session"; import { SessionPage } from "../../pages/session-page"; +type ClarificationMessage = { + type?: string; + content?: string; + metadata?: Record; +}; + +function pendingID(message: ClarificationMessage): string | null { + if (message.type !== "clarification_request") return null; + const value = message.metadata?.pending_id; + return typeof value === "string" ? value : null; +} + +async function clarificationMessages(apiClient: ApiClient, sessionId: string) { + return (await apiClient.listSessionMessages(sessionId)).messages as ClarificationMessage[]; +} + async function waitForSessionWaitingForInput( apiClient: ApiClient, taskId: string, @@ -23,7 +41,195 @@ async function waitForSessionWaitingForInput( .toBe("WAITING_FOR_INPUT"); } +async function waitForSessionTurnsComplete( + apiClient: ApiClient, + sessionId: string, + message: string, +): Promise { + await expect + .poll( + async () => { + const { turns } = await apiClient.listSessionTurns(sessionId); + return turns.length > 0 && turns.every((turn) => Boolean(turn.completed_at)); + }, + { message, timeout: 60_000 }, + ) + .toBe(true); +} + test.describe("Sidebar pending-question indicator without opening the task", () => { + test("older detached clarification stays superseded after a newer bundle is skipped", async ({ + apiClient, + seedData, + testPage, + }) => { + test.setTimeout(180_000); + const title = "Superseded clarification task"; + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + title, + seedData.agentProfileId, + { + description: "/e2e:clarification-timeout", + repository_ids: [seedData.repositoryId], + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }, + ); + if (!task.session_id) throw new Error("supersession setup has no session"); + + let olderPendingID: string | null = null; + await expect + .poll( + async () => { + const messages = await clarificationMessages(apiClient, task.session_id!); + const detached = messages.find( + (message) => + pendingID(message) && + message.metadata?.status === "pending" && + message.metadata?.agent_disconnected === true, + ); + olderPendingID = detached ? pendingID(detached) : null; + return olderPendingID; + }, + { + message: "timed-out clarification should become detached", + timeout: 60_000, + }, + ) + .not.toBeNull(); + await expect + .poll( + async () => { + const messages = await clarificationMessages(apiClient, task.session_id!); + return messages.some((message) => message.content?.includes("Question timed out")); + }, + { + message: "timed-out agent turn should persist its final response", + timeout: 60_000, + }, + ) + .toBe(true); + await waitForSessionTurnsComplete( + apiClient, + task.session_id, + "timed-out agent turn should finish before the next prompt", + ); + await waitForSessionWaitingForInput( + apiClient, + task.id, + "completed timeout turn should leave the session waiting for input", + ); + + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await expect(session.clarificationDeferredNotice()).toBeVisible({ timeout: 30_000 }); + + await apiClient.addUserMessage(task.id, task.session_id, "/e2e:clarification"); + await waitForSessionState(apiClient, { + taskId: task.id, + sessionId: task.session_id, + expectedState: "WAITING_FOR_INPUT", + message: "newer clarification should wait for input", + timeout: 60_000, + }); + + let newerPendingID: string | null = null; + await expect + .poll(async () => { + const ids = (await clarificationMessages(apiClient, task.session_id!)) + .map(pendingID) + .filter((id): id is string => id !== null); + newerPendingID = ids.find((id) => id !== olderPendingID) ?? null; + return new Set(ids).size; + }) + .toBe(2); + expect(newerPendingID).not.toBe(olderPendingID); + + await expect(session.clarificationOverlay()).toBeVisible(); + await session.clarificationSkip().click(); + await expect + .poll(async () => { + const newer = (await clarificationMessages(apiClient, task.session_id!)).find( + (message) => pendingID(message) === newerPendingID, + ); + return newer?.metadata?.status; + }) + .toBe("rejected"); + await waitForSessionTurnsComplete( + apiClient, + task.session_id, + "skipped clarification turn should finish before the next prompt", + ); + + await apiClient.addUserMessage( + task.id, + task.session_id, + 'e2e:message("post-skip turn completed")', + ); + await expect + .poll(async () => { + const { messages } = await apiClient.listSessionMessages(task.session_id!); + return messages.some((message) => message.content?.includes("post-skip turn completed")); + }) + .toBe(true); + await waitForSessionDone( + apiClient, + task.id, + task.session_id, + "post-skip turn should complete before reload", + 60_000, + ); + + await testPage.reload(); + await session.waitForLoad(); + await expect( + session.activeChat().getByText("post-skip turn completed", { exact: true }), + ).toBeVisible(); + const row = session.sidebarTaskItem(title); + await expect(row).toBeVisible(); + await expect( + row.locator( + '[data-testid="task-state-turn-finished"], [data-testid="task-state-workflow-complete"]', + ), + ).toBeVisible(); + await expect(session.clarificationOverlay()).toHaveCount(0); + await expect(row.getByTestId("task-state-waiting-for-input")).toHaveCount(0); + }); + + test("task activation opens the secondary session that owns clarification", async ({ + apiClient, + seedData, + testPage, + }) => { + test.setTimeout(180_000); + const target = await seedSecondaryClarificationTask( + apiClient, + seedData, + "Secondary clarification owner", + ); + const navTask = await apiClient.createTask(seedData.workspaceId, "Secondary owner nav task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + + await testPage.goto(`/t/${navTask.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + const targetRow = session.sidebarTaskItem(target.title); + await expect(targetRow).toBeVisible(); + await expect(targetRow.getByTestId("task-state-waiting-for-input")).toBeVisible(); + await targetRow.click(); + + await expect(testPage).toHaveURL(new RegExp(`/t/${target.id}$`)); + await expect.poll(() => activeSessionId(testPage)).toBe(target.clarificationSessionId); + await expect(session.clarificationOverlay()).toBeVisible(); + await expect(session.clarificationOverlay()).toContainText( + "Which database should we use for this project?", + ); + }); + test("blocked task shows the question icon on a fresh page load; idle task does not", async ({ apiClient, seedData, diff --git a/apps/web/hooks/domains/kanban/use-all-workflow-snapshots-inflight.test.ts b/apps/web/hooks/domains/kanban/use-all-workflow-snapshots-inflight.test.ts index 8f37741aa3..3cf084839e 100644 --- a/apps/web/hooks/domains/kanban/use-all-workflow-snapshots-inflight.test.ts +++ b/apps/web/hooks/domains/kanban/use-all-workflow-snapshots-inflight.test.ts @@ -44,6 +44,7 @@ const STEP_ID = "step-1"; const LIVE_STEP_ID = "step-2"; const PARENT_TASK_ID = "parent-task"; const LIVE_PLACEMENT_TASK_ID = "task-with-live-placement"; +const INVALIDATED_STATUS_TASK_ID = "task-with-invalidated-status"; const mocks = vi.hoisted(() => ({ clearKanbanMulti: vi.fn(), @@ -241,6 +242,72 @@ describe("useAllWorkflowSnapshots in-flight websocket tasks", () => { }); }); +describe("useAllWorkflowSnapshots status invalidation races", () => { + it("keeps a live summary that advanced while an invalidating snapshot was in flight", async () => { + resetState(); + let resolveFetch: (value: unknown) => void = () => {}; + mocks.fetchWorkflowSnapshot.mockReturnValueOnce( + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + setLightweightSnapshot({ + id: INVALIDATED_STATUS_TASK_ID, + workflowStepId: STEP_ID, + title: "Invalidated status", + position: 0, + state: "IN_PROGRESS", + statusSummary: { + revision: 4, + updated_at: "2026-08-13T00:00:04Z", + }, + }); + + renderHook(() => useAllWorkflowSnapshots(WORKSPACE_ID)); + await waitFor(() => + expect(mocks.fetchWorkflowSnapshot).toHaveBeenCalledWith(WORKFLOW_ID, expect.anything()), + ); + + setLightweightSnapshot({ + id: INVALIDATED_STATUS_TASK_ID, + workflowStepId: STEP_ID, + title: "Invalidated status", + position: 0, + state: "IN_PROGRESS", + statusSummary: { + revision: 5, + updated_at: "2026-08-13T00:00:05Z", + }, + }); + resolveFetch({ + steps: [{ id: STEP_ID, name: "Doing", color: null, position: 0 }], + tasks: [ + { + id: INVALIDATED_STATUS_TASK_ID, + workflow_step_id: STEP_ID, + title: "Invalidated status", + position: 0, + state: "IN_PROGRESS", + status_summary_invalidated: true, + }, + ], + }); + + await waitFor(() => expect(mocks.setWorkflowSnapshot).toHaveBeenCalled()); + expect(mocks.setWorkflowSnapshot).toHaveBeenCalledWith( + WORKFLOW_ID, + expect.objectContaining({ + tasks: [ + expect.objectContaining({ + id: INVALIDATED_STATUS_TASK_ID, + statusSummary: expect.objectContaining({ revision: 5 }), + }), + ], + }), + ); + }); +}); + describe("useAllWorkflowSnapshots live task placement", () => { it("keeps a newer live task placement when an older snapshot finishes later", async () => { resetState(); diff --git a/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.test.ts b/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.test.ts index ff3c7e9fd1..eead725ec2 100644 --- a/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.test.ts +++ b/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.test.ts @@ -306,7 +306,11 @@ describe("useAllWorkflowSnapshots — snapshot mapping", () => { */ type SummaryFixture = Record | undefined; -function seedSummaryRace(cached: SummaryFixture, response: SummaryFixture) { +function seedSummaryRace( + cached: SummaryFixture, + response: SummaryFixture, + statusSummaryInvalidated = false, +) { mockState.kanbanMulti.snapshots = { "wf-A": { workflowId: "wf-A", @@ -326,6 +330,7 @@ function seedSummaryRace(cached: SummaryFixture, response: SummaryFixture) { workflow_step_id: "step-1", title: "Task", ...(response ? { status_summary: response } : {}), + ...(statusSummaryInvalidated ? { status_summary_invalidated: true } : {}), }, ], }); @@ -383,4 +388,10 @@ describe("useAllWorkflowSnapshots — status summary revision race", () => { expect((await writtenSummary()).revision).toBe(4); }); + + it("clears an unchanged cached summary when the response explicitly invalidates it", async () => { + seedSummaryRace({ revision: 4, pending_action: "clarification" }, undefined, true); + + expect(await writtenSummary()).toBeUndefined(); + }); }); diff --git a/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.ts b/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.ts index 30a583552a..79eb50d88e 100644 --- a/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.ts +++ b/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.ts @@ -27,10 +27,21 @@ function hasNewerLivePlacement(existing: KanbanTask, fetchStart: KanbanTask | un ); } +function hasNewerLiveStatusSummary( + existing: KanbanTask, + fetchStart: KanbanTask | undefined, +): boolean { + const existingRevision = existing.statusSummary?.revision; + if (existingRevision === undefined) return false; + if (!fetchStart) return true; + return existingRevision > (fetchStart.statusSummary?.revision ?? -1); +} + function mergeFetchedTask( mapped: KanbanTask, existing: KanbanTask, fetchStart: KanbanTask | undefined, + statusSummaryInvalidated: boolean, ): KanbanTask { if (hasNewerLivePlacement(existing, fetchStart)) { // A live task.updated/task.moved event arrived after this request @@ -45,14 +56,18 @@ function mergeFetchedTask( // A response that started before a live WS status update may finish // afterwards, so do not let an older snapshot roll the status // projection back to a lower revision. - const existingRevision = existing.statusSummary?.revision; - const mappedRevision = mapped.statusSummary?.revision; - if (existingRevision !== undefined && existingRevision > (mappedRevision ?? -1)) { - mapped.statusSummary = existing.statusSummary; + if (statusSummaryInvalidated && !hasNewerLiveStatusSummary(existing, fetchStart)) { + mapped.statusSummary = undefined; + } else { + const existingRevision = existing.statusSummary?.revision; + const mappedRevision = mapped.statusSummary?.revision; + if (existingRevision !== undefined && existingRevision > (mappedRevision ?? -1)) { + mapped.statusSummary = existing.statusSummary; + } + // An equal revision can still carry a newer queued-prompt count. Preserve + // the freshest status projection while keeping the revision guard above. + mapped.statusSummary = pickFreshestStatusSummary(mapped.statusSummary, existing.statusSummary); } - // An equal revision can still carry a newer queued-prompt count. Preserve - // the freshest status projection while keeping the revision guard above. - mapped.statusSummary = pickFreshestStatusSummary(mapped.statusSummary, existing.statusSummary); mapped.primarySessionId = mapped.primarySessionId || existing.primarySessionId; mapped.primarySessionState = mapped.primarySessionState || existing.primarySessionState; // Autopilot is immutable after creation. Keep the cached value when @@ -79,7 +94,14 @@ function mergeSnapshotTasks( const mapped = mapSnapshotTask(task, stepIds); if (!mapped) return null; const existing = existingById.get(mapped.id); - return existing ? mergeFetchedTask(mapped, existing, fetchStartById.get(mapped.id)) : mapped; + return existing + ? mergeFetchedTask( + mapped, + existing, + fetchStartById.get(mapped.id), + task.status_summary_invalidated === true, + ) + : mapped; }) .filter((t): t is KanbanTask => t !== null); const snapshotTaskIds = new Set(tasks.map((t) => t.id)); diff --git a/apps/web/hooks/processed-message-filtering.ts b/apps/web/hooks/processed-message-filtering.ts new file mode 100644 index 0000000000..4b54b0ed33 --- /dev/null +++ b/apps/web/hooks/processed-message-filtering.ts @@ -0,0 +1,228 @@ +import { + type ClarificationRequestMetadata, + type Message, + type MessageType, +} from "@/lib/types/http"; +import type { RichMetadata, ToolCallMetadata, TodoSnapshot } from "@/components/task/chat/types"; +import { + findPendingClarification, + isPendingClarificationMessage, + type PendingClarificationScope, +} from "@/lib/utils/pending-clarification"; + +const VISIBLE_MESSAGE_TYPES: Set = new Set([ + "message", + "content", + "tool_call", + "tool_read", + "tool_edit", + "tool_execute", + "tool_search", + "progress", + "status", + "error", + "thinking", + "todo", + "script_execution", + "agent_plan", +]); + +function isVisibleMessageType(type: MessageType | undefined): boolean { + return !type || VISIBLE_MESSAGE_TYPES.has(type); +} + +function isPermissionVisible(message: Message, toolCallIds: Set): boolean { + const metadata = message.metadata as { tool_call_id?: string; status?: string } | undefined; + const toolCallId = metadata?.tool_call_id; + if (toolCallId && toolCallIds.has(toolCallId)) return false; + const status = metadata?.status; + if (status === "approved" || status === "denied" || status === "cancelled") return false; + return true; +} + +export function buildToolCallIds(messages: Message[]): Set { + const set = new Set(); + for (const message of messages) { + if (message.type === "tool_call") { + const toolCallId = (message.metadata as { tool_call_id?: string } | undefined)?.tool_call_id; + if (toolCallId) set.add(toolCallId); + } + } + return set; +} + +export function buildPermissionsByToolCallId(messages: Message[]): Map { + const map = new Map(); + for (const message of messages) { + if (message.type === "permission_request") { + const toolCallId = (message.metadata as { tool_call_id?: string } | undefined)?.tool_call_id; + if (toolCallId) map.set(toolCallId, message); + } + } + return map; +} + +export function buildChildrenByParentToolCallId(messages: Message[]): Map { + const map = new Map(); + for (const message of messages) { + const metadata = message.metadata as ToolCallMetadata | undefined; + const parentId = metadata?.parent_tool_call_id; + if (parentId) { + const children = map.get(parentId) || []; + children.push(message); + map.set(parentId, children); + } + } + return map; +} + +export function buildSubagentChildIds( + childrenByParentToolCallId: Map, +): Set { + const set = new Set(); + for (const children of childrenByParentToolCallId.values()) { + for (const child of children) set.add(child.id); + } + return set; +} + +function isRecoveryMessage(message: Message): boolean { + const metadata = message.metadata as Record | undefined; + return metadata?.recovery_actions === true; +} + +function deduplicateRecoveryMessages(messages: Message[]): Message[] { + let lastRecoveryIndex = -1; + for (let index = messages.length - 1; index >= 0; index--) { + if (isRecoveryMessage(messages[index])) { + lastRecoveryIndex = index; + break; + } + } + if (lastRecoveryIndex === -1) return messages; + const hasLaterActivity = messages + .slice(lastRecoveryIndex + 1) + .some((message) => message.type === "message" || message.type === "content"); + return messages.filter((message, index) => { + if (!isRecoveryMessage(message)) return true; + if (hasLaterActivity) return false; + return index === lastRecoveryIndex; + }); +} + +export function isAgentBootResumeMessage(message: Message): boolean { + if (message.type !== "script_execution") return false; + const metadata = message.metadata as { script_type?: string; is_resuming?: boolean } | undefined; + return metadata?.script_type === "agent_boot" && metadata?.is_resuming === true; +} + +export function isSetupScriptMessage(message: Message): boolean { + if (message.type !== "script_execution") return false; + const metadata = message.metadata as { script_type?: string } | undefined; + return metadata?.script_type === "setup"; +} + +export function deduplicateAgentBootResumes(messages: Message[]): Message[] { + let lastResumeIndex = -1; + for (let index = messages.length - 1; index >= 0; index--) { + if (isAgentBootResumeMessage(messages[index])) { + lastResumeIndex = index; + break; + } + } + if (lastResumeIndex === -1) return messages; + return messages.filter( + (message, index) => !isAgentBootResumeMessage(message) || index === lastResumeIndex, + ); +} + +function findLatestTodoIdsByTurn(messages: Message[]): Map { + const latest = new Map(); + for (const message of messages) { + if (message.type === "todo" && message.turn_id) { + latest.set(message.turn_id, message.id); + } + } + return latest; +} + +function collectPriorSnapshotsByLatestId( + messages: Message[], + latestTodoIdByTurn: Map, +): Map { + const previousByLatestId = new Map(); + for (const message of messages) { + if (message.type !== "todo" || !message.turn_id) continue; + const latestId = latestTodoIdByTurn.get(message.turn_id); + if (!latestId || latestId === message.id) continue; + const snapshot: TodoSnapshot = { + todos: (message.metadata as RichMetadata | undefined)?.todos ?? [], + created_at: message.created_at, + }; + if (!previousByLatestId.has(latestId)) previousByLatestId.set(latestId, []); + previousByLatestId.get(latestId)!.push(snapshot); + } + return previousByLatestId; +} + +export function collapseTodoSnapshotsPerTurn(messages: Message[]): Message[] { + const latestTodoIdByTurn = findLatestTodoIdsByTurn(messages); + if (latestTodoIdByTurn.size === 0) return messages; + const previousByLatestId = collectPriorSnapshotsByLatestId(messages, latestTodoIdByTurn); + return messages.flatMap((message) => { + if (message.type !== "todo" || !message.turn_id) return [message]; + if (latestTodoIdByTurn.get(message.turn_id) !== message.id) return []; + const previous = previousByLatestId.get(message.id); + if (!previous || previous.length === 0) return [message]; + return [{ ...message, metadata: { ...message.metadata, previous_todo_snapshots: previous } }]; + }); +} + +function findActiveClarification( + messages: Message[], + scope?: PendingClarificationScope, +): Message | undefined { + return findPendingClarification(messages, scope) ?? undefined; +} + +function isClarificationVisible( + message: Message, + activeClarification: Message | undefined, +): boolean { + const metadata = message.metadata as ClarificationRequestMetadata | undefined; + if (!isPendingClarificationMessage(message)) return true; + if (activeClarification) { + const activeMetadata = activeClarification.metadata as ClarificationRequestMetadata | undefined; + return activeMetadata?.pending_id + ? metadata?.pending_id !== activeMetadata.pending_id + : message.id !== activeClarification.id; + } + return true; +} + +export function filterVisibleMessages( + messages: Message[], + toolCallIds: Set, + subagentChildIds: Set, + scope?: PendingClarificationScope, +): Message[] { + const activeClarification = findActiveClarification(messages, scope); + const filtered = messages.filter((message) => { + if (subagentChildIds.has(message.id) || isSetupScriptMessage(message)) return false; + if (message.type === "clarification_request") { + return isClarificationVisible(message, activeClarification); + } + if ( + message.type === "status" && + (message.content === "New session started" || message.content === "Session resumed") + ) { + return false; + } + if (isVisibleMessageType(message.type)) return true; + if (message.type === "permission_request") return isPermissionVisible(message, toolCallIds); + return false; + }); + return collapseTodoSnapshotsPerTurn( + deduplicateAgentBootResumes(deduplicateRecoveryMessages(filtered)), + ); +} diff --git a/apps/web/hooks/use-processed-messages.test.ts b/apps/web/hooks/use-processed-messages.test.ts index 3c0d603a49..aa7c119f14 100644 --- a/apps/web/hooks/use-processed-messages.test.ts +++ b/apps/web/hooks/use-processed-messages.test.ts @@ -6,6 +6,8 @@ import { type MessageType, } from "@/lib/types/http"; import type { RichMetadata } from "@/components/task/chat/types"; +import { findPendingClarificationGroup } from "@/lib/utils/pending-clarification"; +import { filterVisibleMessages } from "./processed-message-filtering"; import { buildGroupedRenderItems, buildTodoItems, @@ -20,6 +22,8 @@ import { type RenderItem, } from "./use-processed-messages"; +const CURRENT_TURN_ID = "turn-current"; + function makeMessage( id: string, type: MessageType, @@ -75,6 +79,139 @@ function bootResumed(id: string): Message { }); } +describe("filterVisibleMessages clarification history", () => { + it("uses the newest pending clarification as the active fallback when scope is absent", () => { + const old = makeMessage("fallback-old-question", "clarification_request", { + status: "pending", + }); + const active = makeMessage("fallback-active-question", "clarification_request", { + status: "pending", + }); + + expect( + filterVisibleMessages([old, active], new Set(), new Set()).map( + (message) => message.id, + ), + ).toEqual(["fallback-old-question"]); + }); + + it("keeps a superseded pending clarification as inert transcript history", () => { + const superseded = { + ...makeMessage("old-question", "clarification_request", { + status: "pending", + agent_disconnected: true, + }), + turn_id: "turn-old", + }; + const current = { + ...makeMessage("current-question", "clarification_request", { status: "pending" }), + turn_id: CURRENT_TURN_ID, + }; + + expect( + filterVisibleMessages([superseded, current], new Set(), new Set(), { + currentTurnId: CURRENT_TURN_ID, + }).map((message) => message.id), + ).toEqual(["old-question"]); + }); +}); + +describe("filterVisibleMessages current-turn clarification bundles", () => { + it("hides only the newest pending clarification bundle in the current turn", () => { + const superseded = { + ...makeMessage("same-turn-old-question", "clarification_request", { + status: "pending", + pending_id: "pending-old", + }), + turn_id: CURRENT_TURN_ID, + }; + const active = { + ...makeMessage("same-turn-active-question", "clarification_request", { + status: "pending", + pending_id: "pending-active", + }), + turn_id: CURRENT_TURN_ID, + }; + + expect( + filterVisibleMessages([superseded, active], new Set(), new Set(), { + currentTurnId: CURRENT_TURN_ID, + }).map((message) => message.id), + ).toEqual(["same-turn-old-question"]); + }); +}); + +describe("filterVisibleMessages clarification history without turn authority", () => { + it("keeps malformed pending history visible when durable turn history is empty", () => { + const pending = makeMessage("legacy-question", "clarification_request", { + status: "pending", + }); + + expect( + filterVisibleMessages([pending], new Set(), new Set(), { + currentTurnId: null, + }).map((message) => message.id), + ).toEqual(["legacy-question"]); + expect( + findPendingClarificationGroup([pending], { currentTurnId: null }).map( + (message) => message.id, + ), + ).toEqual([]); + }); + + it.each([null, "permission"] as const)( + "keeps pending metadata as inert history when authority is %s", + (pendingAction) => { + const old = makeMessage("unhydrated-old-question", "clarification_request", { + status: "pending", + agent_disconnected: true, + }); + + expect( + filterVisibleMessages([old], new Set(), new Set(), { + currentTurnId: undefined, + pendingAction, + }).map((message) => message.id), + ).toEqual(["unhydrated-old-question"]); + }, + ); + + it("keeps pending clarification as inert history when explicit authority is unavailable", () => { + const pending = makeMessage("unloaded-question", "clarification_request", { + status: "pending", + }); + + expect( + filterVisibleMessages([pending], new Set(), new Set(), { + currentTurnId: undefined, + pendingAction: undefined, + }).map((message) => message.id), + ).toEqual(["unloaded-question"]); + }); + + it("hides only the active bundle when clarification authority exists before turn hydration", () => { + const old = makeMessage("unhydrated-superseded", "clarification_request", { + status: "pending", + pending_id: "pending-old", + }); + const activeA = makeMessage("unhydrated-active-a", "clarification_request", { + status: "pending", + pending_id: "pending-active", + }); + const activeB = makeMessage("unhydrated-active-b", "clarification_request", { + status: "pending", + pending_id: "pending-active", + }); + + expect( + filterVisibleMessages([old, activeA, activeB], new Set(), new Set(), { + currentTurnId: undefined, + pendingAction: "clarification", + }).map((message) => message.id), + ).toEqual(["unhydrated-superseded"]); + }); +}); + describe("isAgentBootResumeMessage", () => { it("returns true for script_execution agent_boot with is_resuming=true", () => { expect(isAgentBootResumeMessage(bootResumed("r1"))).toBe(true); diff --git a/apps/web/hooks/use-processed-messages.ts b/apps/web/hooks/use-processed-messages.ts index 9038efb8d7..5ee46d6beb 100644 --- a/apps/web/hooks/use-processed-messages.ts +++ b/apps/web/hooks/use-processed-messages.ts @@ -2,17 +2,33 @@ import { useEffect, useMemo, useState } from "react"; import { sessionId as toSessionId, taskId as toTaskId, - type ClarificationRequestMetadata, type Message, type MessageType, + type TaskPendingAction, } from "@/lib/types/http"; -import type { RichMetadata, ToolCallMetadata, TodoSnapshot } from "@/components/task/chat/types"; +import type { ToolCallMetadata } from "@/components/task/chat/types"; import { findPendingClarification, findPendingClarificationGroup, + type PendingClarificationScope, } from "@/lib/utils/pending-clarification"; import { createDebugLogger, isDebug } from "@/lib/debug/log"; import type { LastAgentError } from "@/lib/session-last-agent-error"; +import { + buildChildrenByParentToolCallId, + buildPermissionsByToolCallId, + buildSubagentChildIds, + buildToolCallIds, + filterVisibleMessages, + isSetupScriptMessage, +} from "./processed-message-filtering"; + +export { + collapseTodoSnapshotsPerTurn, + deduplicateAgentBootResumes, + isAgentBootResumeMessage, + isSetupScriptMessage, +} from "./processed-message-filtering"; const debug = createDebugLogger("messages:process"); @@ -61,36 +77,6 @@ const ACTIVITY_MESSAGE_TYPES: Set = new Set([ "tool_search", ]); -const VISIBLE_MESSAGE_TYPES: Set = new Set([ - "message", - "content", - "tool_call", - "tool_read", - "tool_edit", - "tool_execute", - "tool_search", - "progress", - "status", - "error", - "thinking", - "todo", - "script_execution", - "agent_plan", -]); - -function isVisibleMessageType(type: MessageType | undefined): boolean { - return !type || VISIBLE_MESSAGE_TYPES.has(type); -} - -function isPermissionVisible(message: Message, toolCallIds: Set): boolean { - const metadata = message.metadata as { tool_call_id?: string; status?: string } | undefined; - const toolCallId = metadata?.tool_call_id; - if (toolCallId && toolCallIds.has(toolCallId)) return false; - const status = metadata?.status; - if (status === "approved" || status === "denied" || status === "cancelled") return false; - return true; -} - export type TurnGroup = { type: "turn_group"; id: string; @@ -124,52 +110,10 @@ export type GroupedRenderOptions = { export type ProcessedMessagesOptions = { hasOlderMessages?: boolean; lastAgentError?: LastAgentError | null; + currentTurnId?: string | null; + pendingAction?: TaskPendingAction | null; }; -function buildToolCallIds(messages: Message[]): Set { - const set = new Set(); - for (const message of messages) { - if (message.type === "tool_call") { - const toolCallId = (message.metadata as { tool_call_id?: string } | undefined)?.tool_call_id; - if (toolCallId) set.add(toolCallId); - } - } - return set; -} - -function buildPermissionsByToolCallId(messages: Message[]): Map { - const map = new Map(); - for (const message of messages) { - if (message.type === "permission_request") { - const toolCallId = (message.metadata as { tool_call_id?: string } | undefined)?.tool_call_id; - if (toolCallId) map.set(toolCallId, message); - } - } - return map; -} - -function buildChildrenByParentToolCallId(messages: Message[]): Map { - const map = new Map(); - for (const message of messages) { - const metadata = message.metadata as ToolCallMetadata | undefined; - const parentId = metadata?.parent_tool_call_id; - if (parentId) { - const children = map.get(parentId) || []; - children.push(message); - map.set(parentId, children); - } - } - return map; -} - -function buildSubagentChildIds(childrenByParentToolCallId: Map): Set { - const set = new Set(); - for (const children of childrenByParentToolCallId.values()) { - for (const child of children) set.add(child.id); - } - return set; -} - /** True when both maps hold the same keys pointing at the same Message refs. */ export function messageMapsEqualByIdentity( a: Map, @@ -279,146 +223,6 @@ function useStableGroupedItems(next: RenderItem[]): RenderItem[] { return stable; } -function isRecoveryMessage(message: Message): boolean { - const meta = message.metadata as Record | undefined; - return meta?.recovery_actions === true; -} - -/** Hide recovery messages that have been superseded by later conversation activity - * (user/agent messages prove the session recovered) or by a newer recovery message. */ -function deduplicateRecoveryMessages(messages: Message[]): Message[] { - let lastRecoveryIdx = -1; - for (let i = messages.length - 1; i >= 0; i--) { - if (isRecoveryMessage(messages[i])) { - lastRecoveryIdx = i; - break; - } - } - if (lastRecoveryIdx === -1) return messages; - - const hasLaterActivity = messages - .slice(lastRecoveryIdx + 1) - .some((m) => m.type === "message" || m.type === "content"); - - return messages.filter((msg, i) => { - if (!isRecoveryMessage(msg)) return true; - if (hasLaterActivity) return false; - return i === lastRecoveryIdx; - }); -} - -export function isAgentBootResumeMessage(message: Message): boolean { - if (message.type !== "script_execution") return false; - const meta = message.metadata as { script_type?: string; is_resuming?: boolean } | undefined; - return meta?.script_type === "agent_boot" && meta?.is_resuming === true; -} - -/** Per-repo setup scripts (`Repository.setup_script`) run during worktree - * creation and are persisted as `script_execution` rows. They belong - * conceptually to environment preparation, not the chat thread — `PrepareProgress` - * surfaces them as steps inside the env-prep panel. Hiding them from the chat - * prevents the script from rendering above the env-prep panel (which happens - * when the user prompt hasn't been recorded yet, e.g. MCP-auto-started subtasks). */ -export function isSetupScriptMessage(message: Message): boolean { - if (message.type !== "script_execution") return false; - const meta = message.metadata as { script_type?: string } | undefined; - return meta?.script_type === "setup"; -} - -/** A resumed session may produce many "Resumed agent …" boot messages over its - * lifetime (every backend restart emits one). They all convey the same info; - * keep only the most recent and drop the rest — unconditionally, even if user - * messages occurred between them (unlike `deduplicateRecoveryMessages`). The - * underlying DB rows are untouched; this only affects the rendered chat. */ -export function deduplicateAgentBootResumes(messages: Message[]): Message[] { - let lastResumeIdx = -1; - for (let i = messages.length - 1; i >= 0; i--) { - if (isAgentBootResumeMessage(messages[i])) { - lastResumeIdx = i; - break; - } - } - if (lastResumeIdx === -1) return messages; - return messages.filter((msg, i) => !isAgentBootResumeMessage(msg) || i === lastResumeIdx); -} - -function filterVisibleMessages( - messages: Message[], - toolCallIds: Set, - subagentChildIds: Set, -): Message[] { - const filtered = messages.filter((message) => { - if (subagentChildIds.has(message.id)) return false; - if (isSetupScriptMessage(message)) return false; - if (message.type === "clarification_request") { - const metadata = message.metadata as ClarificationRequestMetadata | undefined; - return !(!metadata?.status || metadata.status === "pending"); - } - if ( - message.type === "status" && - (message.content === "New session started" || message.content === "Session resumed") - ) - return false; - if (isVisibleMessageType(message.type)) return true; - if (message.type === "permission_request") return isPermissionVisible(message, toolCallIds); - return false; - }); - - return collapseTodoSnapshotsPerTurn( - deduplicateAgentBootResumes(deduplicateRecoveryMessages(filtered)), - ); -} - -function findLatestTodoIdsByTurn(messages: Message[]): Map { - const latest = new Map(); - for (const message of messages) { - if (message.type === "todo" && message.turn_id) { - latest.set(message.turn_id, message.id); - } - } - return latest; -} - -function collectPriorSnapshotsByLatestId( - messages: Message[], - latestTodoIdByTurn: Map, -): Map { - const previousByLatestId = new Map(); - for (const message of messages) { - if (message.type !== "todo" || !message.turn_id) continue; - const latestId = latestTodoIdByTurn.get(message.turn_id); - if (!latestId || latestId === message.id) continue; - const snapshot: TodoSnapshot = { - todos: (message.metadata as RichMetadata | undefined)?.todos ?? [], - created_at: message.created_at, - }; - if (!previousByLatestId.has(latestId)) previousByLatestId.set(latestId, []); - previousByLatestId.get(latestId)!.push(snapshot); - } - return previousByLatestId; -} - -/** Some agents (Claude Opus/Sonnet) emit a fresh `todo` message every time - * they update their plan, producing a long stack of "Updated Todos" rows in - * the chat. Each todo message is a full snapshot of the list, so all but the - * latest in a turn are strictly stale. Collapse them: keep only the latest - * per `turn_id` and attach the earlier snapshots to its metadata as - * `previous_todo_snapshots` so the UI can show the progression on expand. */ -export function collapseTodoSnapshotsPerTurn(messages: Message[]): Message[] { - const latestTodoIdByTurn = findLatestTodoIdsByTurn(messages); - if (latestTodoIdByTurn.size === 0) return messages; - - const previousByLatestId = collectPriorSnapshotsByLatestId(messages, latestTodoIdByTurn); - - return messages.flatMap((message) => { - if (message.type !== "todo" || !message.turn_id) return [message]; - if (latestTodoIdByTurn.get(message.turn_id) !== message.id) return []; - const previous = previousByLatestId.get(message.id); - if (!previous || previous.length === 0) return [message]; - return [{ ...message, metadata: { ...message.metadata, previous_todo_snapshots: previous } }]; - }); -} - /** A subagent is another agent's turn, not a tool the agent used, so it never * collapses into a turn group. Grouping one hid whole review waves behind a * "4 tool calls, 2 subagents" row far up the transcript; hoisting it keeps the @@ -599,6 +403,40 @@ export function buildTodoItems(visibleMessages: Message[]) { return []; } +function usePendingClarificationState(messages: Message[], options: ProcessedMessagesOptions) { + const hasScopeKeys = "currentTurnId" in options || "pendingAction" in options; + const scope = useMemo( + () => + hasScopeKeys + ? { currentTurnId: options.currentTurnId, pendingAction: options.pendingAction } + : undefined, + [hasScopeKeys, options.currentTurnId, options.pendingAction], + ); + return { + scope, + pendingClarification: useMemo( + () => findPendingClarification(messages, scope), + [messages, scope], + ), + pendingClarificationGroup: useMemo( + () => findPendingClarificationGroup(messages, scope), + [messages, scope], + ), + }; +} + +function useVisibleMessages( + messages: Message[], + toolCallIds: Set, + subagentChildIds: Set, + scope: PendingClarificationScope | undefined, +) { + return useMemo( + () => filterVisibleMessages(messages, toolCallIds, subagentChildIds, scope), + [messages, toolCallIds, subagentChildIds, scope], + ); +} + export function useProcessedMessages( messages: Message[], taskId: string | null, @@ -619,16 +457,12 @@ export function useProcessedMessages( () => buildSubagentChildIds(childrenByParentToolCallId), [childrenByParentToolCallId], ); - const pendingClarification = useMemo(() => findPendingClarification(messages), [messages]); - const pendingClarificationGroup = useMemo( - () => findPendingClarificationGroup(messages), - [messages], + const { scope, pendingClarification, pendingClarificationGroup } = usePendingClarificationState( + messages, + options, ); - const visibleMessages = useMemo( - () => filterVisibleMessages(messages, toolCallIds, subagentChildIds), - [messages, toolCallIds, subagentChildIds], - ); + const visibleMessages = useVisibleMessages(messages, toolCallIds, subagentChildIds, scope); const taskDescriptionMessage: Message | null = useMemo(() => { return taskDescription && visibleMessages.length === 0 diff --git a/apps/web/hooks/use-task-pending-input.test.tsx b/apps/web/hooks/use-task-pending-input.test.tsx index 5fb397263a..8816038fcd 100644 --- a/apps/web/hooks/use-task-pending-input.test.tsx +++ b/apps/web/hooks/use-task-pending-input.test.tsx @@ -7,11 +7,13 @@ import { taskId as toTaskId, type Message, type TaskSession, + type Turn, } from "@/lib/types/http"; import { useSessionPendingInput, useTaskPendingInput } from "./use-task-pending-input"; const PRIMARY_SESSION_ID = "session-primary"; const SECONDARY_SESSION_ID = "session-secondary"; +const BASE_TIMESTAMP = "2026-05-02T00:00:00Z"; function message(overrides: Partial): Message { return { @@ -21,7 +23,7 @@ function message(overrides: Partial): Message { author_type: "agent", content: "", type: "message", - created_at: "2026-05-02T00:00:00Z", + created_at: BASE_TIMESTAMP, ...overrides, }; } @@ -31,8 +33,8 @@ function session(id: string, state: TaskSession["state"]): TaskSession { id: toSessionId(id), task_id: toTaskId("task-1"), state, - started_at: "2026-05-02T00:00:00Z", - updated_at: "2026-05-02T00:00:00Z", + started_at: BASE_TIMESTAMP, + updated_at: BASE_TIMESTAMP, }; } @@ -40,12 +42,28 @@ function sessionWithPendingAction(id: string, action: "clarification" | "permiss return Object.assign(session(id, "WAITING_FOR_INPUT"), { pending_action: action }); } -function wrapper(messagesBySession: Record = {}, sessions: TaskSession[] = []) { +function turn(id: string, sessionId: string, startedAt: string): Turn { + return { + id, + session_id: toSessionId(sessionId), + task_id: toTaskId("task-1"), + started_at: startedAt, + created_at: startedAt, + updated_at: startedAt, + }; +} + +function wrapper( + messagesBySession: Record = {}, + sessions: TaskSession[] = [], + turnsBySession: Record = {}, +) { return function Wrapper({ children }: { children: ReactNode }) { return ( [item.id, item])), }, @@ -169,6 +187,128 @@ describe("useTaskPendingInput", () => { }); }); +describe("useTaskPendingInput pending clarification turn authority", () => { + it("preserves pending clarification while session state authority is still loading", () => { + const olderTurn = turn("turn-older", PRIMARY_SESSION_ID, BASE_TIMESTAMP); + const newestTurn = turn("turn-newest", PRIMARY_SESSION_ID, "2026-05-02T00:01:00Z"); + const { result } = renderHook( + () => useTaskPendingInput(PRIMARY_SESSION_ID, { taskId: "task-1" }), + { + wrapper: wrapper( + { + [PRIMARY_SESSION_ID]: [ + message({ + id: "question-before-session-hydration", + session_id: toSessionId(PRIMARY_SESSION_ID), + turn_id: olderTurn.id, + type: "clarification_request", + metadata: { status: "pending" }, + }), + ], + }, + [], + { [PRIMARY_SESSION_ID]: [olderTurn, newestTurn] }, + ), + }, + ); + + expect(result.current).toEqual({ clarification: true, permission: false }); + }); + + it("ignores a detached pending clarification from an older durable turn", () => { + const oldTurn = turn("turn-old", PRIMARY_SESSION_ID, BASE_TIMESTAMP); + const currentTurn = turn("turn-current", PRIMARY_SESSION_ID, "2026-05-02T00:01:00Z"); + const { result } = renderHook( + () => useTaskPendingInput(PRIMARY_SESSION_ID, { taskId: "task-1" }), + { + wrapper: wrapper( + { + [PRIMARY_SESSION_ID]: [ + message({ + id: "detached-old-question", + session_id: toSessionId(PRIMARY_SESSION_ID), + turn_id: oldTurn.id, + type: "clarification_request", + metadata: { status: "pending", agent_disconnected: true }, + }), + message({ + id: "newer-turn-message", + session_id: toSessionId(PRIMARY_SESSION_ID), + turn_id: currentTurn.id, + }), + ], + }, + [session(PRIMARY_SESSION_ID, "RUNNING")], + { [PRIMARY_SESSION_ID]: [oldTurn, currentTurn] }, + ), + }, + ); + + expect(result.current).toEqual({ clarification: false, permission: false }); + }); +}); + +describe("useTaskPendingInput clean clarification turn authority", () => { + it("suppresses a visible predecessor when the session projection is explicitly clean", () => { + const visiblePredecessor = turn("turn-visible-predecessor", PRIMARY_SESSION_ID, BASE_TIMESTAMP); + const cleanSession = Object.assign(session(PRIMARY_SESSION_ID, "RUNNING"), { + pending_action: null, + }); + const { result } = renderHook( + () => useTaskPendingInput(PRIMARY_SESSION_ID, { taskId: "task-1" }), + { + wrapper: wrapper( + { + [PRIMARY_SESSION_ID]: [ + message({ + id: "stale-predecessor-question", + session_id: toSessionId(PRIMARY_SESSION_ID), + turn_id: visiblePredecessor.id, + type: "clarification_request", + metadata: { status: "pending" }, + }), + ], + }, + [cleanSession], + { [PRIMARY_SESSION_ID]: [visiblePredecessor] }, + ), + }, + ); + + expect(result.current).toEqual({ clarification: false, permission: false }); + }); + + it("quarantines pending clarification history for a terminal primary session", () => { + const completedTurn = turn("turn-completed", PRIMARY_SESSION_ID, BASE_TIMESTAMP); + const { result } = renderHook( + () => + useTaskPendingInput(PRIMARY_SESSION_ID, { + primarySessionState: "COMPLETED", + primarySessionPendingAction: "clarification", + }), + { + wrapper: wrapper( + { + [PRIMARY_SESSION_ID]: [ + message({ + id: "stale-completed-question", + session_id: toSessionId(PRIMARY_SESSION_ID), + turn_id: completedTurn.id, + type: "clarification_request", + metadata: { status: "pending" }, + }), + ], + }, + [], + { [PRIMARY_SESSION_ID]: [completedTurn] }, + ), + }, + ); + + expect(result.current).toEqual({ clarification: false, permission: false }); + }); +}); + describe("useSessionPendingInput", () => { it("returns both flags false when sessionId is null", () => { const { result } = renderHook(() => useSessionPendingInput(null), { wrapper: wrapper() }); @@ -199,4 +339,31 @@ describe("useSessionPendingInput", () => { }); expect(result.current).toEqual({ clarification: false, permission: false }); }); + + it("quarantines pending clarification history for a cancelled session", () => { + const cancelledTurn = turn("turn-cancelled", SECONDARY_SESSION_ID, BASE_TIMESTAMP); + const { result } = renderHook(() => useSessionPendingInput(SECONDARY_SESSION_ID), { + wrapper: wrapper( + { + [SECONDARY_SESSION_ID]: [ + message({ + id: "stale-cancelled-question", + session_id: toSessionId(SECONDARY_SESSION_ID), + turn_id: cancelledTurn.id, + type: "clarification_request", + metadata: { status: "pending" }, + }), + ], + }, + [ + Object.assign(session(SECONDARY_SESSION_ID, "CANCELLED"), { + pending_action: "clarification" as const, + }), + ], + { [SECONDARY_SESSION_ID]: [cancelledTurn] }, + ), + }); + + expect(result.current).toEqual({ clarification: false, permission: false }); + }); }); diff --git a/apps/web/hooks/use-task-pending-input.ts b/apps/web/hooks/use-task-pending-input.ts index 106a866ba8..561117be41 100644 --- a/apps/web/hooks/use-task-pending-input.ts +++ b/apps/web/hooks/use-task-pending-input.ts @@ -1,8 +1,10 @@ import { useAppStore } from "@/components/state-provider"; -import type { Message, TaskPendingAction, TaskSession } from "@/lib/types/http"; +import type { Message, TaskPendingAction, TaskSession, Turn } from "@/lib/types/http"; import { + clarificationTurnIdForSession, hasPendingClarificationForSession, hasPendingPermissionForSession, + type PendingClarificationScope, } from "@/lib/utils/pending-clarification"; import { aggregateTaskPendingInput } from "@/lib/utils/task-pending-input"; @@ -17,6 +19,12 @@ export type PendingInputFallback = { primarySessionPendingAction?: TaskPendingAction | null; }; +type PrimarySessionProjection = { + id: string | null | undefined; + pendingAction: TaskPendingAction | null | undefined; + state: string | null | undefined; +}; + function fallbackFlag( fallback: PendingInputFallback | undefined, action: TaskPendingAction, @@ -33,10 +41,27 @@ function actionFlags(action: TaskPendingAction | null | undefined): PendingInput function loadedSessionFlags( messagesBySession: Record, + turnsBySession: Record, sessionId: string, + sessionState: string | null | undefined, + pendingAction?: TaskPendingAction | null, ): PendingInput { + const currentTurnId = + sessionState == null + ? undefined + : clarificationTurnIdForSession(sessionState, turnsBySession[sessionId]); + // With both authority signals unavailable, preserve the legacy full-message + // scan. Once either loads, explicit turn/action scoping applies. + const clarificationScope: PendingClarificationScope | undefined = + currentTurnId === undefined && pendingAction === undefined + ? undefined + : { currentTurnId, pendingAction }; return { - clarification: hasPendingClarificationForSession(messagesBySession, sessionId), + clarification: hasPendingClarificationForSession( + messagesBySession, + sessionId, + clarificationScope, + ), permission: hasPendingPermissionForSession(messagesBySession, sessionId), }; } @@ -59,8 +84,15 @@ export function useTaskPendingInput( toBitmask( selectTaskPendingFlags( state.messages.bySession, + state.turns.bySession, fallback?.taskId ? state.taskSessionsByTask.itemsByTaskId[fallback.taskId] : undefined, - primarySessionId, + { + id: primarySessionId, + state: primarySessionId ? state.taskSessions.items[primarySessionId]?.state : undefined, + pendingAction: primarySessionId + ? state.taskSessions.items[primarySessionId]?.pending_action + : undefined, + }, fallback, ), ), @@ -74,6 +106,7 @@ function toBitmask(flags: PendingInput): number { function selectFlagsFromLoadedTaskSessions( messagesBySession: Record, + turnsBySession: Record, taskSessions: TaskSession[], fallback: PendingInputFallback | undefined, ): PendingInput { @@ -81,7 +114,13 @@ function selectFlagsFromLoadedTaskSessions( taskSessions, (session) => { if (messagesBySession[session.id] === undefined) return undefined; - return loadedSessionFlags(messagesBySession, session.id); + return loadedSessionFlags( + messagesBySession, + turnsBySession, + session.id, + session.state, + session.pending_action, + ); }, fallback?.taskPendingAction, ); @@ -96,14 +135,23 @@ function selectFlagsFromLoadedTaskSessions( function selectFlagsFromPrimarySession( messagesBySession: Record, - primarySessionId: string | null | undefined, + turnsBySession: Record, + primarySession: PrimarySessionProjection, fallback: PendingInputFallback | undefined, ): PendingInput { const taskSnapshot = actionFlags(fallback?.taskPendingAction); if (taskSnapshot.clarification || taskSnapshot.permission) return taskSnapshot; - if (!primarySessionId) return NONE; - if (messagesBySession[primarySessionId] !== undefined) { - return loadedSessionFlags(messagesBySession, primarySessionId); + if (!primarySession.id) return NONE; + if (messagesBySession[primarySession.id] !== undefined) { + return loadedSessionFlags( + messagesBySession, + turnsBySession, + primarySession.id, + primarySession.state ?? fallback?.primarySessionState, + primarySession.pendingAction !== undefined + ? primarySession.pendingAction + : fallback?.primarySessionPendingAction, + ); } return { clarification: fallbackFlag(fallback, "clarification"), @@ -113,14 +161,20 @@ function selectFlagsFromPrimarySession( function selectTaskPendingFlags( messagesBySession: Record, + turnsBySession: Record, taskSessions: TaskSession[] | undefined, - primarySessionId: string | null | undefined, + primarySession: PrimarySessionProjection, fallback: PendingInputFallback | undefined, ): PendingInput { if (taskSessions?.length) { - return selectFlagsFromLoadedTaskSessions(messagesBySession, taskSessions, fallback); + return selectFlagsFromLoadedTaskSessions( + messagesBySession, + turnsBySession, + taskSessions, + fallback, + ); } - return selectFlagsFromPrimarySession(messagesBySession, primarySessionId, fallback); + return selectFlagsFromPrimarySession(messagesBySession, turnsBySession, primarySession, fallback); } /** @@ -132,7 +186,15 @@ export function useSessionPendingInput(sessionId: string | null | undefined): Pe const flags = useAppStore((state) => { if (!sessionId) return 0; if (state.messages.bySession[sessionId] !== undefined) { - return toBitmask(loadedSessionFlags(state.messages.bySession, sessionId)); + return toBitmask( + loadedSessionFlags( + state.messages.bySession, + state.turns.bySession, + sessionId, + state.taskSessions?.items?.[sessionId]?.state, + state.taskSessions?.items?.[sessionId]?.pending_action, + ), + ); } return toBitmask(actionFlags(state.taskSessions?.items?.[sessionId]?.pending_action)); }); diff --git a/apps/web/hooks/use-task-removal-session-loading.test.ts b/apps/web/hooks/use-task-removal-session-loading.test.ts new file mode 100644 index 0000000000..9d044c2dd5 --- /dev/null +++ b/apps/web/hooks/use-task-removal-session-loading.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, onTestFinished, vi } from "vitest"; +import { renderHook } from "@testing-library/react"; +import type { StoreApi } from "zustand"; +import type { AppState } from "@/lib/state/store"; +import type { TaskSession } from "@/lib/types/http"; + +const listTaskSessionsMock = vi.fn(); +const TASK_ID = "task-cached"; + +vi.mock("@/lib/api", () => ({ + fetchTask: vi.fn(), + listTaskSessions: (...args: unknown[]) => listTaskSessionsMock(...args), +})); + +import { useTaskRemoval } from "./use-task-removal"; + +function makeSession(id: string): TaskSession { + return { + id, + task_id: TASK_ID, + task_environment_id: `${id}-environment`, + state: "WAITING_FOR_INPUT", + started_at: "2026-08-15T15:00:00Z", + updated_at: "2026-08-15T15:00:00Z", + } as TaskSession; +} + +function makeStore(cachedSession: TaskSession): StoreApi { + const state = { + taskSessionsByTask: { + itemsByTaskId: { [TASK_ID]: [cachedSession] }, + loadedByTaskId: { [TASK_ID]: true }, + loadingByTaskId: {}, + }, + setTaskSessionsLoading: vi.fn(), + setTaskSessionsForTask: vi.fn(), + } as unknown as AppState; + return { + getState: () => state, + setState: vi.fn(), + subscribe: vi.fn(), + } as unknown as StoreApi; +} + +describe("useTaskRemoval session loading", () => { + beforeEach(() => vi.clearAllMocks()); + + it("force-refreshes a previously loaded task session list", async () => { + const freshSession = makeSession("fresh-session"); + listTaskSessionsMock.mockResolvedValue({ sessions: [freshSession] }); + const { result } = renderHook(() => + useTaskRemoval({ store: makeStore(makeSession("cached-session")) }), + ); + + const sessions = await result.current.loadTaskSessionsForTask(TASK_ID, { force: true }); + + expect(listTaskSessionsMock).toHaveBeenCalledWith(TASK_ID, { cache: "no-store" }); + expect(sessions).toEqual([freshSession]); + }); + + it("rejects a failed forced refresh instead of returning a stale owner", async () => { + listTaskSessionsMock.mockRejectedValue(new Error("offline")); + const { result } = renderHook(() => + useTaskRemoval({ store: makeStore(makeSession("cached-session")) }), + ); + + await expect(result.current.loadTaskSessionsForTask(TASK_ID, { force: true })).rejects.toThrow( + "offline", + ); + }); + + it("rejects an older forced response after a newer snapshot wins", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + onTestFinished(() => consoleError.mockRestore()); + type SessionResponse = { sessions: TaskSession[] }; + let resolveOlder: (response: SessionResponse) => void = () => {}; + let resolveNewer: (response: SessionResponse) => void = () => {}; + const olderResponse = new Promise((resolve) => { + resolveOlder = resolve; + }); + const newerResponse = new Promise((resolve) => { + resolveNewer = resolve; + }); + listTaskSessionsMock.mockReturnValueOnce(olderResponse).mockReturnValueOnce(newerResponse); + const store = makeStore(makeSession("cached-session")); + const { result } = renderHook(() => useTaskRemoval({ store })); + + const olderLoad = result.current.loadTaskSessionsForTask(TASK_ID, { force: true }); + const newerLoad = result.current.loadTaskSessionsForTask(TASK_ID, { force: true }); + const newerSession = makeSession("newer-session"); + resolveNewer({ sessions: [newerSession] }); + await expect(newerLoad).resolves.toEqual([newerSession]); + resolveOlder({ sessions: [makeSession("older-session")] }); + await expect(olderLoad).rejects.toMatchObject({ name: "AbortError" }); + expect(consoleError).not.toHaveBeenCalled(); + + expect(store.getState().setTaskSessionsForTask).toHaveBeenCalledTimes(1); + expect(store.getState().setTaskSessionsForTask).toHaveBeenCalledWith(TASK_ID, [newerSession]); + expect(store.getState().setTaskSessionsLoading).toHaveBeenNthCalledWith(1, TASK_ID, true); + expect(store.getState().setTaskSessionsLoading).toHaveBeenNthCalledWith(2, TASK_ID, true); + expect(store.getState().setTaskSessionsLoading).toHaveBeenNthCalledWith(3, TASK_ID, false); + expect(store.getState().setTaskSessionsLoading).toHaveBeenCalledTimes(3); + }); +}); diff --git a/apps/web/hooks/use-task-removal.ts b/apps/web/hooks/use-task-removal.ts index 12c8b590c6..5116ca5751 100644 --- a/apps/web/hooks/use-task-removal.ts +++ b/apps/web/hooks/use-task-removal.ts @@ -7,6 +7,7 @@ import { linkToTaskOverview, replaceTaskUrl } from "@/lib/links"; import { fetchTask, listTaskSessions } from "@/lib/api"; import { performLayoutSwitch } from "@/lib/state/dockview-store"; import { getRecentTasks } from "@/lib/recent-tasks"; +import { createAbortError, isAbortError } from "@/lib/utils/abort-error"; type TaskRemovalOptions = { store: StoreApi; @@ -14,6 +15,13 @@ type TaskRemovalOptions = { useLayoutSwitch?: boolean; }; +export type TaskSessionLoadOptions = { + /** Ignore the local task-session cache and request an authoritative snapshot. */ + force?: boolean; + /** Cancel the authoritative request when its task selection is superseded. */ + signal?: AbortSignal; +}; + type RemoveFromBoardOptions = { /** * The active task ID captured **before** the async delete/archive API call. @@ -39,33 +47,83 @@ type RemoveFromBoardResult = { excludedTaskIds?: ReadonlySet; }; +const taskSessionLoadGenerations = new WeakMap, Map>(); + +function beginTaskSessionLoad(store: StoreApi, taskId: string): number { + let generations = taskSessionLoadGenerations.get(store); + if (!generations) { + generations = new Map(); + taskSessionLoadGenerations.set(store, generations); + } + const generation = (generations.get(taskId) ?? 0) + 1; + generations.set(taskId, generation); + return generation; +} + +function taskSessionLoadIsCurrent( + store: StoreApi, + taskId: string, + generation: number, +): boolean { + return taskSessionLoadGenerations.get(store)?.get(taskId) === generation; +} + function cachedSessionsHaveEnvIds(sessions: TaskSession[]): boolean { return sessions.length === 0 || sessions.every((session) => !!session.task_environment_id); } +function taskSessionListRequestOptions(signal?: AbortSignal) { + return signal ? { cache: "no-store" as const, init: { signal } } : { cache: "no-store" as const }; +} + +function commitTaskSessionLoad( + store: StoreApi, + taskId: string, + generation: number, + sessions: TaskSession[], + force: boolean, +): TaskSession[] { + if (taskSessionLoadIsCurrent(store, taskId, generation)) { + store.getState().setTaskSessionsForTask(taskId, sessions); + return sessions; + } + // Forced callers use this result to choose a pending-action owner. Never + // let a superseded response escape even though its cache write was gated. + if (force) { + throw createAbortError("Task session load was superseded"); + } + return store.getState().taskSessionsByTask.itemsByTaskId[taskId] ?? []; +} + async function loadTaskSessionsForTaskFromStore( store: StoreApi, taskId: string, + options?: TaskSessionLoadOptions, ): Promise { const state = store.getState(); + const force = options?.force === true; + const signal = options?.signal; const cachedSessions = state.taskSessionsByTask.itemsByTaskId[taskId] ?? []; - if (state.taskSessionsByTask.loadedByTaskId[taskId]) { + if (!force && state.taskSessionsByTask.loadedByTaskId[taskId]) { if (cachedSessionsHaveEnvIds(cachedSessions)) return cachedSessions; } - if (state.taskSessionsByTask.loadingByTaskId[taskId]) { + if (!force && state.taskSessionsByTask.loadingByTaskId[taskId]) { return cachedSessions; } + const loadGeneration = beginTaskSessionLoad(store, taskId); store.getState().setTaskSessionsLoading(taskId, true); try { - const response = await listTaskSessions(taskId, { cache: "no-store" }); - store.getState().setTaskSessionsForTask(taskId, response.sessions ?? []); - return response.sessions ?? []; + const response = await listTaskSessions(taskId, taskSessionListRequestOptions(signal)); + const sessions = response.sessions ?? []; + return commitTaskSessionLoad(store, taskId, loadGeneration, sessions, force); } catch (error) { - console.error("Failed to load task sessions:", error); - store.getState().setTaskSessionsForTask(taskId, []); - return []; + if (!isAbortError(error)) console.error("Failed to load task sessions:", error); + if (force) throw error; + return cachedSessions; } finally { - store.getState().setTaskSessionsLoading(taskId, false); + if (taskSessionLoadIsCurrent(store, taskId, loadGeneration)) { + store.getState().setTaskSessionsLoading(taskId, false); + } } } @@ -293,7 +351,8 @@ function shouldSwitchAfterRemoval( */ export function useTaskRemoval({ store, useLayoutSwitch = false }: TaskRemovalOptions) { const loadTaskSessionsForTask = useCallback( - (taskId: string) => loadTaskSessionsForTaskFromStore(store, taskId), + (taskId: string, options?: TaskSessionLoadOptions) => + loadTaskSessionsForTaskFromStore(store, taskId, options), [store], ); diff --git a/apps/web/lib/kanban/map-task.ts b/apps/web/lib/kanban/map-task.ts index 695e941fa3..0b17931ae2 100644 --- a/apps/web/lib/kanban/map-task.ts +++ b/apps/web/lib/kanban/map-task.ts @@ -77,6 +77,7 @@ export type TaskLike = { metadata?: Record | null; archived_at?: string | null; status_summary?: TaskStatusSummary | null; + status_summary_invalidated?: boolean; }; export type WorkspaceMode = "inherit_parent" | "new_workspace" | "shared_group"; diff --git a/apps/web/lib/state/slices/session/session-slice.ts b/apps/web/lib/state/slices/session/session-slice.ts index 0addf29c05..a171cf5e1b 100644 --- a/apps/web/lib/state/slices/session/session-slice.ts +++ b/apps/web/lib/state/slices/session/session-slice.ts @@ -3,6 +3,10 @@ import { original } from "immer"; import type { Message, TaskSession } from "@/lib/types/http"; import type { SessionSlice, SessionSliceState } from "./types"; import { buildTurnActions } from "./turn-actions"; +import { + buildTaskSessionProjectionActions, + mergePendingActionProjection, +} from "./task-session-projection-actions"; import { reconcileMessages } from "./message-signature"; import { migrateEnvKeyedData, @@ -135,10 +139,12 @@ function mergeCancellationProjection( /** Merge an incoming session update with an existing session, preserving nullable fields. */ function mergeTaskSession(existing: TaskSession, incoming: TaskSession): TaskSession { const cancellation = mergeCancellationProjection(existing, incoming); + const pendingAction = mergePendingActionProjection(existing, incoming); return { ...existing, ...incoming, ...cancellation, + ...pendingAction, agent_profile_snapshot: incoming.agent_profile_snapshot ?? existing.agent_profile_snapshot, worktree_id: incoming.worktree_id ?? existing.worktree_id, worktree_path: incoming.worktree_path ?? existing.worktree_path, @@ -607,6 +613,7 @@ export const createSessionSlice: StateCreator< ...buildMessageActions(set), ...buildTurnActions(set), ...buildTaskSessionActions(set), + ...buildTaskSessionProjectionActions(set), setSessionAgentctlStatus: (sessionId, status) => set((draft) => { draft.sessionAgentctl.itemsBySessionId[sessionId] = status; diff --git a/apps/web/lib/state/slices/session/task-session-projection-actions.ts b/apps/web/lib/state/slices/session/task-session-projection-actions.ts new file mode 100644 index 0000000000..2cc3a62c06 --- /dev/null +++ b/apps/web/lib/state/slices/session/task-session-projection-actions.ts @@ -0,0 +1,82 @@ +import type { StateCreator } from "zustand"; +import type { TaskSession } from "@/lib/types/http"; +import type { SessionSlice } from "./types"; + +type ImmerSet = Parameters< + StateCreator +>[0]; + +type PendingActionProjection = Pick; + +function parseEpoch(epoch: string): bigint | null { + if (!/^\d+$/.test(epoch)) return null; + return BigInt(epoch); +} + +function revisionMayReplaceExisting( + incoming: TaskSession["pending_action_revision"], + existing: TaskSession["pending_action_revision"], +): incoming is NonNullable { + if (!incoming) return false; + const incomingEpoch = parseEpoch(incoming.epoch); + if (incomingEpoch === null) return false; + if (!existing) return true; + const existingEpoch = parseEpoch(existing.epoch); + if (existingEpoch === null || incomingEpoch > existingEpoch) return true; + if (incomingEpoch < existingEpoch) return false; + return incoming.sequence >= existing.sequence; +} + +export function mergePendingActionProjection( + existing: PendingActionProjection, + incoming: PendingActionProjection, +): PendingActionProjection { + const incomingRevision = incoming.pending_action_revision; + const existingRevision = existing.pending_action_revision; + if (revisionMayReplaceExisting(incomingRevision, existingRevision)) { + return { + pending_action: + incoming.pending_action === undefined ? existing.pending_action : incoming.pending_action, + pending_action_revision: incomingRevision, + }; + } + if (incomingRevision || existingRevision) { + return { + pending_action: existing.pending_action, + pending_action_revision: existingRevision, + }; + } + return { + pending_action: + incoming.pending_action === undefined ? existing.pending_action : incoming.pending_action, + pending_action_revision: undefined, + }; +} + +export function buildTaskSessionProjectionActions(set: ImmerSet) { + return { + setTaskSessionPendingAction: ( + sessionId: string, + pendingAction: Parameters[1], + revision: Parameters[2], + ) => + set((draft) => { + const session = draft.taskSessions.items[sessionId]; + if (!session) return; + const projection = mergePendingActionProjection(session, { + pending_action: pendingAction, + pending_action_revision: revision, + }); + session.pending_action = projection.pending_action; + session.pending_action_revision = projection.pending_action_revision; + const sessionsByTask = draft.taskSessionsByTask.itemsByTaskId[session.task_id]; + if (sessionsByTask) { + const match = sessionsByTask.find((item) => item.id === sessionId); + if (match) { + match.pending_action = projection.pending_action; + match.pending_action_revision = projection.pending_action_revision; + } + } + }), + }; +} diff --git a/apps/web/lib/state/slices/session/types.ts b/apps/web/lib/state/slices/session/types.ts index d62e8d955e..3eaf224ea2 100644 --- a/apps/web/lib/state/slices/session/types.ts +++ b/apps/web/lib/state/slices/session/types.ts @@ -1,5 +1,7 @@ import type { Message, + TaskPendingAction, + TaskPendingActionRevision, TaskSession, Turn, TaskPlan, @@ -224,6 +226,11 @@ export type SessionSliceActions = { * the session isn't in the store (never creates a bare session record). */ updateSessionReadCursor: (sessionId: string, lastReadMessageId: string) => void; + setTaskSessionPendingAction: ( + sessionId: string, + pendingAction: TaskPendingAction | null, + revision?: TaskPendingActionRevision, + ) => void; removeTaskSession: (taskId: string, sessionId: string) => void; setTaskSessionsForTask: (taskId: string, sessions: TaskSession[]) => void; upsertTaskSessionFromEvent: (taskId: string, session: TaskSession) => void; diff --git a/apps/web/lib/state/slices/session/update-session-read-cursor.test.ts b/apps/web/lib/state/slices/session/update-session-read-cursor.test.ts index 46be2e34bc..f9e4cc2d9a 100644 --- a/apps/web/lib/state/slices/session/update-session-read-cursor.test.ts +++ b/apps/web/lib/state/slices/session/update-session-read-cursor.test.ts @@ -23,6 +23,11 @@ function makeStore() { const TASK_ID = toTaskId("task-1"); const SESSION_ID = toSessionId("session-1"); const TS = "2026-04-20T00:00:00Z"; +const REVISION_EPOCH = "7"; + +function pendingRevision(sequence: number) { + return { epoch: REVISION_EPOCH, sequence }; +} function makeSession(overrides: Partial = {}): TaskSession { return { @@ -94,3 +99,115 @@ describe("updateSessionReadCursor", () => { expect(listed?.last_read_message_id).toBe("m2"); }); }); + +describe("setTaskSessionPendingAction", () => { + it("updates the by-id and per-task projections without replacing session state", () => { + const store = makeStore(); + const session = makeSession({ + pending_action: null, + pending_action_revision: pendingRevision(1), + }); + store.setState((draft) => { + draft.taskSessions.items[SESSION_ID] = session; + draft.taskSessionsByTask.itemsByTaskId[TASK_ID] = [session]; + }); + + store.getState().setTaskSessionPendingAction(SESSION_ID, "clarification", pendingRevision(2)); + + expect(store.getState().taskSessions.items[SESSION_ID]).toMatchObject({ + state: "RUNNING", + pending_action: "clarification", + pending_action_revision: pendingRevision(2), + }); + expect(store.getState().taskSessionsByTask.itemsByTaskId[TASK_ID]?.[0].pending_action).toBe( + "clarification", + ); + }); + + it("does not create a bare session when the projection is not loaded", () => { + const store = makeStore(); + + store.getState().setTaskSessionPendingAction(SESSION_ID, "clarification"); + + expect(store.getState().taskSessions.items[SESSION_ID]).toBeUndefined(); + }); + + it("preserves a newer WebSocket projection when a deferred list response resolves", () => { + const store = makeStore(); + const staleListSession = makeSession({ + pending_action: null, + pending_action_revision: pendingRevision(1), + }); + store.getState().setTaskSessionsForTask(TASK_ID, [staleListSession]); + + store.getState().setTaskSessionPendingAction(SESSION_ID, "clarification", pendingRevision(2)); + store.getState().setTaskSessionsForTask(TASK_ID, [{ ...staleListSession, state: "COMPLETED" }]); + + expect(store.getState().taskSessions.items[SESSION_ID]).toMatchObject({ + state: "COMPLETED", + pending_action: "clarification", + pending_action_revision: pendingRevision(2), + }); + }); + + it("rejects a delayed WebSocket projection older than the HTTP snapshot", () => { + const store = makeStore(); + store.getState().setTaskSessionsForTask(TASK_ID, [ + makeSession({ + pending_action: "clarification", + pending_action_revision: pendingRevision(2), + }), + ]); + + store.getState().setTaskSessionPendingAction(SESSION_ID, null, pendingRevision(1)); + + expect(store.getState().taskSessions.items[SESSION_ID]).toMatchObject({ + pending_action: "clarification", + pending_action_revision: pendingRevision(2), + }); + }); + + it("accepts a newer backend epoch and rejects delayed frames from the old epoch", () => { + const store = makeStore(); + // Epochs are parsed as bigint, so decimal width changes at restart 10 do + // not turn this into lexicographic ordering. + const oldRevision = { epoch: "9", sequence: 99 }; + const newRevision = { epoch: "10", sequence: 1 }; + store + .getState() + .setTaskSessionsForTask(TASK_ID, [ + makeSession({ pending_action: "clarification", pending_action_revision: oldRevision }), + ]); + + store.getState().setTaskSessionPendingAction(SESSION_ID, null, newRevision); + store.getState().setTaskSessionPendingAction(SESSION_ID, "permission", { + ...oldRevision, + sequence: 100, + }); + + expect(store.getState().taskSessions.items[SESSION_ID]).toMatchObject({ + pending_action: null, + pending_action_revision: newRevision, + }); + }); + + it("rejects an unseen older backend epoch after client state is rebuilt", () => { + const store = makeStore(); + const currentRevision = { epoch: "3", sequence: 1 }; + store + .getState() + .setTaskSessionsForTask(TASK_ID, [ + makeSession({ pending_action: null, pending_action_revision: currentRevision }), + ]); + + store.getState().setTaskSessionPendingAction(SESSION_ID, "clarification", { + epoch: "1", + sequence: 99, + }); + + expect(store.getState().taskSessions.items[SESSION_ID]).toMatchObject({ + pending_action: null, + pending_action_revision: currentRevision, + }); + }); +}); diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index 2657e25965..147d784d5a 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -6,6 +6,8 @@ import type { Branch, RepositoryScript, Message, + TaskPendingAction, + TaskPendingActionRevision, Turn, TaskSession, TaskWalkthrough, @@ -402,6 +404,11 @@ export type AppState = KanbanSlice & { setMessagesLoading: (sessionId: string, loading: boolean) => void; setTaskSession: (session: TaskSession) => void; updateSessionReadCursor: (sessionId: string, lastReadMessageId: string) => void; + setTaskSessionPendingAction: ( + sessionId: string, + pendingAction: TaskPendingAction | null, + revision?: TaskPendingActionRevision, + ) => void; removeTaskSession: (taskId: string, sessionId: string) => void; setTaskSessionsForTask: (taskId: string, sessions: TaskSession[]) => void; upsertTaskSessionFromEvent: (taskId: string, session: TaskSession) => void; diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index 6454ce2a8b..f6e44a8da5 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -186,6 +186,11 @@ export type TaskSessionState = export type TaskPendingAction = "clarification" | "permission"; +export type TaskPendingActionRevision = { + epoch: string; + sequence: number; +}; + /** * Fine-grained busy substate of a session (see ADR-0049). Distinguishes * a foreground turn that is actively generating from one that is idle, held open @@ -375,6 +380,8 @@ export type Task = ActiveSubagentCountFields & { // isFromOfficeProjection in the Go task repo for the canonical rule. is_from_office?: boolean; status_summary?: TaskStatusSummary | null; + /** Explicitly clears a cached status summary. Omission keeps partial-response semantics. */ + status_summary_invalidated?: boolean; }; // Task origin values mirror models.TaskOrigin* constants in the Go backend. @@ -466,6 +473,8 @@ export type TaskSession = ActiveSubagentCountFields & { supports_steering?: boolean; /** Compact pending-input projection used when this session's messages are unloaded. */ pending_action?: TaskPendingAction | null; + /** Cross-channel logical clock for pending_action snapshots. */ + pending_action_revision?: TaskPendingActionRevision; error_message?: string; metadata?: Record | null; agent_profile_snapshot?: Record | null; diff --git a/apps/web/lib/types/session-events.ts b/apps/web/lib/types/session-events.ts index 24646f19a3..5c0a8a57f8 100644 --- a/apps/web/lib/types/session-events.ts +++ b/apps/web/lib/types/session-events.ts @@ -2,6 +2,7 @@ import type { BackendMessage } from "./backend-message"; import type { QueuedMessage } from "@/lib/state/slices/session/types"; import type { FileChangeNotificationPayload } from "./workspace-files"; import type { ForegroundActivity } from "./activity"; +import type { TaskPendingAction, TaskPendingActionRevision } from "./http"; import type { AgentCapabilitiesPayload, SessionInfoPayload, @@ -26,6 +27,10 @@ export type MessageAddedPayload = { requests_input?: boolean; created_at: string; updated_at?: string; + /** Authoritative per-session input projection after this semantic message mutation. */ + pending_action?: TaskPendingAction | null; + /** Logical clock shared with REST session snapshots. */ + pending_action_revision?: TaskPendingActionRevision; }; export type TaskSessionStateChangedPayload = { diff --git a/apps/web/lib/utils/abort-error.test.ts b/apps/web/lib/utils/abort-error.test.ts new file mode 100644 index 0000000000..bbf7de9415 --- /dev/null +++ b/apps/web/lib/utils/abort-error.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { createAbortError, isAbortError } from "./abort-error"; + +describe("abort error helpers", () => { + it("creates and recognizes the shared abort marker", () => { + const error = createAbortError("superseded"); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe("superseded"); + expect(error.name).toBe("AbortError"); + expect(isAbortError(error)).toBe(true); + }); + + it("recognizes cross-realm abort-shaped errors", () => { + expect(isAbortError({ name: "AbortError" })).toBe(true); + expect(isAbortError(new Error("ordinary"))).toBe(false); + expect(isAbortError(null)).toBe(false); + }); +}); diff --git a/apps/web/lib/utils/abort-error.ts b/apps/web/lib/utils/abort-error.ts new file mode 100644 index 0000000000..1ed121cf0a --- /dev/null +++ b/apps/web/lib/utils/abort-error.ts @@ -0,0 +1,11 @@ +export function createAbortError(message: string): Error { + const error = new Error(message); + error.name = "AbortError"; + return error; +} + +export function isAbortError(error: unknown): boolean { + return ( + typeof error === "object" && error !== null && "name" in error && error.name === "AbortError" + ); +} diff --git a/apps/web/lib/utils/pending-clarification-ordering.test.ts b/apps/web/lib/utils/pending-clarification-ordering.test.ts new file mode 100644 index 0000000000..b1b44a9e20 --- /dev/null +++ b/apps/web/lib/utils/pending-clarification-ordering.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { sessionId as toSessionId, taskId as toTaskId, type Message } from "@/lib/types/http"; +import { hasPendingClarification } from "./pending-clarification"; + +function message(overrides: Partial): Message { + return { + id: "message", + session_id: toSessionId("session-1"), + task_id: toTaskId("task-1"), + author_type: "agent", + content: "", + type: "message", + created_at: "2026-05-02T00:00:00Z", + ...overrides, + }; +} + +describe("pending clarification message ordering", () => { + it("keeps a current-turn request visible after a delayed older-turn event", () => { + const active = message({ + id: "active", + turn_id: "t2", + type: "clarification_request", + metadata: { status: "pending" }, + created_at: "2026-05-02T00:00:02Z", + }); + const delayedOldEvent = message({ + id: "delayed-old-event", + turn_id: "t1", + created_at: "2026-05-02T00:00:01Z", + }); + + expect(hasPendingClarification([active, delayedOldEvent])).toBe(true); + }); +}); diff --git a/apps/web/lib/utils/pending-clarification.test.ts b/apps/web/lib/utils/pending-clarification.test.ts index 73c279cb2e..e0d517fd9e 100644 --- a/apps/web/lib/utils/pending-clarification.test.ts +++ b/apps/web/lib/utils/pending-clarification.test.ts @@ -1,11 +1,23 @@ import { describe, expect, it } from "vitest"; -import { sessionId as toSessionId, taskId as toTaskId, type Message } from "@/lib/types/http"; import { + sessionId as toSessionId, + taskId as toTaskId, + type Message, + type Turn, +} from "@/lib/types/http"; +import { + clarificationTurnIdForSession, + findPendingClarification, findPendingClarificationGroup, hasPendingClarification, hasPendingPermissionRequest, + newestDurableTurnId, } from "./pending-clarification"; +const CURRENT_TURN_ID = "turn-new"; +const OLD_PENDING_ID = "pending-old"; +const TURN_TIMESTAMP = "2026-08-14T12:00:00Z"; + function message(overrides: Partial): Message { return { id: "msg-1", @@ -19,6 +31,17 @@ function message(overrides: Partial): Message { }; } +function turn(id: string, startedAt = TURN_TIMESTAMP, createdAt = TURN_TIMESTAMP): Turn { + return { + id, + session_id: toSessionId("session-1"), + task_id: toTaskId("task-1"), + started_at: startedAt, + created_at: createdAt, + updated_at: createdAt, + }; +} + describe("hasPendingClarification", () => { it("detects clarification requests with pending status", () => { expect( @@ -67,6 +90,60 @@ describe("hasPendingClarification", () => { }); }); +describe("hasPendingClarification turn scoping", () => { + it("ignores a detached pending clarification from a previous turn", () => { + expect( + hasPendingClarification([ + message({ + id: "old-detached", + turn_id: "t1", + type: "clarification_request", + metadata: { status: "pending", agent_disconnected: true }, + }), + message({ + id: "current", + turn_id: "t2", + type: "message", + created_at: "2026-05-02T00:00:01Z", + }), + ]), + ).toBe(false); + }); + + it("detects a pending clarification in the current turn", () => { + expect( + hasPendingClarification([ + message({ + id: "stale", + turn_id: "t1", + type: "clarification_request", + metadata: { status: "pending" }, + }), + message({ + id: "active", + turn_id: "t2", + type: "clarification_request", + metadata: { status: "pending" }, + created_at: "2026-05-02T00:00:01Z", + }), + ]), + ).toBe(true); + }); + + it("ignores a legacy null-turn clarification when a newer turn exists", () => { + expect( + hasPendingClarification([ + message({ + id: "legacy", + type: "clarification_request", + metadata: { status: "pending" }, + }), + message({ id: "current", turn_id: "t2", type: "message" }), + ]), + ).toBe(false); + }); +}); + describe("findPendingClarificationGroup", () => { it("returns empty array when there are no messages", () => { expect(findPendingClarificationGroup([])).toEqual([]); @@ -135,6 +212,291 @@ describe("findPendingClarificationGroup", () => { }); expect(findPendingClarificationGroup([old, a]).map((m) => m.id)).toEqual(["a"]); }); + + it("counts terminal siblings for completeness but returns only pending questions", () => { + const answered = message({ + id: "answered", + type: "clarification_request", + metadata: { pending_id: "p1", question_total: 2, status: "answered" }, + }); + const pending = message({ + id: "pending", + type: "clarification_request", + metadata: { pending_id: "p1", question_total: 2, status: "pending" }, + }); + + expect(findPendingClarificationGroup([answered, pending]).map((item) => item.id)).toEqual([ + "pending", + ]); + }); +}); + +describe("current-turn clarification ownership", () => { + it("ignores an older pending request when a newer durable turn exists", () => { + const messages = [ + message({ + id: "old", + turn_id: "turn-old", + type: "clarification_request", + metadata: { pending_id: OLD_PENDING_ID, status: "pending" }, + }), + message({ id: "new", turn_id: CURRENT_TURN_ID, type: "message" }), + ]; + expect(findPendingClarification(messages, { currentTurnId: CURRENT_TURN_ID })).toBeNull(); + expect(findPendingClarificationGroup(messages, { currentTurnId: CURRENT_TURN_ID })).toEqual([]); + }); + + it.each([null, "permission"] as const)( + "treats a matching visible turn as inert when explicit authority is %s", + (pendingAction) => { + const pending = message({ + id: "visible-predecessor", + turn_id: CURRENT_TURN_ID, + type: "clarification_request", + metadata: { pending_id: OLD_PENDING_ID, status: "pending" }, + }); + const scope = { currentTurnId: CURRENT_TURN_ID, pendingAction }; + + expect(findPendingClarification([pending], scope)).toBeNull(); + expect(findPendingClarificationGroup([pending], scope)).toEqual([]); + }, + ); + + it("does not reactivate history when every newer-turn message is deleted", () => { + const messages = [ + message({ + id: "old", + turn_id: "turn-old", + type: "clarification_request", + metadata: { pending_id: OLD_PENDING_ID, status: "pending" }, + }), + ]; + expect(findPendingClarification(messages, { currentTurnId: CURRENT_TURN_ID })).toBeNull(); + }); + + it("returns only the current turn's exact pending bundle", () => { + const messages = [ + message({ + id: "old", + turn_id: "turn-old", + type: "clarification_request", + metadata: { pending_id: OLD_PENDING_ID, status: "pending" }, + }), + message({ + id: "current-a", + turn_id: CURRENT_TURN_ID, + type: "clarification_request", + metadata: { pending_id: "pending-new", question_total: 2, status: "pending" }, + }), + message({ + id: "current-b", + turn_id: CURRENT_TURN_ID, + type: "clarification_request", + metadata: { pending_id: "pending-new", question_total: 2, status: "pending" }, + }), + ]; + expect( + findPendingClarificationGroup(messages, { currentTurnId: CURRENT_TURN_ID }).map( + (item) => item.id, + ), + ).toEqual(["current-a", "current-b"]); + }); + + it("keeps an earlier pending bundle visible when a newer bundle is terminal", () => { + const messages = [ + message({ + id: "pending", + turn_id: CURRENT_TURN_ID, + type: "clarification_request", + metadata: { pending_id: "pending-1", status: "pending" }, + }), + message({ + id: "rejected", + turn_id: CURRENT_TURN_ID, + type: "clarification_request", + metadata: { pending_id: "pending-2", status: "rejected" }, + }), + ]; + const scope = { currentTurnId: CURRENT_TURN_ID }; + expect(findPendingClarification(messages, scope)?.id).toBe("pending"); + expect(findPendingClarificationGroup(messages, scope).map((item) => item.id)).toEqual([ + "pending", + ]); + }); +}); + +describe("newestDurableTurnId load states", () => { + it("distinguishes unavailable, empty, and populated durable turn history", () => { + expect(newestDurableTurnId()).toBeUndefined(); + expect(newestDurableTurnId([])).toBeNull(); + expect(newestDurableTurnId([turn("turn-only")])).toBe("turn-only"); + }); + + it("selects the turn with the later start time", () => { + expect( + newestDurableTurnId([ + turn("turn-id-at-noon", "2026-08-14T12:00:00Z"), + turn("turn-id-one-second-later", "2026-08-14T12:00:01Z"), + ]), + ).toBe("turn-id-one-second-later"); + }); +}); + +describe("clarificationTurnIdForSession", () => { + it("quarantines pending message history for terminal sessions", () => { + const turns = [turn(CURRENT_TURN_ID)]; + expect(clarificationTurnIdForSession("COMPLETED", turns)).toBeNull(); + expect(clarificationTurnIdForSession("FAILED", turns)).toBeNull(); + expect(clarificationTurnIdForSession("CANCELLED", turns)).toBeNull(); + }); + + it("keeps current-turn authority for input-capable sessions", () => { + expect(clarificationTurnIdForSession("RUNNING", [turn(CURRENT_TURN_ID)])).toBe(CURRENT_TURN_ID); + }); +}); + +describe("clarification authority fallbacks", () => { + it("uses compact session authority while turn history is unavailable", () => { + const messages = [message({ type: "clarification_request", metadata: { status: "pending" } })]; + expect(findPendingClarification(messages, { pendingAction: null })).toBeNull(); + expect(findPendingClarification(messages, { pendingAction: "clarification" })?.id).toBe( + "msg-1", + ); + }); + + it("treats loaded empty turn history as having no active clarification", () => { + const messages = [message({ type: "clarification_request", metadata: { status: "pending" } })]; + expect(findPendingClarification(messages, { currentTurnId: null })).toBeNull(); + }); + + it("applies every turn-authority state to direct pending checks", () => { + const old = message({ + id: "old", + turn_id: "turn-old", + type: "clarification_request", + metadata: { status: "pending" }, + }); + const current = message({ + id: "current", + turn_id: CURRENT_TURN_ID, + type: "clarification_request", + metadata: { status: "pending" }, + }); + + expect(hasPendingClarification([old, current], { currentTurnId: null })).toBe(false); + expect( + hasPendingClarification([old, current], { + currentTurnId: undefined, + pendingAction: "clarification", + }), + ).toBe(true); + expect(hasPendingClarification([old, current], { currentTurnId: undefined })).toBe(false); + expect(hasPendingClarification([old, current], { currentTurnId: CURRENT_TURN_ID })).toBe(true); + expect(hasPendingClarification([old], { currentTurnId: CURRENT_TURN_ID })).toBe(false); + }); +}); + +describe("newestDurableTurnId ordering", () => { + it("uses creation time before the turn id when start times match", () => { + expect( + newestDurableTurnId([ + turn("turn-z", TURN_TIMESTAMP, TURN_TIMESTAMP), + turn("turn-a", TURN_TIMESTAMP, "2026-08-14T12:00:00.000000001Z"), + ]), + ).toBe("turn-a"); + }); + + it("selects the newest durable turn with backend tie-break ordering", () => { + expect( + newestDurableTurnId([ + { + id: "turn-a", + session_id: toSessionId("session-1"), + task_id: toTaskId("task-1"), + started_at: TURN_TIMESTAMP, + created_at: TURN_TIMESTAMP, + updated_at: TURN_TIMESTAMP, + }, + { + id: "turn-b", + session_id: toSessionId("session-1"), + task_id: toTaskId("task-1"), + started_at: TURN_TIMESTAMP, + created_at: TURN_TIMESTAMP, + updated_at: TURN_TIMESTAMP, + }, + ]), + ).toBe("turn-b"); + }); + + it("uses preserved subsecond precision before the turn id tie-break", () => { + expect( + newestDurableTurnId([ + { + id: "turn-z", + session_id: toSessionId("session-1"), + task_id: toTaskId("task-1"), + started_at: "2026-08-14T12:00:00.100Z", + created_at: TURN_TIMESTAMP, + updated_at: TURN_TIMESTAMP, + }, + { + id: "turn-a", + session_id: toSessionId("session-1"), + task_id: toTaskId("task-1"), + started_at: "2026-08-14T12:00:00.900Z", + created_at: TURN_TIMESTAMP, + updated_at: TURN_TIMESTAMP, + }, + ]), + ).toBe("turn-a"); + }); + + it("orders an exact-second timestamp before a later fractional timestamp", () => { + expect( + newestDurableTurnId([ + { + id: "turn-z", + session_id: toSessionId("session-1"), + task_id: toTaskId("task-1"), + started_at: TURN_TIMESTAMP, + created_at: TURN_TIMESTAMP, + updated_at: TURN_TIMESTAMP, + }, + { + id: "turn-a", + session_id: toSessionId("session-1"), + task_id: toTaskId("task-1"), + started_at: "2026-08-14T12:00:00.1Z", + created_at: TURN_TIMESTAMP, + updated_at: TURN_TIMESTAMP, + }, + ]), + ).toBe("turn-a"); + }); + + it("orders an exact-second timestamp before a one-nanosecond-later timestamp", () => { + expect( + newestDurableTurnId([ + { + id: "turn-z", + session_id: toSessionId("session-1"), + task_id: toTaskId("task-1"), + started_at: TURN_TIMESTAMP, + created_at: TURN_TIMESTAMP, + updated_at: TURN_TIMESTAMP, + }, + { + id: "turn-a", + session_id: toSessionId("session-1"), + task_id: toTaskId("task-1"), + started_at: "2026-08-14T12:00:00.000000001Z", + created_at: TURN_TIMESTAMP, + updated_at: TURN_TIMESTAMP, + }, + ]), + ).toBe("turn-a"); + }); }); describe("hasPendingPermissionRequest", () => { diff --git a/apps/web/lib/utils/pending-clarification.ts b/apps/web/lib/utils/pending-clarification.ts index 5690e37589..f6be4cd76c 100644 --- a/apps/web/lib/utils/pending-clarification.ts +++ b/apps/web/lib/utils/pending-clarification.ts @@ -1,4 +1,20 @@ -import type { ClarificationRequestMetadata, Message } from "@/lib/types/http"; +import type { + ClarificationRequestMetadata, + Message, + TaskPendingAction, + Turn, +} from "@/lib/types/http"; +import { isInputCapableSessionState } from "./task-pending-input"; + +export type PendingClarificationScope = { + /** + * Undefined means turn history is not loaded, so pendingAction gates the fallback. + * Null means history is loaded but has no durable turns, so all messages are hidden. + * A string scopes detection to that exact turn. An empty object disables detection. + */ + currentTurnId?: string | null; + pendingAction?: TaskPendingAction | null; +}; export function isPendingClarificationMessage(message: Message): boolean { if (message.type !== "clarification_request") return false; @@ -6,53 +22,144 @@ export function isPendingClarificationMessage(message: Message): boolean { return !metadata?.status || metadata.status === "pending"; } -export function findPendingClarification(messages?: readonly Message[] | null): Message | null { - if (!messages) return null; - for (let i = messages.length - 1; i >= 0; i--) { - if (isPendingClarificationMessage(messages[i])) return messages[i]; +// TurnDTO timestamps are normalized by the backend to UTC with a Z suffix. +// Non-Z input falls through to raw comparison and is outside this contract. +function durableTurnTimestampKey(value: string): string { + const match = /^(.*T\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?Z$/.exec(value); + if (!match) return value; + return `${match[1]}.${(match[2] ?? "").padEnd(9, "0")}Z`; +} + +export function newestDurableTurnId(turns?: readonly Turn[]): string | null | undefined { + if (turns === undefined) return undefined; + if (turns.length === 0) return null; + let newest = turns[0]; + for (let index = 1; index < turns.length; index++) { + const candidate = turns[index]; + const newestKey = [ + durableTurnTimestampKey(newest.started_at), + durableTurnTimestampKey(newest.created_at), + newest.id, + ]; + const candidateKey = [ + durableTurnTimestampKey(candidate.started_at), + durableTurnTimestampKey(candidate.created_at), + // Mirrors the backend's final deterministic tie-break. Turn IDs do not + // encode creation time; exact timestamp ties have no finer ordering. + candidate.id, + ]; + for (let part = 0; part < candidateKey.length; part++) { + if (candidateKey[part] === newestKey[part]) continue; + if (candidateKey[part] > newestKey[part]) newest = candidate; + break; + } + } + return newest.id; +} + +export function clarificationTurnIdForSession( + sessionState: string | null | undefined, + turns?: readonly Turn[], +): string | null | undefined { + if (sessionState && !isInputCapableSessionState(sessionState)) return null; + return newestDurableTurnId(turns); +} + +function clarificationMessagesInScope( + messages: readonly Message[], + scope?: PendingClarificationScope, +): readonly Message[] { + if (!scope) return messages; + if (scope.pendingAction !== undefined && scope.pendingAction !== "clarification") return []; + if (scope.currentTurnId === null) return []; + if (scope.currentTurnId === undefined) { + return scope.pendingAction === "clarification" ? messages : []; + } + return messages.filter((message) => message.turn_id === scope.currentTurnId); +} + +function newestMessageTurnId(messages: readonly Message[]): string | undefined { + let newest: Message | undefined; + for (const message of messages) { + if (!message.turn_id) continue; + if (!newest) { + newest = message; + continue; + } + const createdAt = durableTurnTimestampKey(message.created_at); + const newestCreatedAt = durableTurnTimestampKey(newest.created_at); + if (createdAt > newestCreatedAt || (createdAt === newestCreatedAt && message.id > newest.id)) { + newest = message; + } + } + return newest?.turn_id; +} + +export function findPendingClarification( + messages?: readonly Message[] | null, + scope?: PendingClarificationScope, +): Message | null { + if (!messages?.length) return null; + const scoped = clarificationMessagesInScope(messages, scope); + // Sidebar callers do not have durable turn history. Use persisted message + // order instead of WebSocket arrival order so a delayed predecessor event + // cannot hide the current request or re-arm an older one. + const latestTurnId = newestMessageTurnId(scoped); + for (let i = scoped.length - 1; i >= 0; i--) { + if (latestTurnId && scoped[i].turn_id !== latestTurnId) continue; + if (scoped[i].type !== "clarification_request") continue; + if (isPendingClarificationMessage(scoped[i])) return scoped[i]; } return null; } -// findPendingClarificationGroup returns every clarification_request message -// that shares the latest pending message's pending_id, ordered by chat position. -// Multi-question bundles emit one message per question; the chat panel uses this -// list to render every pending question card together. +// findPendingClarificationGroup returns pending clarification_request messages +// that share the latest pending message's pending_id, ordered by chat position. +// Multi-question bundles emit one message per question; terminal siblings count +// toward arrival completeness but are not rendered for a replacement answer. // // Gates on `question_total` from metadata: returns an empty array until the // number of messages received equals the expected bundle size. This prevents // a user from acting on a partially-arrived bundle (clicking an option before // the rest of the N messages have been streamed in via the WS), which would // otherwise trigger a 400 from the backend's all-required gate. -export function findPendingClarificationGroup(messages?: readonly Message[] | null): Message[] { +export function findPendingClarificationGroup( + messages?: readonly Message[] | null, + scope?: PendingClarificationScope, +): Message[] { if (!messages) return []; - const last = findPendingClarification(messages); + const scoped = clarificationMessagesInScope(messages, scope); + const last = findPendingClarification(scoped); if (!last) return []; const meta = last.metadata as ClarificationRequestMetadata | undefined; const pendingID = meta?.pending_id; if (!pendingID) return [last]; - const group = messages.filter((m) => { + const bundle = scoped.filter((m) => { if (m.type !== "clarification_request") return false; const mMeta = m.metadata as ClarificationRequestMetadata | undefined; return mMeta?.pending_id === pendingID; }); const expectedTotal = meta?.question_total; - if (typeof expectedTotal === "number" && expectedTotal > 0 && group.length < expectedTotal) { + if (typeof expectedTotal === "number" && expectedTotal > 0 && bundle.length < expectedTotal) { return []; } - return group; + return bundle.filter(isPendingClarificationMessage); } -export function hasPendingClarification(messages?: readonly Message[] | null): boolean { - return findPendingClarification(messages) !== null; +export function hasPendingClarification( + messages?: readonly Message[] | null, + scope?: PendingClarificationScope, +): boolean { + return findPendingClarification(messages, scope) !== null; } export function hasPendingClarificationForSession( messagesBySession: Record, sessionId?: string | null, + scope?: PendingClarificationScope, ): boolean { if (!sessionId) return false; - return hasPendingClarification(messagesBySession[sessionId]); + return hasPendingClarification(messagesBySession[sessionId], scope); } // --- Permission request helpers --- diff --git a/apps/web/lib/ws/handlers/messages.test.ts b/apps/web/lib/ws/handlers/messages.test.ts index 197b3ee559..71874cc670 100644 --- a/apps/web/lib/ws/handlers/messages.test.ts +++ b/apps/web/lib/ws/handlers/messages.test.ts @@ -6,6 +6,14 @@ import { createMessagesHandlerRegistration, createMessageUpdateScheduler } from type UpdatedMessage = BackendMessageMap["session.message.updated"]; const TEST_TIMESTAMP = "2026-08-02T00:00:00.000Z"; +const PROJECTION_EPOCH = "7"; +const SESSION_ID = "session-1"; +const QUESTION_ID = "question-1"; +const QUESTION_CONTENT = "Choose"; + +function pendingRevision(sequence: number) { + return { epoch: PROJECTION_EPOCH, sequence }; +} function makePayload( sessionId: string, @@ -42,10 +50,12 @@ function makeStore(currentMessages: Record = {}, completedTur const updateMessage = vi.fn(); const addMessage = vi.fn(); const removeMessage = vi.fn(); + const setTaskSessionPendingAction = vi.fn(); const state = { updateMessage, addMessage, removeMessage, + setTaskSessionPendingAction, messages: { bySession: currentMessages }, turns: { bySession: completedTurn @@ -60,6 +70,7 @@ function makeStore(currentMessages: Record = {}, completedTur updateMessage, addMessage, removeMessage, + setTaskSessionPendingAction, }; } @@ -187,6 +198,127 @@ describe("session message frame scheduler", () => { }); }); +describe("session message pending-action projection", () => { + it("applies the authoritative projection carried by a message event", () => { + const { store, setTaskSessionPendingAction } = makeStore(); + const registration = createMessagesHandlerRegistration(store); + + registration.handlers["session.message.added"]!({ + id: "add-clarification", + type: "notification", + action: "session.message.added", + payload: { + ...makePayload(SESSION_ID, QUESTION_ID, QUESTION_CONTENT), + type: "clarification_request", + pending_action: "clarification", + pending_action_revision: pendingRevision(1), + }, + timestamp: TEST_TIMESTAMP, + }); + + expect(setTaskSessionPendingAction).toHaveBeenCalledWith( + SESSION_ID, + "clarification", + pendingRevision(1), + ); + + registration.handlers["session.message.updated"]!({ + ...makeUpdated(SESSION_ID, QUESTION_ID, QUESTION_CONTENT), + payload: { + ...makePayload(SESSION_ID, QUESTION_ID, QUESTION_CONTENT), + type: "clarification_request", + pending_action: null, + pending_action_revision: pendingRevision(2), + }, + }); + registration.scheduler.flush(); + + expect(setTaskSessionPendingAction).toHaveBeenLastCalledWith( + SESSION_ID, + null, + pendingRevision(2), + ); + registration.dispose(); + }); + + it("preserves event order when batched updates revisit a message", () => { + const { store, setTaskSessionPendingAction } = makeStore(); + const registration = createMessagesHandlerRegistration(store); + const handler = registration.handlers["session.message.updated"]!; + let sequence = 0; + const update = (messageId: string, pendingAction: "clarification" | "permission" | null) => { + handler({ + ...makeUpdated(SESSION_ID, messageId, QUESTION_CONTENT), + payload: { + ...makePayload(SESSION_ID, messageId, QUESTION_CONTENT), + type: "clarification_request", + pending_action: pendingAction, + pending_action_revision: pendingRevision(++sequence), + }, + }); + }; + + update("question-a", "clarification"); + update("question-b", null); + update("question-a", "permission"); + registration.scheduler.flush(); + + expect(setTaskSessionPendingAction).toHaveBeenLastCalledWith( + SESSION_ID, + "permission", + pendingRevision(3), + ); + registration.dispose(); + }); +}); + +describe("session message deleted pending-action projection", () => { + it("applies the cleared projection carried by a deleted message event", () => { + const { store, removeMessage, setTaskSessionPendingAction } = makeStore(); + const registration = createMessagesHandlerRegistration(store); + + registration.handlers["session.message.deleted"]!({ + id: "delete-clarification", + type: "notification", + action: "session.message.deleted", + payload: { + ...makePayload(SESSION_ID, QUESTION_ID, QUESTION_CONTENT), + type: "clarification_request", + pending_action: null, + pending_action_revision: pendingRevision(4), + }, + timestamp: TEST_TIMESTAMP, + }); + + expect(removeMessage).toHaveBeenCalledWith(SESSION_ID, QUESTION_ID); + expect(setTaskSessionPendingAction).toHaveBeenCalledWith(SESSION_ID, null, pendingRevision(4)); + registration.dispose(); + }); +}); + +describe("session message pending-action ordering", () => { + it("ignores a projection from an update older than the current message", () => { + const { store, setTaskSessionPendingAction } = makeStore({ + [SESSION_ID]: [{ id: QUESTION_ID, updated_at: "2026-08-02T00:00:02.000Z" }], + }); + const registration = createMessagesHandlerRegistration(store); + + registration.handlers["session.message.updated"]!({ + ...makeUpdated(SESSION_ID, QUESTION_ID, QUESTION_CONTENT), + payload: { + ...makePayload(SESSION_ID, QUESTION_ID, QUESTION_CONTENT), + type: "clarification_request", + pending_action: "clarification", + pending_action_revision: pendingRevision(1), + }, + }); + registration.scheduler.flush(); + + expect(setTaskSessionPendingAction).not.toHaveBeenCalled(); + registration.dispose(); + }); +}); + describe("session message snapshot ordering", () => { it("does not apply a batched update older than a refetched snapshot", () => { const { store, updateMessage } = makeStore({ diff --git a/apps/web/lib/ws/handlers/messages.ts b/apps/web/lib/ws/handlers/messages.ts index c4d2fcab7b..88c16e8165 100644 --- a/apps/web/lib/ws/handlers/messages.ts +++ b/apps/web/lib/ws/handlers/messages.ts @@ -89,6 +89,17 @@ function isOlderThanCurrentSnapshot(store: StoreApi, payload: MessageP return !payload.updated_at || current.updated_at > payload.updated_at; } +function applyPendingActionProjection(store: StoreApi, payload: MessagePayload): void { + if (!("pending_action" in payload)) return; + store + .getState() + .setTaskSessionPendingAction( + payload.session_id, + payload.pending_action ?? null, + payload.pending_action_revision, + ); +} + function defaultSchedule(callback: () => void): number { if (typeof requestAnimationFrame === "function") { return requestAnimationFrame(() => callback()); @@ -166,9 +177,15 @@ export function createMessagesHandlerRegistration( if (!payload.session_id) return; scheduler.flush(); store.getState().addMessage(settleMessageForCompletedTurn(store, toMessage(payload))); + applyPendingActionProjection(store, payload); }, "session.message.updated": (message) => { - scheduler.enqueue(message.payload); + const payload = message.payload; + if (!payload.session_id || !payload.message_id) return; + if (!isOlderThanCurrentSnapshot(store, payload)) { + applyPendingActionProjection(store, payload); + } + scheduler.enqueue(payload); }, "session.message.deleted": (message) => { const payload = message.payload; @@ -178,6 +195,7 @@ export function createMessagesHandlerRegistration( // the deleted row. scheduler.flush(); store.getState().removeMessage(sessionId(payload.session_id), payload.message_id); + applyPendingActionProjection(store, payload); }, }; return { handlers, scheduler, dispose: scheduler.dispose }; diff --git a/docs/decisions/0015-explicit-completion-signal-for-auto-advance.md b/docs/decisions/0015-explicit-completion-signal-for-auto-advance.md index 2eb744c3d9..c6d92d210a 100644 --- a/docs/decisions/0015-explicit-completion-signal-for-auto-advance.md +++ b/docs/decisions/0015-explicit-completion-signal-for-auto-advance.md @@ -1,9 +1,14 @@ # 0015: Explicit completion signal for auto-advance -**Status:** proposed +**Status:** proposed (amended 2026-08-14) **Date:** 2026-06-04 **Area:** backend, frontend +> Amended by +> [ADR-2026-08-14-current-turn-clarification-ownership](2026-08-14-current-turn-clarification-ownership.md): +> a detached clarification remains a hard-pause barrier only while its turn remains current. +> That ownership rule stands independently of this proposal's future status. + ## Context Today, a workflow step's `on_turn_complete` actions (most commonly `move_to_next`, i.e. "auto-advance") fire whenever the agent's turn ends. The orchestrator's only signal that work is "done" is that the agent has stopped streaming and the ACP turn is complete. That signal is unreliable as a proxy for "user requirements satisfied": @@ -70,9 +75,9 @@ Today, a workflow step's `on_turn_complete` actions (most commonly `move_to_next - **Step requires explicit signal** (auto-advance enabled and `auto_advance_requires_signal == true`): on bare turn-end, read `Metadata["pending_step_completion_signal"]`. If absent (or stale for a prior step), do **not** evaluate transition actions; call `setSessionWaitingForInput` and surface a "Completion pending" UI hint. If present and matches the current step, run the existing `processTurnCompleteActions` / `resolveTransitionTargetStep` / `executeStepTransition` pipeline and clear the bag entry. The same path is triggered out-of-band by a new bus subscriber `onStepCompletionSignaled` for the case where the signal lands *after* the turn already ended (signal-after-halt is the common shape). - **Step does not require explicit signal** (legacy behavior): unchanged. -5. **Clarification hard-pause barrier.** A pending durable clarification request blocks every `on_turn_complete` transition path, including the legacy path, the workflow-engine path, and the `workflow.step_completion_signaled` subscriber. This guard runs before explicit completion-signal evaluation, so even a previously written `step_complete_kandev` signal cannot advance while a user question is pending. +5. **Clarification hard-pause barrier.** A pending durable clarification request in the session's current turn blocks every `on_turn_complete` transition path, including the legacy path, the workflow-engine path, and the `workflow.step_completion_signaled` subscriber. An older-turn pending row is superseded history and does not block. This guard runs before explicit completion-signal evaluation, so even a previously written `step_complete_kandev` signal cannot advance while a current-turn user question is pending. - When `ask_user_question_kandev` ends without an answer because the MCP wait timed out, the transport disconnected, or the handler context was cancelled, the MCP handler calls the orchestrator's clarification pause path. That path detaches the pending clarification for later answer handling, silently cancels the active agent turn when one exists, marks the session `WAITING_FOR_INPUT`, completes the active turn bookkeeping, and deliberately skips workflow stop triggers. When the user later answers the pending clarification, the answer resumes the current session as a continuation prompt and deliberately skips `on_turn_start` triggers; otherwise answering a question could immediately fire start-of-turn transitions for the same step. + When `ask_user_question_kandev` ends without an answer because the MCP wait timed out, the transport disconnected, or the handler context was cancelled, the MCP handler calls the orchestrator's clarification pause path. That path detaches the pending clarification for later answer handling, silently cancels the active agent turn when one exists, marks the session `WAITING_FOR_INPUT`, completes the active turn bookkeeping, and deliberately skips workflow stop triggers. While that turn remains current, a later answer resumes the current session as a continuation prompt and deliberately skips `on_turn_start` triggers; otherwise answering a question could immediately fire start-of-turn transitions for the same step. Accepting a newer turn supersedes the detached request and prevents a stale answer from resuming the agent. 6. **Halt-without-signal fallback.** When the agent halts without having called `step_complete_kandev`, the UI shows an inline "Mark complete & advance" action (rendered in the chat composer area, akin to the existing clarification overlay). One click writes the bag entry with `source: "manual_fallback"` and emits the same `workflow.step_completion_signaled` event. This is the safety net: false negatives (work done, agent forgot to call the tool) cost one user click; false positives (agent calls the tool prematurely) are caught because user can keep messaging and the next turn re-opens the step. The fallback is **disabled** during an active ask-clarification waiting window so the button doesn't compete with a pending user question. @@ -90,7 +95,7 @@ Today, a workflow step's `on_turn_complete` actions (most commonly `move_to_next - **Compliance burden on the agent.** Whether the agent obeys the injected instruction is a function of (a) the model's instruction-following, and (b) the agent CLI's handling of system-prompt prepends. Both vary across Claude, Codex, Cursor, OpenCode. The fallback button is what makes this acceptable; without it, the design would brick non-Claude agents. - **Adds a new MCP tool.** Increases the kandev MCP surface area; tool listing payload grows by one entry. The tool itself is small, but new agents and new CLIs need to be validated against it the same way ADR 0014's strategies were. - **`step_complete_kandev` is fire-and-forget once it returns.** The MCP call only returns after the bag write + bus publish complete, but the actual transition runs asynchronously in the bus subscriber. An agent that calls the tool then immediately continues working may have trailing tokens race with the transition. Mitigation today is behavioural: the tool description in `kandev-context.md` instructs the agent to "call this as the LAST action of the step." The manual fallback button and re-open semantics are the safety nets if the agent gets it wrong. -- **Clarification timeout becomes a hard pause, not a completed turn.** A no-answer `ask_user_question_kandev` result cancels the current agent turn without running `on_turn_complete`. The user-visible state is a waiting session with the clarification still answerable; the next user answer resumes the same step instead of triggering turn-start automation. +- **Clarification timeout becomes a current-turn hard pause, not a completed turn.** A no-answer `ask_user_question_kandev` result cancels the current agent turn without running `on_turn_complete`. The user-visible state is a waiting session with the clarification still answerable until the session accepts newer work; an answer before then resumes the same step instead of triggering turn-start automation. - **Idempotency.** Multiple calls within the same step are deduped at the bag-write site (first call wins, subsequent calls return `{accepted: false, reason: "already_signaled"}` without error). Cross-step re-entry is allowed — a fresh step's `currentStepID` no longer matches the stale bag entry, so the next call writes through. - **No new schema for the signal itself.** Storage piggybacks on the existing `task_sessions.metadata` JSON column. Audit of *consumed* signals piggybacks on the existing `session_step_history.metadata` JSON column. Cancelled signals (overwritten by a user reply) are not retained — chat already surfaces what the user needs. - **Telemetry.** Two `expvar` counters under `workflow_*` expose the explicit-signal health metric: `step_completion_signal_received_total` counts accepted signals by source and agent type, and `step_completion_signal_fallback_used_total` counts manual fallback uses by agent type. Their ratio (fallback-used / received ≤ 10% per agent type) is the headline "is the explicit-signal flow working" metric. The fallback counter is published now, but its increment site waits for the manual fallback button. diff --git a/docs/decisions/2026-08-14-current-turn-clarification-ownership.md b/docs/decisions/2026-08-14-current-turn-clarification-ownership.md new file mode 100644 index 0000000000..26759fa913 --- /dev/null +++ b/docs/decisions/2026-08-14-current-turn-clarification-ownership.md @@ -0,0 +1,112 @@ +# ADR-2026-08-14-current-turn-clarification-ownership: Current Turn Owns Active Clarification + +**Status:** accepted (amended 2026-08-15) +**Date:** 2026-08-14 +**Area:** backend, frontend, protocol, workflow + +## Context + +Kandev deliberately keeps a clarification answerable when its MCP wait times out or disconnects. +The persisted question stays pending and receives `agent_disconnected=true`; a late answer can then +resume the same session through an event fallback. + +Several consumers treated every historical pending row as operational. Turn completion repeatedly +detached and republished old bundles, the workflow guard scanned full session history, and a persisted +task summary could retain the resulting question indicator indefinitely. Other consumers already +limited pending actions to the latest surviving message turn. That boundary can move backward when +the final message of a newer turn is deleted even though its durable turn record remains. Those +conflicting definitions let an older request reappear after a newer request was rejected and let an +inert request block workflow transitions. + +Task summaries also identify only the kind of pending action. A real question owned by a secondary +session can therefore make a task row correct while normal task navigation opens the primary session +and hides the question. + +## Decision + +The session's current turn owns active clarification state. + +- A pending clarification is operational only when its message belongs to the session's newest durable + `task_session_turns` record. Missing status retains the existing legacy meaning of pending only after + current-turn ownership matches. +- Deleting messages never moves current-turn ownership backward or reactivates a superseded bundle. +- Detachment preserves late-answer behavior only while that turn remains current. Once a newer turn is + accepted, older pending rows become inert history without requiring a destructive rewrite. +- One repository derivation supplies the backend clarification guard, detach/expiry fallback, + response validation, per-session pending projection, and task-summary reconciliation. +- Message and clarification events trigger a fresh authoritative projection. Event arrival order and + one in-memory request identity per session do not define persisted pending state. +- Response handling follows the same ownership rule. An atomic current-turn claim precedes both live + waiter delivery and detached resume publication. Terminal message updates become visible only after + delivery succeeds; a failed detached publication returns an error and restores the bundle to pending + when its turn is still current. Retryability is acknowledged only after the live projector durably + refreshes the task summary from those restored rows; exhausted summary compare-and-set retries return + an error instead of false acknowledgement. Message publication then converges other clients. + Detached orchestrator acceptance uses a finite deadline. A detached current-turn rejection persists + without resuming; any response to a superseded or terminal bundle returns conflict without a write or + resume event. +- If historical per-row failures leave a current-turn bundle with both terminal and pending siblings, + the response claim transitions only the pending rows and preserves terminal siblings. Failed delivery + restores only rows owned by that claim. +- Repeated detachment of an already detached bundle performs no write and publishes no duplicate + message occurrence. +- Existing task summaries reconcile their pending field on normal task-list and boot reads. Repair uses + compare-and-set, preserves unrelated fields, advances revision only for a semantic change, and + publishes the complete corrected replacement. +- Frontend transcript logic consumes the existing durable turn list when available and uses the + server's per-session `pending_action` while turn history is unavailable. Task-row navigation selects + the newest matching input-capable session before remembered or primary preferences. + +This decision narrows the clarification hard-pause lifetime in +[ADR 0015](0015-explicit-completion-signal-for-auto-advance.md). A detached question remains a hard +barrier while its turn is current, not forever after unrelated later turns are accepted. +This ownership rule stands independently if ADR 0015 is later rejected, superseded, or amended again. + +## Consequences + +- Old pending metadata remains useful audit evidence but is not operational state by itself. +- Late answers remain supported across disconnects and restarts until the user or system starts newer + work in that session. +- Workflow transitions cannot be blocked by an older-turn question. +- Sidebar and phone indicators converge from repository truth after restart without a database + cleanup migration. +- A task-row question icon leads to the session that owns the question, including a non-primary + session. +- Pending projection performs bounded database reads on pending-sensitive events and normal list/boot + hydration. It avoids unbounded transcript delivery or inactive-session subscriptions. +- SQLite and PostgreSQL must select the same newest durable turn; dialect-sensitive repository tests + are required. + +## Alternatives considered + +### Treat every historical pending row as active + +Rejected. It preserves unlimited late answers by allowing obsolete requests to reappear, emit fresh +events, and block workflow progress after newer turns. + +### Expire every question immediately when its waiter disconnects + +Rejected. A short MCP timeout or transport loss would discard required user input even when the user +has not moved the conversation forward. + +### Rewrite old rows when every new turn starts + +Rejected. It adds a mutation to every turn-entry path and changes transcript history to enforce a +rule that can be reconstructed from existing turn identity. + +### Derive ownership from the latest surviving message + +Rejected. Deleting the last message in a newer turn would roll the boundary backward and reactivate +older pending history even though the newer `task_session_turns` record remains durable. + +### Add a dedicated clarification-state table + +Rejected for this repair. Existing message status, bundle identity, and turn identity contain the +needed authority. A new table would add migration and dual-write failure modes without changing the +product rule. + +### Put the owning session in the task summary + +Rejected. Task session list responses already expose bounded per-session `pending_action`. Loading +that existing list only when a pending task is activated keeps the task summary constant-size and +avoids another identity contract. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index a299c1ea81..e04675563f 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -20,7 +20,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 0012 | [Service-only UI self-update](0012-service-only-self-update.md) | accepted | backend, frontend, cli | 2026-05-29 | | 0013 | [Multi-branch task support — N (repo, branch) pairs per task](0013-multi-branch-tasks.md) | accepted | backend, frontend | 2026-06-01 | | 0014 | [Per-CLI MCP server injection for passthrough mode](0014-passthrough-mcp-injection-strategies.md) | accepted | backend | 2026-05-29 | -| 0015 | [Explicit completion signal for auto-advance](0015-explicit-completion-signal-for-auto-advance.md) | proposed | backend, frontend | 2026-06-04 | +| 0015 | [Explicit completion signal for auto-advance](0015-explicit-completion-signal-for-auto-advance.md) | proposed (amended 2026-08-14) | backend, frontend | 2026-06-04 | | 0016 | [Read-only absolute file paths](0016-observed-external-file-reads.md) | accepted | backend | 2026-06-14 | | 0017 | [Resource metrics sampling](0017-resource-metrics-sampling.md) | accepted | backend, frontend, protocol | 2026-06-14 | | 0018 | [Runtime settings overrides](0018-runtime-settings-overrides.md) | accepted (amended 2026-08-11) | backend, frontend, cli | 2026-06-14 | @@ -168,6 +168,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 2026-08-12-empty-utility-bindings-inherit-default | [Empty Built-in Utility Bindings Inherit the Default](2026-08-12-empty-utility-bindings-inherit-default.md) | accepted | backend, frontend | 2026-08-12 | | 2026-08-12-setup-timeout-owns-launch-budget | [One Setup Timeout Owns Launch Budgets](2026-08-12-setup-timeout-owns-launch-budget.md) | accepted | backend | 2026-08-12 | | 2026-08-10-no-em-dash-public-copy | [Keep Em Dashes Out of Public Copy](2026-08-10-no-em-dash-public-copy.md) | accepted | frontend, infra | 2026-08-10 | +| 2026-08-14-current-turn-clarification-ownership | [Current Turn Owns Active Clarification](2026-08-14-current-turn-clarification-ownership.md) | accepted (amended 2026-08-15) | backend, frontend, protocol, workflow | 2026-08-14 | | 2026-08-12-task-bound-fork-destinations | [Bind Fork Push Destinations to Tasks](2026-08-12-task-bound-fork-destinations.md) | accepted (amended 2026-08-13) | backend, frontend, workflow, security, GitHub | 2026-08-12 | | 2026-08-13-hard-delete-task-contribution-links | [Hard delete owns task contribution links](2026-08-13-hard-delete-task-contribution-links.md) | accepted | backend | 2026-08-13 | | 2026-08-15-office-mode-follows-active-workspace | [Office Mode Follows the Active Workspace](2026-08-15-office-mode-follows-active-workspace.md) | accepted | frontend, backend | 2026-08-15 | diff --git a/docs/plans/clarification-active-lifecycle/plan.md b/docs/plans/clarification-active-lifecycle/plan.md new file mode 100644 index 0000000000..f6e516198c --- /dev/null +++ b/docs/plans/clarification-active-lifecycle/plan.md @@ -0,0 +1,510 @@ +--- +spec: docs/specs/clarification-active-lifecycle/spec.md +created: 2026-08-14 +status: completed +--- + +# Implementation Plan: Active Clarification Lifecycle + +## Overview + +Make current-turn ownership the single definition of an active clarification, then use that +authority in workflow guarding, detachment, response validation, task-summary projection, chat +state, and task navigation. Black-box desktop/mobile regressions land and fail first. Backend +authority and summary convergence follow, then frontend current-turn and pending-session selection, +then production-build E2E returns green. + +No schema migration, task-summary shape, new HTTP route, or direct mutation of the reported main +instance is required. + +### Confirmed root cause and reproduction evidence + +- `task_session_messages.metadata.status` retained older detached rows as `pending`, by design. +- `FindPendingClarificationMessagesBySessionID` scanned every historical turn. Every later turn + completion found those rows, wrote `agent_disconnected=true` again, and republished + `message.updated`. +- The workflow clarification guard used that same unbounded finder, so an inert historical row could + block `on_turn_complete`. +- `GetPendingActionsBySessionIDs` already scoped task/session API projections to the latest surviving + message turn. The live main instance therefore returned no flat pending action for two affected tasks + while their persisted `status_summary.pending_action` still said `clarification`. Review also found + that message deletion could move this provisional boundary backward despite a newer durable turn. +- The status projector restored that cached summary and tracked one request identity per session. + Existing-summary hydration repaired only missing rows, so a stale pending field survived restart + and reload. +- Frontend loaded-message discovery scanned all turns, allowing an older pending bundle to reappear + after the visible newer bundle was rejected. +- One reported task had a genuine current clarification in a non-primary secondary session. The + task-level icon was correct, but desktop and phone activation preferred remembered/primary session, + hiding the question. + +Read-only inspection found three projected clarification tasks: two stale projections with no +current-turn action and one genuine secondary-session action. Logs for the first case showed an old +bundle detached, a newer bundle rejected, and a later completion republishing the old pending row. + +--- + +## Backend + +### Current-turn clarification authority + +- In `apps/backend/internal/task/repository/sqlite/message.go`, replace the historical pending finder + with `FindActiveClarificationMessagesBySessionID`. Select the newest durable + `task_session_turns` row per session, then restrict messages to that turn. Reuse this derivation in + `GetPendingActionsBySessionIDs` so SQLite and PostgreSQL agree and message deletion cannot reactivate + an older bundle. +- Treat missing clarification status as pending for parity with the existing compact projection. + Return only current-turn `clarification_request` rows whose status is empty or `pending`. +- Rename the repository interfaces and focused mocks in + `apps/backend/internal/task/repository/interface.go`, + `apps/backend/internal/clarification/handlers.go`, and + `apps/backend/internal/orchestrator/service.go`. +- In `apps/backend/internal/clarification/canceller.go`, use the active finder for detach and expiry + fallback. Skip an already-terminal or already-detached row before writing or publishing. Count only + bundles that changed so repeated completion is a semantic no-op. +- In `apps/backend/internal/orchestrator/clarification_guard.go`, guard only on active current-turn + rows. Preserve fail-closed behavior when the authoritative query fails. +- In `apps/backend/internal/clarification/handlers.go`, validate the database-fallback response + against active current-turn ownership before any fallback write. A detached current-turn answer + succeeds only after one synchronously acknowledged orchestrator resume; a detached current-turn + rejection persists without resuming the agent. Any answer or rejection for a superseded/terminal + bundle returns conflict without a write or agent resume. +- Order durable turns identically by `started_at`, `created_at`, then `id` descending. Add an + environment-gated PostgreSQL behavior test for the changed query. No migration or persisted-row + rewrite. + +### Authoritative summary convergence + +- Add a `PendingActionLoader` to `apps/backend/internal/task/statussummary/projector.go`. It returns + current actions keyed by input-capable session for one task. +- Wire the loader in `apps/backend/internal/backendapp/gateway.go` using the task repository's bounded + session list and `GetPendingActionsBySessionIDs`; do not load transcripts. +- In `apps/backend/internal/task/statussummary/projector_events.go`, refresh pending state from the + loader after message, permission, and clarification occurrences. Ordinary messages in a newer turn + therefore clear superseded questions. Event ordering and `pending_id` memory no longer define the + production projection. +- Refresh authoritative pending state while restoring a persisted projection and after compare-and-set + rejection. On loader failure, retain last known state and surface the error; never optimistically + clear a question. +- Rename/extend `HydrateMissingTaskStatusSummaries` in + `apps/backend/internal/task/service/service_status_summary_rebuild.go` to reconcile existing summary + pending fields as well as build absent rows. For an existing row, clone the latest stored summary, + replace only `PendingAction`, advance revision/time on semantic change, and use bounded CAS + reload/retry. +- Publish `task.status_summary.updated` after a successful existing-row repair so other connected + clients converge. Preserve unrelated primary, activity, error, Git, PR, and queued-prompt fields. +- Update the task-list and boot callers in + `apps/backend/internal/task/handlers/task_http_handlers.go` and + `apps/backend/internal/backendapp/boot_state.go` to use the reconciler. + +--- + +## Frontend + +### Current-turn transcript discovery + +- In `apps/web/lib/utils/pending-clarification.ts`, accept the newest durable turn ID from the existing + `turns.bySession` state and bound clarification discovery to it. While turn history is unavailable, + use the session's authoritative `pending_action` rather than treating the latest surviving message + as current; retain latest-message fallback only for legacy sessions with no turn records. +- Build the selected bundle only from that turn and exact `pending_id`; terminal or older-turn rows + cannot become the overlay fallback after a newer bundle is skipped. +- Extend `apps/web/lib/utils/pending-clarification.test.ts` with older-pending/newer-turn, same-turn + bundle, missing-turn legacy, newer-rejected-bundle, and deleted-newer-turn-message cases. + +### Pending-owner task navigation + +- Add a pure resolver in `apps/web/components/task/task-select-helpers.ts` that selects the first + (newest, because the API order is newest-first) input-capable session whose `pending_action` + matches the task summary action. When a task advertises pending input but no matching input-capable + owner exists, fail closed and navigate only to the task; never guess the remembered, primary, or + first session. Clean tasks retain those normal session fallbacks. +- When a desktop task advertises pending input, always finish the existing session-list load before + switching, even if the preferred session already has an environment mapping. Apply the same rule + when the global sidebar starts from a non-task route. Keep the existing selection-token and + external-navigation race guards, and invalidate deferred off-route selection when the pathname + changes or its initiating sidebar callback unmounts. +- Reuse the resolver in + `apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts`; remove the synchronous + primary fast path for a pending task, close the drawer only after the selected session/URL is set, + and preserve failure fallback. +- Permission uses the same navigation rule because `TaskPendingAction` is shared. This plan does not + change permission lifecycle semantics. + +### Mobile design contract + +- **Desktop outcome / phone entry:** both task-row surfaces lead to the session that owns the visible + pending indicator. Phone entry stays the existing Tasks control and task-switcher drawer. +- **Nearest exemplar:** `session-task-switcher-sheet.tsx` remains the shipped inset bottom drawer with + its existing safe-area, focus, dismissal, and internal-scroll behavior. +- **Hierarchy:** task row remains the only choice. No second question-specific control is added. +- **Presentation:** no visual, copy, geometry, or route redesign. +- **Shared logic:** one pure pending-owner resolver drives desktop and phone selection. +- **Touch behavior:** phone E2E uses `.tap()`, asserts drawer dismissal, task URL, active chat, and + question visibility. + +--- + +## Tests + +- **What:** only newest-durable-turn clarification rows are active; older-turn pending rows are + excluded, missing status remains pending only in that turn, deleting every newer-turn message does + not reactivate history, and SQLite/PostgreSQL agree. + - **Files:** `apps/backend/internal/task/repository/sqlite/message_active_clarification_test.go`, + `apps/backend/internal/task/repository/sqlite/message_crud_coverage_test.go`, and + `apps/backend/internal/task/repository/sqlite/message_pending_postgres_test.go` + - **How:** real database tests create two turns with pending rows, delete every message in the newer + turn, and verify the active finder and compact pending projection still exclude the older bundle. +- **What:** repeated detach does not update or publish an already-detached bundle; a new turn prevents + old-row detachment; query failure keeps the workflow guard closed. + - **Files:** `apps/backend/internal/clarification/canceller_test.go`, + `apps/backend/internal/orchestrator/clarification_guard_test.go`, and + `apps/backend/internal/orchestrator/event_handlers_agent_clarification_test.go` + - **How:** focused fakes count writes/events, plus orchestrator service tests around the repository + boundary. +- **What:** current-turn detached response still resumes or dismisses as appropriate, but a stale + older-turn response returns conflict and initiates no agent resume. + - **File:** `apps/backend/internal/clarification/handlers_test.go` + - **How:** handler-to-repository integration with persisted bundles and an empty in-memory store. +- **What:** projector restore, newer ordinary message, terminal events, and CAS races converge to the + authoritative pending map without losing another summary field. + - **Files:** `apps/backend/internal/task/statussummary/projector_test.go` and + `apps/backend/internal/task/service/service_status_summary_rebuild_test.go` + - **How:** table-driven projector loader tests and real summary-store CAS tests, including loader + failure and a competing revision. +- **What:** boot/task-list repair an existing stale summary, not only an absent row. + - **Files:** `apps/backend/internal/backendapp/status_summary_boot_test.go` and the task HTTP handler + tests nearest existing status-summary coverage. + - **How:** persisted stale summary plus current-turn messages, then assert returned/persisted revision + and complete replacement event. +- **What:** loaded transcript discovery follows durable turn identity through message deletion and + preserves legacy no-turn data. + - **File:** `apps/web/lib/utils/pending-clarification.test.ts` + - **How:** pure Vitest message arrays. +- **What:** pending owner outranks remembered/primary selection on desktop and phone while clean tasks + preserve existing preference and async race behavior. + - **Files:** `apps/web/components/task/task-select-helpers.test.ts` and + `apps/web/components/task/mobile/session-task-switcher-sheet-hooks.test.ts` + - **How:** pure resolver cases plus asynchronous selection harnesses. + +--- + +## E2E Tests + +- **Scenario:** an older detached question survives, a newer question is asked and skipped, then the + old question never resurfaces. + - **File:** `apps/web/e2e/tests/task/sidebar-pending-question.spec.ts` + - **What to verify:** current overlay closes, sidebar question icon clears, a later turn completion + cannot re-arm it, and reload preserves the clear state. +- **Scenario:** a secondary session owns the task's only current clarification. + - **File:** `apps/web/e2e/tests/task/sidebar-pending-question.spec.ts` + - **What to verify:** clicking the task row from another task loads sessions, activates the secondary + session instead of the clean primary, and displays the clarification. +- **Scenario:** the same secondary-session task is selected from the phone task drawer. + - **File:** `apps/web/e2e/tests/task/mobile-sidebar-task-actions.spec.ts` + - **What to verify:** touch activation selects the pending secondary, closes the inset drawer, + updates `/t/:taskId`, shows the task title and question, and creates no horizontal overflow. +- **Scenario:** a detached current-turn question remains answerable. + - **File:** existing `apps/web/e2e/tests/chat/clarification.spec.ts` + - **What to verify:** the existing timeout/deferred-answer regression still resumes before a newer + turn supersedes the question. + +Use API helpers only to establish sessions/turns; all outcomes are asserted through task/chat UI and +survive reload. Managed runner builds production artifacts. No fixed sleeps or widened timeouts. + +--- + +## Verification Results + +All locally available task gates passed; environment-gated parity also passed in PR CI: + +- Backend authority: repository, clarification, and orchestrator packages passed. PostgreSQL parity + was skipped locally without `KANDEV_TEST_POSTGRES_DSN` and passed in PR CI. +- Summary convergence: status-summary, task-service, backend-app, and focused handler tests passed. +- Frontend: 3 Vitest files / 68 tests, TypeScript, i18n, and frozen install passed. +- Final review remediation: backend repository, clarification, and orchestrator packages passed; + changed backend lint reported zero issues. Six focused web files passed 108 tests, followed by + TypeScript, zero-warning full lint, i18n check, and i18n ratchet. +- Dispatch-acknowledgement remediation: repository, clarification, and orchestrator packages passed; + focused detached-resume tests and changed backend lint reported zero issues. +- Restart-window remediation: unpublished successor reservations persist exact clarification recovery + identity, startup restores empty unattempted reservations before executor reconciliation, and + message-backed reservations become accepted authority. Focused SQLite/startup tests passed; + PostgreSQL parity is environment-gated. +- Session-selection review remediation: six shared desktop/mobile selection and removal suites passed + 75 tests; typecheck, zero-warning full lint, and the i18n ratchet passed. +- Rejected-load review remediation: both primary-session and sessionless pending-selection fallbacks + revalidate click-time summary ownership; the focused race suite passed 14 tests. +- Review remediation: agentctl acceptance is not reported as success until the reserved successor is + durably published; post-acceptance publication failure remains terminal and rollback-ineligible. + Clarification overlay and transcript filtering now share the same optional authority scope. +- At-most-once remediation: immediately before external executor dispatch, a durable attempt marker + makes restart recovery fail closed across the acceptance/publication crash window. Marker-write + failure rolls back before dispatch; known synchronous rejection remains safely rollback-eligible. +- Ready-race remediation: live reservations broadcast acceptance or rollback. An overlapping ready + event waits for that result and revalidates prompt generation, preventing a delayed predecessor from + completing the successor or running workflow completion against it. +- Final bot-review remediation: clarification persistence operations use fresh bounded contexts; + post-acceptance transport failures run cleanup without reopening the answer; reservation rollback + lets the still-authoritative predecessor ready event complete; startup recovery errors fail before + prompt admission; and stale pending-owner loads still open the requested task on desktop and mobile. + Full clarification/orchestrator tests, ten race-enabled focused repetitions, changed-code backend + lint, 55 focused web tests, zero-warning focused web lint, typecheck, and i18n ratchet passed. +- Follow-up bot review: clarification detach database failures are error-level diagnostics, and pause + cancellation treats a wired no-active-turn snapshot as explicit authority. A first turn created + during detachment is preserved, matching the existing successor-turn protection; both focused races + passed ten repetitions, followed by the full orchestrator and clarification suites and zero-issue + changed-code backend lint. +- Later bot review: failed reserved-turn rollback keeps its live reservation unresolved, blocking ready + handling and prompt admission until restart recovery; clarification detach and expiry repository + operations now use fresh detached 30-second contexts. Both focused regressions passed ten times. +- Final Codex review: task-session HTTP and WebSocket lists fail closed when authoritative pending-action + projection fails. Desktop and mobile async selection also remain inert when the selected task + projection disappears, avoiding navigation to a deleted task. The dual-transport handler regression, + 57 focused web tests, zero-warning focused web lint, typecheck, i18n ratchet, and changed-code Go lint + passed. +- Claude review remediation removes a dead clarification lookup, narrows handler and cancellation + repository interfaces, keeps caller metadata unchanged across any partial completion failure, and + documents the transaction-local claim timestamp. Durable-turn ordering coverage now includes the + created-time tie-break alongside existing load-state, start-time, ID, and nanosecond cases. +- Final Codex review returns restored clarification rows through the repository/service boundary and + publishes their pending state after commit, closing the interleaved task-summary refresh race. The + durable selection overview now matches task-only fallback for changed summaries and inert handling + only when the task projection disappears. Broader clarification-focused handler, repository, and + orchestrator tests passed, followed by zero-issue changed-code Go lint. +- CodeRabbit and Claude follow-up align recovery's attempted-flag decoder with tolerant SQL semantics + and pin the generationless-ready drop after a reservation rollback. Boolean, string, and numeric flag + encodings plus adjacent recovery/ready races passed ten focused repetitions; broader recovery and + ready suites passed, followed by zero-issue changed-code Go lint. +- Latest review remediation bounds synchronous detached resume, acknowledges restored pending state + through the live projector before promising retryability, removes wall-clock polling from repository + timestamp tests through an injected clock, and clarifies why every successor-turn message shares the + PostgreSQL session lock. +- New bot-review remediation uses bounded detached rollback after admission-time context cancellation, + normalizes pending/attempted flag predicates across SQLite and PostgreSQL, surfaces exhausted pending + summary CAS retries, and makes atomic clarification bundle operations a compile-time repository + contract. Focused race checks and all six affected backend package suites passed; the environment-gated + PostgreSQL predicate case skipped locally and remains enabled in CI. +- Follow-up review bounds terminal bundle publication with the same fresh persistence context. The + cancelled-caller regression, full clarification suite, and focused lint passed. +- Latest Codex review makes superseded forced-load aborts inert across desktop and phone and records + initial task-projection presence so deletion also invalidates legacy pending-action selections. This + changes shared selection state only, not mobile composition or touch behavior; focused desktop/mobile + unit coverage satisfies mobile parity without another Playwright case. +- Four focused task-selection suites passed 61 tests; full web lint, typecheck, and the i18n ratchet + passed. +- Late Claude review normalizes the detached-message guard across boolean, numeric, and string truthy + encodings. Restored clarification rows now publish even when synchronous summary acknowledgement is + unavailable or fails, while the acknowledgement error still reaches the caller. +- Full SQLite repository and task-service suites passed in 49s and 129s; changed-code Go lint reported + zero issues. +- Later Codex/Claude review rehydrates keyed Git observations after a nil-aggregate restart and rejects + malformed clarification recovery message-ID arrays instead of silently restoring a subset. Review + wording now distinguishes hidden unattempted reservations from authoritative attempted or + message-backed turns and preserves terminal clarification siblings during rejection. +- Full status-summary and SQLite repository suites passed; changed-code Go lint reported zero issues. +- Final review follow-up applies the same nil-aggregate restart and CAS-rebase guarantee to keyed pull + requests, makes unpublished-turn startup recovery a compile-time turn-repository requirement, and + adds PostgreSQL coverage for string truthy detached flags. Go RFC3339 parsing coverage confirms the + fixed nanosecond wire format remains compatible with existing Go consumers. +- The focused regressions and full status-summary, SQLite repository, task-service, and backend-app + suites passed; changed-code Go lint reported zero issues. The PostgreSQL case skipped locally without + `KANDEV_TEST_POSTGRES_DSN` and remains enabled in PostgreSQL CI. +- Final bot-review remediation rehydrates configured source observations before the first summary row, + keeps a durably restored clarification retryable when live convergence fails, and replaces mobile's + module-global selection sequence with one controller per mounted sheet. Focused Go suites, 42 web + selection tests, typecheck, zero-warning focused lint, and the i18n ratchet passed. +- Latest Codex review fails orchestrator startup when its turn service is absent instead of silently + bypassing reservation recovery, and aligns the remaining stale-summary scenario with task-only phone + navigation. The focused startup regressions and full orchestrator suite passed. +- Follow-up Codex review invalidates the phone task-sheet selection controller on unmount so a deferred + load cannot mutate or close a replacement tablet sheet. The 48-test focused selection run passed. +- Fresh Claude review wires the existing expiry path into every accepted terminal-session transition. + Fresh Codex review propagates status-summary repair failures and removes each failed stale summary from + task-list and boot responses so their authoritative coarse pending action remains visible. Focused + regressions, all five affected backend suites, and changed-code lint passed. +- Managed production E2E: Chromium 3/3 plus detached recovery 1/1; mobile Chrome 12/12. +- Current-head review remediation keeps unpublished attempted reservations out of client turn history + and propagates watchdog cancellation through fallback database and prompt work. Focused SQLite and + orchestrator regressions passed; PostgreSQL parity remains environment-gated in CI. +- Follow-up review adds scheduled positive and superseded watchdog coverage and makes primary and + detached clarification delivery share one claimed-bundle turn-identity rule. +- Late CodeRabbit review restores the planned deterministic tie-breaks to ascending turn-history reads + and adds same-timestamp batch-list coverage. +- Latest CodeRabbit review passes terminal session state through every loaded-message pending-input + selector, so stale current-turn clarification rows cannot re-arm task or session UI after completion + or cancellation. The 55 focused web tests, typecheck, zero-warning focused lint, and i18n ratchet + passed. +- Fresh Claude review makes cancellation and live response delivery choose one winner under the pending + entry lock. A cancellation that wins after response lookup now returns not-found immediately, while + an already-resolved response keeps its durable confirmation path. The deleted-task mobile selection + comment was rejected because the reviewed spec deliberately keeps that stale continuation inert. The + focused races passed ten repetitions, the full clarification race suite passed, and changed-code Go + lint reported zero issues. +- Fresh Codex review makes an explicit clean or permission session projection outrank filtered visible + turn history. This keeps a predecessor clarification inert while an authoritative attempted successor + is intentionally unpublished from client history, across both chat discovery and pending indicators. + The 108 focused web tests, typecheck, zero-warning focused lint, and i18n ratchet passed. +- Follow-up Codex review adds authoritative per-session `pending_action` to semantic message events and + narrowly mirrors it into loaded by-ID and per-task session projections. A newly streamed question now + advances a cached clean projection, while terminal and successor mutations clear it without waiting + for a session-list refresh. Event-arrival ordering stays authoritative across batched message updates. + The 73 focused web tests, full task-service suite, targeted race suite (10 repetitions), web typecheck, + zero-warning focused lint, i18n ratchet, backend changed-lines lint, and public-doc validators passed. +- A second Codex pass orders REST and WebSocket pending-action snapshots with a shared process-epoch + logical revision reserved before each authoritative read. Store merges now reject older projections, + including a deferred task-session list response that resolves after a newer message event. +- Follow-up Claude review separates degraded turn-service warnings from graceful missing-identity debug + logs, rejects partial clarification response-delivery intents, and adds context-aware 1 ms / 2 ms + backoff between startup summary compare-and-set retries. +- Follow-up CodeRabbit review keeps the independent response-delivery recovery pass running after a + malformed reservation while excluding delivery IDs still owned by unresolved prompt reservations. + Detached-bundle recovery coverage also confirms the disconnect marker survives restoration. +- An intermediate exact-head Codex review replaced timestamp generations with opaque UUID epochs and + a bounded client retirement list. A later review exposed the remaining client-reload hole, so the + final design uses a database-backed monotonic generation and numeric client comparison instead. +- Exact-head CodeRabbit review bounds a responder waiting for an already-started durable confirmation + while leaving the waiter to retain its eventual result, and consolidates HTTP and WebSocket session + summary projections behind one helper. +- Latest exact-head Codex review rejects unseen stale epochs after client state rebuild and routes + canceller-originated message updates through the task service's projection-aware publisher. The + former uses an atomic `kandev_meta` generation allocated at backend startup; the latter covers both + expired bundles and first-observed detachment. +- This remediation batch passed all affected backend package suites (backend app, clarification, + MCP handlers, orchestrator, task handlers, SQLite repository, and task service), focused race + tests with three final repetitions, 39 focused web tests, the superseded-clarification E2E + regression, web typecheck, zero-warning frontend and Go changed-lines lint, format and i18n + checks, and both public-doc validators. +- The final review follow-up passed all affected backend suites again, both projection-focused web + suites (21 tests), web typecheck, zero-warning frontend and Go changed-lines lint, i18n checks and + ratchet, and the public-doc validator's 61 tests plus 41-page scan. +- Latest Claude review bounds the sidebar's message-only clarification fallback to the newest visible + turn. A second suggestion was already covered by `clearPendingLocked`; explicit restart coverage now + proves stale dismissal clears and persists an aggregate-only restored pending action. The three + affected web suites (110 tests) and full status-summary package pass. +- Exact-head Codex review makes that sidebar fallback use durable message creation order rather than + WebSocket arrival order. A delayed predecessor-turn frame can no longer hide the active current-turn + clarification; the five affected web suites (127 tests), typecheck, and zero-warning focused lint pass. +- Exact-head CodeRabbit review makes clarification restore contingent on the still-live durable delivery + marker, so a late successful confirmation cannot be reopened after the responder times out. It also + keeps aborted sessionless mobile selection inert, guarantees blocked test cleanup, and covers deleted + message pending-action projections. All affected backend packages, 81 focused web tests, typecheck, + zero-warning frontend lint, and changed-code Go lint pass. +- Exact-head Claude review replaces a watchdog test's hot polling loop with a bounded eventual assertion + and documents why the legacy event path intentionally relies on the repository's current-turn SQL + authority guard. Both focused orchestrator regressions pass. +- The next Claude and CodeRabbit pass keeps a stale-dismiss event from clearing a different pending + identity in event-only projector mode and makes an active-turn lookup error outrank any partial turn + returned alongside it. Both review regressions pass. +- The latest review pass bounds terminal clarification expiry before state publication, patches active + turn metadata under session authority, exposes mixed-turn bundle corruption in warning logs, and + makes blocking test cleanup failure-safe. PostgreSQL-gated epoch coverage now checks monotonic and + corrupt-value behavior. All affected backend suites and changed-code lint pass; the PostgreSQL case + is locally skipped when `KANDEV_TEST_POSTGRES_DSN` is unavailable. +- Exact-head Greptile and Codex follow-up closes the remaining writer and deadline gaps: full turn + snapshots now lock and reject stale versions, active metadata patches return their committed state + without a fallible post-commit read, and terminal expiry preserves the orchestrator's shorter deadline + through the cancellation-detached persistence layer. Cross-dialect lock coverage is PostgreSQL-gated; + all locally runnable affected suites pass. +- The accompanying CodeRabbit pass makes reserved-turn waiter cleanup immediate and idempotent in five + agent-ready regressions. A later exact-head summary regression enforces the feature spec by invalidating + a known-stale status-summary row when pending-action CAS repair exhausts its retries, preserving the + authoritative coarse fallback. A later exact-head authority regression supersedes the delayed suggestion + to restrict add/delete events: all message additions and deletions refresh pending action because they + can add or remove the evidence that makes a reserved successor authoritative; only ordinary content + updates omit the projection. +- A final Codex race follow-up routes late prompt-usage and agent-identity fields through an atomic + metadata patch that works after reservation publication and turn completion. The stale full-snapshot + CAS remains as a clobber guard, while publication-winning usage metadata now persists on retry-free + merge semantics. Full SQLite repository, task-service, and orchestrator suites pass. +- Exact-head Codex and Greptile follow-up preserves both client and restart convergence. Ordinary message + add/delete events now carry the authoritative pending-action projection when message evidence changes + turn authority. Reserved-turn publication keeps recovery markers durable until `turn.started` is + accepted, surfaces bus failure, then clears only reservation metadata under session authority even if + a fast provider completed the turn during publication. Focused regressions and the full task, + integration, backend-composition, and orchestrator suites pass. +- Delayed CodeRabbit and Claude follow-up makes anomalous nil session loads fail turn reservation without + a panic, documents all three pending-clarification scope states (including the empty-object trap), and + emits a structured warning with task, attempt count, and last revision when summary reconciliation + exhausts its compare-and-set retry budget. Focused backend tests and focused frontend formatting/lint + pass. +- Exact-head Codex follow-up sanitizes private prompt-dispatch recovery fields from every public turn + event. The concurrent publication regression now completes through the task service and proves a fast + `turn.completed` payload cannot retain the pre-cleanup metadata. +- A further exact-head Codex regression marks the known-stale summary invalid in partial boot/task-list + results when all pending-action compare-and-set retries lose. The authoritative coarse task fallback + remains visible instead of being shadowed by the stale summary. +- Exact-head Greptile review adds a durable startup event outbox for accepted and ambiguous prompt + reservations. Recovery replaces private reservation fields with a start-event marker, replays + `turn.started` and any already-required `turn.completed` event in order, and clears the marker only + after successful publication. Failed replay now fails startup and remains retryable across restarts. +- Replay regressions, the full task subtree, backend composition, integration, orchestrator, executor, + and changed-code Go lint pass. PostgreSQL parity remains CI-gated when no local DSN is configured. +- Delayed Codex and Claude review makes summary invalidation explicit across the Go DTO and shared + desktop/mobile snapshot merge while protecting a newer in-flight WebSocket revision. It also caps + post-deadline delivery-confirmation waiting at five minutes, treats partial clarification recovery + metadata as a startup error, suppresses expected cancellation warnings, documents the duplicated SQL + bind, and pins numeric epoch ordering across `"9"` to `"10"`. +- All affected Go package suites, 31 focused web tests, frontend typecheck, zero-warning frontend and + changed-code Go lint, and the i18n ratchet pass. +- Latest Claude and CodeRabbit follow-up reports corrupt persisted projection epochs with an actionable + metadata diagnostic and makes a response claim immutable across an outliving confirmation callback. + Callback output remains locally owned, while durable finalization and recovery continue to serialize. +- Focused confirmation, immutable-snapshot, projection-epoch, and race-enabled regressions pass, along + with the full clarification and SQLite repository suites. +- `git diff --check` passed. All runners used isolated test state and exited cleanly. + +--- + +## Implementation Waves And Parallel Candidates + +Execution remains sequential in the primary conversation. + +Wave 1: + +- [x] [task-01-clarification-regression-red](task-01-clarification-regression-red.md) + +Wave 2: + +- [x] [task-02-current-turn-backend-authority](task-02-current-turn-backend-authority.md) + +Wave 3: + +- [x] [task-03-summary-pending-convergence](task-03-summary-pending-convergence.md) + +Wave 4: + +- [x] [task-04-pending-owner-navigation](task-04-pending-owner-navigation.md) + +Wave 5: + +- [x] [task-05-clarification-regression-green](task-05-clarification-regression-green.md) + +No task is marked parallel-safe. Tasks 02 and 03 share pending repository contracts; tasks 03 and 04 +share projection semantics; task 05 spans all layers. Waves do not authorize subagents. + +--- + +## Risks + +- Durable-turn ordering must agree across dialects. Use the same `started_at`, `created_at`, and `id` + ordering in scalar and batch queries, then run the env-gated PostgreSQL behavior test. +- Clearing on any ordinary message without checking its turn would recreate event-order bugs. The + repository refresh, not message type, decides whether pending state changed. +- Read-time summary repair can race the live projector. Bounded CAS reload/retry and projector + resynchronization must preserve newer unrelated summary fields. +- A stale tab can submit after another tab starts a new turn. The response guard must run before any + fallback persistence or resume request. +- Pending task selection adds an async list request to a formerly synchronous fast path. Existing + selection tokens, external-navigation guard, layout cleanup, URL ordering, and phone drawer failure + fallback must remain intact. +- Historical rows remain `pending` in raw metadata. Any future consumer must use the active repository + projection rather than inventing another all-history scan. + +## Out of scope + +- Direct cleanup/backfill of the main instance database. +- Schema changes or unrelated task-summary wire-shape changes. +- Clarification UI redesign or new user-facing copy. +- Historical notification retraction. +- Permission lifecycle changes beyond shared pending-owner navigation. diff --git a/docs/plans/clarification-active-lifecycle/task-01-clarification-regression-red.md b/docs/plans/clarification-active-lifecycle/task-01-clarification-regression-red.md new file mode 100644 index 0000000000..9ae447e7de --- /dev/null +++ b/docs/plans/clarification-active-lifecycle/task-01-clarification-regression-red.md @@ -0,0 +1,86 @@ +--- +id: "01-clarification-regression-red" +title: "Clarification regression red" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/clarification-active-lifecycle/spec.md" +--- + +# Task 01: Clarification regression red + +## Acceptance + +- Desktop production-build E2E reproduces the old-detached/newer-skipped sequence and fails because + the old question or task icon resurfaces after reload or later completion. +- Desktop E2E creates a task whose non-primary session owns clarification and fails because task-row + activation opens the clean preferred/primary session. +- Phone E2E reproduces secondary pending ownership with touch activation and fails on active chat or + question visibility while preserving existing drawer assertions. +- Each failure is captured once for the expected product reason before production code changes. + +## Verification + +```bash +(cd apps && pnpm install --frozen-lockfile) +(cd apps/web && pnpm e2e:run tests/task/sidebar-pending-question.spec.ts) +(cd apps/web && pnpm e2e:run --no-build --project mobile-chrome tests/task/mobile-sidebar-task-actions.spec.ts) +``` + +The focused commands are expected to exit nonzero in this RED task. Record the exact failing +assertions and fresh artifact paths; do not weaken assertions or increase timeouts. + +## Files likely touched + +- `apps/web/e2e/tests/task/sidebar-pending-question.spec.ts` +- `apps/web/e2e/tests/task/mobile-sidebar-task-actions.spec.ts` +- `apps/web/e2e/helpers/clarification.ts` only if shared current test setup removes duplication +- `apps/web/e2e/pages/session-page.ts` only if a stable existing surface lacks a reusable locator + +## Dependencies + +None. + +## Parallelism + +Sequential. These black-box failures define every later task's acceptance boundary. + +## Inputs + +- Spec scenarios for newer-turn supersession and secondary-session navigation. +- Existing `/e2e:clarification-timeout` and `/e2e:clarification` mock scenarios. +- Existing `ApiClient.addUserMessage`, `ApiClient.launchSession`, `SessionPage`, and sidebar helpers. +- E2E guidance: seed via API, assert through UI, verify reload, use `.tap()` on phone, and run managed + production builds. + +## Risks + +- Confirm the second clarification has a different `pending_id` before clicking Skip; identical prompt + text is not sufficient evidence. +- Wait on exact session states and persisted messages, not arbitrary sleeps or agent output alone. +- Keep mobile file naming/project routing intact so Playwright discovers the intended test. + +## Output contract + +Report scenarios added, exact commands, expected failure messages/artifact paths, and why each failure +matches the confirmed defect. Mark this task `done`, retain failing tests for later GREEN, and update +the plan checkbox/results. + +## Results + +- Added production-build regressions for older detached clarification supersession, desktop + secondary-session ownership, and phone drawer secondary-session ownership. +- `cd apps && pnpm install --frozen-lockfile` passed (already up to date). +- `cd apps/web && pnpm e2e:run tests/task/sidebar-pending-question.spec.ts` discovered 3 tests: + 1 passed and 2 failed for the expected defects. Reload found one superseded + `clarification-overlay` instead of zero; desktop activation selected the clean primary session + instead of the clarification-owning secondary. Artifacts were reported under + `/tmp/pw-out/task-sidebar-pending-quest-5d9bc-r-a-newer-bundle-is-skipped-chromium/` and + `/tmp/pw-out/task-sidebar-pending-quest-6afec-ion-that-owns-clarification-chromium/`. +- `cd apps/web && pnpm e2e:run --no-build --project mobile-chrome tests/task/mobile-sidebar-task-actions.spec.ts` + discovered 12 tests. The new phone regression failed for the expected reason: active session was + the clean primary instead of the clarification-owning secondary. Artifact was reported under + `/tmp/pw-out/task-mobile-sidebar-task-a-5cc01-ation-from-the-phone-drawer-mobile-chrome/`. + Ten baselines passed; one unrelated existing create-subtask case timed out during setup and will be + rerun in the GREEN wave. diff --git a/docs/plans/clarification-active-lifecycle/task-02-current-turn-backend-authority.md b/docs/plans/clarification-active-lifecycle/task-02-current-turn-backend-authority.md new file mode 100644 index 0000000000..004317b7b3 --- /dev/null +++ b/docs/plans/clarification-active-lifecycle/task-02-current-turn-backend-authority.md @@ -0,0 +1,213 @@ +--- +id: "02-current-turn-backend-authority" +title: "Current-turn backend authority" +status: completed +wave: 2 +depends_on: ["01-clarification-regression-red"] +plan: "plan.md" +spec: "../../specs/clarification-active-lifecycle/spec.md" +--- + +# Task 02: Current-turn backend authority + +## Acceptance + +- One repository method returns only pending clarification rows in the newest durable turn, with + matching SQLite/PostgreSQL behavior and legacy missing-status support after ownership matches. +- Deleting every message from a newer turn cannot reactivate a pending clarification from an older + turn. +- Detach/expiry fallback and workflow guarding consume that method; repeated detach writes and + publishes nothing, while repository errors keep the workflow barrier closed. +- A detached bundle in the current turn accepts a late answer only after one bounded, acknowledged + orchestrator resume dispatch, or a rejection without resuming the agent. It does not wait for the + resumed turn to complete. Any database-fallback answer or rejection for an + older-turn or terminal bundle returns conflict, performs no write, and initiates no agent resume. + +## Verification + +```bash +cd apps/backend && go test ./internal/task/repository/sqlite ./internal/clarification ./internal/orchestrator +``` + +The PostgreSQL behavior test skips locally unless `KANDEV_TEST_POSTGRES_DSN` is set and runs in the +repository's PostgreSQL CI job. + +## Files likely touched + +- `apps/backend/internal/task/repository/sqlite/message.go` +- `apps/backend/internal/task/repository/sqlite/message_active_clarification_test.go` +- `apps/backend/internal/task/repository/sqlite/message_crud_coverage_test.go` +- `apps/backend/internal/task/repository/sqlite/message_pending_postgres_test.go` +- `apps/backend/internal/task/repository/interface.go` +- `apps/backend/internal/clarification/canceller.go` +- `apps/backend/internal/clarification/canceller_test.go` +- `apps/backend/internal/clarification/handlers.go` +- `apps/backend/internal/clarification/handlers_test.go` +- `apps/backend/internal/orchestrator/service.go` +- `apps/backend/internal/orchestrator/clarification_guard.go` +- `apps/backend/internal/orchestrator/clarification_guard_test.go` +- Focused repository-interface mocks that compile against the renamed method + +## Dependencies + +Task 01. + +## Parallelism + +Sequential. Task 03 consumes this repository authority and its exact failure semantics. + +## Inputs + +- Spec `What`, `API surface`, `State machine`, and stale-client scenarios. +- ADR current-turn ownership decision. +- Existing `pendingActionsBySessionQuery` and `pendingActionMessageOrder` dialect patterns. +- Existing clarification handler primary, detached fallback, and stale-dismissed paths. + +## Risks + +- Do not filter solely by maximum timestamp; use the shared `started_at`, `created_at`, and `id` + ordering for tied values. +- Derive the boundary from `task_session_turns`, never the latest surviving message. +- Do not reject a detached current-turn bundle merely because the in-memory Store no longer owns it. +- Use `context.WithoutCancel` for terminal writes already promised durable by the handler. +- Do not make query failure look like no pending clarification in workflow guarding. + +## Output contract + +Use TDD: add focused failing repository/canceller/handler/guard cases, run RED, implement minimally, +then run the exact package command. Report files, test counts/results, PostgreSQL skip/run state, +blockers/risks, and update task/plan status. + +## Results + +- Added newest-durable-turn clarification authority for SQLite and PostgreSQL, including stable turn + ordering, missing-status compatibility, and deletion-proof ownership. +- Routed canceller, workflow guard, and detached-response fallback through that authority. Repeated + detachment is a no-op; superseded/terminal responses return conflict without writes or resume + events; repository errors fail closed. +- PR review follow-up now claims durable current-turn ownership before live waiter delivery, withholds + terminal message events until delivery succeeds, and restores a still-current detached bundle when + acknowledged resume acceptance fails so the answer can be retried. The detached HTTP path calls the + orchestrator synchronously, so executor rejection cannot be hidden by event-bus publication success. + SQLite concurrency, rollback, supersession, cancelled-context, and PostgreSQL-dialect cases pin the + behavior. +- Detach and expiry counts now include only bundles whose messages changed. Malformed messages without + their schema-required durable turn remain inert instead of becoming pending authority. +- Final review remediation lets atomic response claims recover pending rows from a mixed-status bundle, + validates answers only for those pending rows, and preserves terminal siblings; targeted rollback + restores only rows owned by the failed delivery attempt. +- Restore serialization uses copied metadata, so a failed transactional write cannot mutate its + in-memory terminal snapshot. +- Detached HTTP resume now preserves a 30-second context through the executor and uses dispatch-only + acknowledgement, so request completion cannot wait for the full agent turn. +- Detached resume now reserves its successor durably before agentctl acknowledgement so early provider + frames retain a valid turn foreign key, while an unpublished marker keeps the empty row from becoming + current-turn authority. The reservation stores source turn, pending ID, and exact claimed message IDs. +- Startup reconciliation runs before executor discovery, deletes empty unattempted unpublished + reservations, and restores only their claimed clarification rows. A message-backed reservation is + preserved and its marker cleared as ambiguous acceptance. SQLite/startup regressions passed; + PostgreSQL parity remains environment-gated. +- Review remediation now reports agentctl acceptance followed by successor-publication failure as a + non-retryable server error. It keeps the answer terminal, removes rollback eligibility, and preserves + active process ownership so the accepted prompt cannot be dispatched twice in-process. +- At-most-once remediation persists `prompt_dispatch_attempted=true` immediately before the external + executor call. Startup preserves an attempted empty successor as dispatch-ambiguous authority; + marker-write failure rolls back before dispatch, while known synchronous rejection can still restore + the claimed clarification. +- Ready-race remediation gives each live reservation an accepted-or-rolled-back completion signal. + `agent.ready` waits on that signal outside the cancellation guard, reacquires the guard, and + revalidates prompt generation before it can settle turns or run workflow completion. +- Bot-review remediation bounds each clarification claim and restore database operation with a fresh + detached 30-second context. Post-acceptance transport errors now run normal failure cleanup while + retaining the non-retryable accepted marker, including when durable publication also fails. +- A ready event resumes normal predecessor completion after a reservation rolls back and its prompt + generation remains authoritative. Startup now fails before watcher, scheduler, or prompt admission + when any unpublished reservation cannot be reconciled. +- Follow-up review remediation represents a wired no-active-turn snapshot explicitly during + clarification-pause cancellation. A first turn created during detachment now rejects the stale pause + just like a successor to an existing turn; the unwired turn-service fallback stays unchanged. +- Later review remediation keeps a failed reserved-turn rollback unresolved in memory, quarantining + ready handling and later prompt admission until restart recovery. Detach and expiry repository work + now reuse the fresh detached 30-second persistence context instead of stripping every deadline. +- Session cancellation now drains all in-memory waiters but mutates and counts only bundles returned by + durable current-turn authority, so a stale timeout cannot cancel a newer active turn. +- Final review remediation makes that durable detach an atomic `UPDATE ... RETURNING` claim over the + current turn, pending status, and non-detached marker. Restore writes recheck current-turn ownership + in the update itself, unexpected live-delivery failures distinguish recovered retry state, and test + doubles preserve caller-owned claim snapshots instead of mutating them in place. +- SQLite and environment-gated PostgreSQL regressions cover one-shot detachment and the successor-turn + restore guard. +- `cd apps/backend && go test ./internal/task/repository/sqlite ./internal/clarification ./internal/orchestrator` + passed. The environment-gated PostgreSQL case skipped locally because + `KANDEV_TEST_POSTGRES_DSN` was unset; it remains enabled for PostgreSQL CI. +- The same exact package command passed again after review remediation; changed-code `golangci-lint` + reported zero issues against merge base `8c9456074a2f61abec48ddd8742ec81635faa16e`. +- After dispatch-acknowledgement remediation, the repository, clarification, and orchestrator package + command passed again; focused detached-resume tests and changed-code `golangci-lint` also passed. +- Bot-review remediation passed the full clarification and orchestrator suites, changed-code + `golangci-lint` with zero issues, and ten race-enabled repetitions of the reservation-ready and + post-acceptance failure regressions. +- The explicit no-turn cancellation regression and the existing successor-turn case passed ten + focused repetitions; the full orchestrator and clarification suites passed, and changed-code + `golangci-lint` reported zero issues. +- Failed-rollback quarantine plus bounded detach/expiry context regressions passed ten focused + repetitions. +- Task-session HTTP and WebSocket list handlers now return an internal error when authoritative pending + projection fails instead of silently presenting every session as clean; the focused dual-transport + regression and changed-code Go lint passed. +- Claude review remediation splits the handler and canceller repository surfaces, removes the unused + pending-session lookup, and clones completion metadata without exposing partial in-memory mutation on + write failure. The failed second-write regression and adjacent claim/restore cases passed. +- Final Codex review returns committed pending rows from restoration and publishes them before reporting + a retryable resume rejection. The interleaved-refresh handler regression, repository restore cases, + detached-resume orchestrator case, and service/adapter compile checks passed. +- CodeRabbit remediation decodes boolean, string, and numeric attempted markers consistently with SQL, + preventing ambiguous accepted reservations from being deleted during recovery. Claude follow-up adds + direct coverage for dropping a generationless ready after rollback; both groups passed ten times. +- Final review remediation bounds synchronous detached resume with the same fresh 30-second context as + durable claim and restore work. Repository tests now inject exact mutation clocks, removing + scheduler-dependent timestamp-boundary sleeps while retaining nanosecond ordering coverage. +- Latest bot review remediation rolls back failed successor-turn and attempt-marker admission through a + fresh bounded context after caller cancellation. Shared SQLite/PostgreSQL predicates now recognize + boolean, numeric, and string pending/attempted encodings; focused regressions cover both rollback paths + and both database dialects. Focused race checks and the six affected backend package suites passed; + the PostgreSQL case skipped locally without `KANDEV_TEST_POSTGRES_DSN` and remains enabled in CI. +- Follow-up review bounds every terminal clarification publication with the fresh 30-second persistence + context. A cancelled-caller regression and the full clarification suite passed with zero lint issues. +- Later Claude review makes malformed claimed-message ID arrays fail startup recovery atomically instead + of silently restoring a subset. The SQLite early-return path documents its database-level writer + serialization, and the pending-action query documents its current-turn session boundary. +- The full SQLite repository suite passed; changed-code Go lint reported zero issues. +- Final review follow-up moves unpublished-turn reconciliation onto the compile-time turn-repository + contract and fails loudly when the service is miswired. PostgreSQL parity coverage now pins string + `"true"` and `"1"` detached flags alongside the SQLite cases. +- The focused service regression, full SQLite repository and task-service suites, handler compile, and + changed-code Go lint passed. The PostgreSQL test remains environment-gated and skipped locally. +- Final Claude review distinguishes durable restore from live convergence: once the database bundle is + pending again, a publication or summary error is logged but the response still identifies the answer + as safe to retry. The focused failure cases and full clarification suite passed. +- Latest Codex review makes the orchestrator's composition boundary fail closed when no turn service is + wired, preventing watcher, scheduler, and prompt admission startup without reservation recovery. +- Fresh Claude review turns the pre-existing expiry scaffold into shipped behavior: any accepted + completed, failed, or cancelled session transition expires its current-turn pending bundles. Focused + coverage and the full clarification and orchestrator suites passed. +- Delayed CodeRabbit review guards the anomalous `(nil, nil)` session-repository result before turn + admission dereferences it. `ReserveTurn` now returns an error instead of panicking, with a focused + regression. +- Exact-head Codex review makes public turn-event metadata recovery-clean at the shared publication + boundary. The fast-completion regression now uses `Service.CompleteTurn` and verifies the concurrently + emitted completion event does not leak any `prompt_dispatch_*` fields. +- Exact-head Greptile review adds a durable startup start-event marker for accepted and ambiguous + reservations. Startup replays `turn.started` and any already-required `turn.completed` event before + clearing it; a failed replay fails startup and leaves the marker for the next restart. +- Replay regressions, the full task subtree, backend composition, integration, orchestrator, executor, + and changed-code Go lint pass. PostgreSQL parity remains CI-gated without a local DSN. +- Delayed Claude review caps post-deadline delivery-confirmation waiting at five minutes, fails startup + on partial clarification recovery metadata, logs expected watchdog cancellation at debug, and + documents the intentional duplicated pending-ID SQL bind. +- Clarification, SQLite repository, orchestrator, and changed-code Go lint pass after remediation. +- Latest CodeRabbit follow-up makes claimed messages immutable after construction and keeps finalized + snapshots callback-owned. A callback may complete after the responder's bounded wait without racing + recovery reads; the existing repository guards serialize its durable outcome against compensation. +- Focused immutable-snapshot and confirmation tests pass under the race detector, and the full + clarification suite passes. diff --git a/docs/plans/clarification-active-lifecycle/task-03-summary-pending-convergence.md b/docs/plans/clarification-active-lifecycle/task-03-summary-pending-convergence.md new file mode 100644 index 0000000000..446df5b9a9 --- /dev/null +++ b/docs/plans/clarification-active-lifecycle/task-03-summary-pending-convergence.md @@ -0,0 +1,126 @@ +--- +id: "03-summary-pending-convergence" +title: "Summary pending convergence" +status: completed +wave: 3 +depends_on: ["02-current-turn-backend-authority"] +plan: "plan.md" +spec: "../../specs/clarification-active-lifecycle/spec.md" +--- + +# Task 03: Summary pending convergence + +## Acceptance + +- The production projector derives pending state from a bounded authoritative loader on restore and + pending-sensitive events, including an ordinary message in a newer turn and deletion of that turn's + last message; event order and message deletion cannot re-arm an older request. +- Boot/task-list hydration repairs an existing stale pending field with monotonic bounded CAS, + preserves every unrelated summary field, returns the corrected row, and publishes a complete + replacement on semantic change. +- Loader errors retain last known pending state; CAS rejection reloads and resynchronizes before + retry/later events. + +## Verification + +```bash +cd apps/backend && go test ./internal/task/statussummary ./internal/task/service ./internal/backendapp +``` + +## Files likely touched + +- `apps/backend/internal/task/statussummary/projector.go` +- `apps/backend/internal/task/statussummary/projector_events.go` +- `apps/backend/internal/task/statussummary/projector_helpers.go` +- `apps/backend/internal/task/statussummary/projector_test.go` +- `apps/backend/internal/task/service/service_status_summary_rebuild.go` +- `apps/backend/internal/task/service/service_status_summary_rebuild_test.go` +- `apps/backend/internal/task/handlers/task_http_handlers.go` +- Task HTTP status-summary test nearest the existing list hydration coverage +- `apps/backend/internal/backendapp/gateway.go` +- `apps/backend/internal/backendapp/boot_state.go` +- `apps/backend/internal/backendapp/status_summary_boot_test.go` + +## Dependencies + +Task 02. + +## Parallelism + +Sequential. It shares pending repository semantics with Task 02 and defines the task action consumed +by Task 04. + +## Inputs + +- Spec persistence, failure, and summary-repair scenarios. +- `statussummary.BuildFromAuthoritative`, `SummaryUpdated`, and existing queued-count CAS retry pattern. +- Task list/boot already batch-load sessions and `GetPendingActionsBySessionIDs`; reuse those bounded + inputs. + +## Risks + +- Never rebuild unrelated fields of an existing summary from incomplete read inputs. +- A successful CAS followed by publication failure must not roll back the stored correction. +- Avoid self-subscription loops when publishing `task.status_summary.updated`. +- Do not add transcript reads or one query per task to normal list hydration. + +## Output contract + +Use TDD: prove stale restore, newer-turn clear, loader failure, existing-row repair, and CAS contention +fail first. Run the exact package command, report test counts/results and event behavior, reconcile +actual files, then update task/plan status. + +## Results + +- Added an authoritative pending-action loader to the live projector. Restore, message, + clarification, permission, task, and session-state occurrences refresh bounded repository state; + lookup failure retains the stored action, and CAS rejection reloads/retries. +- Replaced missing-only hydration with summary reconciliation. Existing rows update only + `pending_action`, advance revision/time, preserve unrelated fields, and publish one complete + `task.status_summary.updated` replacement. Boot and task-list assembly now invoke it. +- Added restore, ordinary-message, deletion, loader-error, CAS-contention, boot, and task-list + regressions. +- PR review follow-up preserves the last valid summary snapshot when a task disappears during a + rejected-CAS reload, avoiding a nil overwrite and spurious missing-summary rebuild. CAS retries also + reload session state before re-deriving pending ownership. +- `cd apps/backend && go test ./internal/task/statussummary ./internal/task/service ./internal/backendapp` + passed. The focused task-handler reconciliation test also passed. +- Full `go test ./internal/task/service -count=1` passed after the follow-up (291.044s). +- Final review remediation wires the live projector back into the task service as a synchronous + acknowledgement boundary. Restored clarification publication now returns an error until durable + pending-summary convergence succeeds, with focused service and handler regressions. +- Latest review remediation surfaces bounded summary CAS exhaustion instead of returning a false + acknowledgement. Atomic clarification bundle methods now belong to the compile-time message repository + contract rather than an optional runtime assertion. The focused CAS race check and full status-summary, + task-service, and backend-app suites passed. +- Late Claude review publishes every committed restored clarification row even when synchronous summary + acknowledgement is missing or fails, while preserving that error as the caller result. SQLite's + detached guard now treats string `"true"` and `"1"` like boolean/numeric truthy metadata. +- Full SQLite repository and task-service suites passed in 49s and 129s; changed-code Go lint reported + zero issues. +- Later Codex review rehydrates keyed Git observations whenever the loader is configured, including a + restart whose persisted aggregate is nil. The nil-baseline multi-repository regression and full + status-summary suite passed; changed-code Go lint reported zero issues. +- Final review follow-up rehydrates keyed pull-request observations under the same nil-aggregate rule + during both restart restore and rejected-CAS rebase, preserving unchanged sibling pull requests when + the next source event updates one key. +- The focused restart and rejected-CAS regressions and full status-summary suite passed; changed-code Go + lint reported zero issues. +- Final Codex review moves configured session, Git, and pull-request rehydration ahead of missing-row + creation, so the first keyed event cannot omit unchanged siblings. The focused missing-row regression + and full status-summary suite passed. +- Fresh Codex review propagates existing-summary repair failures, deletes the failed stale row from the + response map, and makes task-list and boot assembly use that partial authoritative result. Focused + coverage plus the full task-service, handler, and backend-app suites passed. +- Delayed Claude review adds a structured warning at compare-and-set exhaustion with the task ID, + configured attempt count, and last observed revision. The existing exhaustion regression now asserts + that observability contract. +- Exact-head Codex review aligns exhaustion with the feature spec: a summary still disagreeing with the + authoritative pending action after all retries is explicitly invalidated, so task-list and boot + consumers clear cached stale state and use the coarse pending-action fallback. Delayed Codex follow-up + carries that invalidation through the Go DTO and shared desktop/mobile snapshot merge without erasing + a newer WebSocket revision received while the read was in flight. +- Task service, DTO, handler, backend composition, 31 focused web tests, frontend typecheck/lint, and + changed-code Go lint pass after remediation. +- Latest Claude follow-up turns a corrupt persisted projection epoch's otherwise opaque `sql.ErrNoRows` + into an actionable canonical-positive-integer diagnostic, with a focused regression. diff --git a/docs/plans/clarification-active-lifecycle/task-04-pending-owner-navigation.md b/docs/plans/clarification-active-lifecycle/task-04-pending-owner-navigation.md new file mode 100644 index 0000000000..649b94bac4 --- /dev/null +++ b/docs/plans/clarification-active-lifecycle/task-04-pending-owner-navigation.md @@ -0,0 +1,139 @@ +--- +id: "04-pending-owner-navigation" +title: "Pending-owner navigation" +status: completed +wave: 4 +depends_on: ["03-summary-pending-convergence"] +plan: "plan.md" +spec: "../../specs/clarification-active-lifecycle/spec.md" +--- + +# Task 04: Pending-owner navigation + +## Acceptance + +- Loaded-message clarification discovery uses the newest durable turn from existing turn state, + survives deletion of all newer-turn messages, and preserves legacy behavior when no turn record + exists. +- Desktop task activation loads sessions and chooses the newest input-capable session matching the + task's pending action before remembered/primary preference, including activation from a non-task + route; clean-task behavior and async selection guards stay unchanged. +- Phone task-drawer activation uses the same resolver, closes only after navigation state is applied, + and preserves the existing failure fallback and inset-drawer interaction. + +## Verification + +```bash +(cd apps && pnpm install --frozen-lockfile) +(cd apps && pnpm --filter @kandev/web test -- lib/utils/pending-clarification.test.ts components/task/task-select-helpers.test.ts components/task/mobile/session-task-switcher-sheet-hooks.test.ts) +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm run i18n:check) +``` + +## Files likely touched + +- `apps/web/lib/utils/pending-clarification.ts` +- `apps/web/lib/utils/pending-clarification.test.ts` +- `apps/web/components/task/task-select-helpers.ts` +- `apps/web/components/task/task-select-helpers.test.ts` +- `apps/web/components/task/task-session-sidebar.tsx` +- `apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts` +- `apps/web/components/task/mobile/session-task-switcher-sheet-hooks.test.ts` + +## Dependencies + +Task 03. + +## Parallelism + +Sequential. Desktop and phone must share one resolver and the server projection established by Task 03. + +## Inputs + +- Spec current-turn transcript and pending-owner scenarios. +- Mobile design contract in `plan.md`. +- Existing `resolvePreferredSessionId`, `resolveLoadedSessionId`, selection-token guard, and + `TaskSession.pending_action` API type. +- Existing phone inset task drawer and `.tap()` E2E conventions. + +## Risks + +- Summary presence is authoritative: do not fall back to a stale legacy task pending action when a + present summary explicitly omits it. +- Filter pending owners to input-capable session states and matching action type. +- Preserve newest-first API ordering; do not sort by localized labels or client clocks. +- Do not close the phone drawer before a failed session load has applied its safe task-level fallback. +- No new user-facing copy; if implementation unexpectedly adds any, route it through i18n. + +## Output contract + +Use TDD: add current-turn and pending-owner cases, observe RED, implement shared logic, then run every +exact command. Report test counts/results, desktop/phone behavior, blockers/risks, actual files, and +update task/plan status. + +## Results + +- Clarification discovery now follows the newest durable turn, keeps earlier active bundles visible + when a newer bundle is terminal, remains deletion-proof, and gates unavailable turn history on the + compact session pending action while retaining legacy no-turn behavior. +- PR review follow-up keeps superseded pending-metadata rows as inert transcript history when turns + are unhydrated and the server reports no active clarification, while hiding only the authoritative + latest bundle when clarification remains active. The explicit no-turn legacy overlay path is pinned. +- Mixed-status recovery counts terminal siblings for arrival completeness but renders only pending + questions, so already-answered cards never request replacement answers. +- The all-unloaded boot state hides pending overlays until turn or compact session authority arrives. +- Desktop navigation re-reads the active source session after asynchronous loading, and mobile item + projection reuses the shared task pending-action authority helper. +- Closing the mobile task sheet invalidates any asynchronous selection before a later reopen, preventing + an old session load from navigating or closing the new sheet instance. +- Added one shared pending-owner resolver. Desktop waits for session loading when a task advertises + input, including non-task routes; phone selection waits, navigates to the owning session, then + closes the drawer, with safe load-failure fallback. +- Final review remediation forces a no-cache session request for pending selections and rejects a + failed forced refresh instead of using a stale owner. Desktop and phone capture the click-time + summary revision/action and discard delayed session results after either value changes. +- Later review remediation generation-guards every per-task session request that reaches the network, + preventing an older forced response from overwriting a newer snapshot. Sessionless pending fallback + also releases the outgoing layout before clearing the active session on desktop; the shared loader + preserves the same freshness rule for phone selection. +- Focused races cover a newer summary arriving before the delayed HTTP continuation on both desktop + and phone; shared session-loader tests cover cached success and failed forced refresh. +- Rejected forced loads now perform the same click-time summary revision/action revalidation as + successful loads. Primary-session and sessionless race regressions passed in the 14-test focused suite. +- Bot-review remediation keeps the stale owner-session result discarded but opens the requested task + through the task-only fallback. Desktop releases the outgoing layout; mobile activates the task, + navigates, and closes the existing inset drawer. Three focused suites passed 55 tests with zero lint + warnings, typecheck, and the i18n ratchet. +- Final Codex review leaves selection inert when the task projection itself disappears during an + authoritative load, while retaining the task-only fallback when a still-present task changes pending + owner. Desktop and mobile race regressions passed in the 57-test focused run, followed by zero-warning + focused lint, typecheck, and the i18n ratchet. +- Claude review confirms the durable-turn resolver's existing load-state, start-time, ID, and nanosecond + ordering coverage; a focused created-time tie-break case closes the remaining comparison branch. +- Final Codex review reconciles the durable wording with implemented behavior: a changed owner opens the + task-only fallback, while a disappeared task projection alone leaves selection inert. +- Latest Codex review ignores `AbortError` from a superseded shared forced load on desktop and phone, + preventing the losing continuation from clearing the winning session. Desktop now captures whether a + legacy pending task had a store projection at click time and remains inert if that projection is + deleted. This is shared state-only behavior with no layout, navigation surface, touch, or breakpoint + change, so focused desktop/mobile unit regressions satisfy mobile parity without new Playwright scope. +- Four focused task-selection suites passed 61 tests; full web lint, typecheck, and the i18n ratchet + passed. +- Six shared desktop/mobile selection and removal suites passed 75 tests; web typecheck, zero-warning + full lint, and the i18n ratchet passed. +- Final Claude review replaces the module-global phone selection sequence with a controller owned by + each mounted task sheet. Independent-controller and lifecycle regressions passed in the 42-test + focused selection run; this remains state-only mobile parity work with no layout or touch change. +- Latest Codex review corrects the last stale-summary scenario to match the implemented phone behavior: + discard the obsolete owner, open the selected task-only route, and close the sheet. +- Follow-up Codex review invalidates each mounted sheet's controller during unmount, preventing a + deferred phone load from selecting a task or closing the replacement tablet sheet. The 48-test + focused selection run, typecheck, zero-warning full lint, and i18n ratchet passed. +- `cd apps && pnpm install --frozen-lockfile` passed. +- The exact three-file Vitest command passed: 3 files, 68 tests. +- `cd apps/web && pnpm run typecheck` passed. +- `cd apps/web && pnpm run i18n:check` passed; existing real-locale parity warnings remain advisory. +- Follow-up Vitest passed 79 tests across processed-message and pending-clarification suites; web lint, + typecheck, i18n check, and i18n ratchet passed. +- Delayed Claude review documents the three-state `currentTurnId` scope contract and explicitly calls + out that an empty scope disables detection, preventing future callers from silently misusing it. diff --git a/docs/plans/clarification-active-lifecycle/task-05-clarification-regression-green.md b/docs/plans/clarification-active-lifecycle/task-05-clarification-regression-green.md new file mode 100644 index 0000000000..6b0999926e --- /dev/null +++ b/docs/plans/clarification-active-lifecycle/task-05-clarification-regression-green.md @@ -0,0 +1,86 @@ +--- +id: "05-clarification-regression-green" +title: "Clarification regression green" +status: completed +wave: 5 +depends_on: + [ + "01-clarification-regression-red", + "02-current-turn-backend-authority", + "03-summary-pending-convergence", + "04-pending-owner-navigation", + ] +plan: "plan.md" +spec: "../../specs/clarification-active-lifecycle/spec.md" +--- + +# Task 05: Clarification regression green + +## Acceptance + +- The exact desktop and phone black-box regressions captured in Task 01 pass against a fresh managed + production build without retries masking failure. +- The existing detached current-turn deferred-answer E2E still passes, proving newer-turn + supersession did not collapse the intended timeout recovery window. +- Reload, drawer dismissal, active session, task URL, question visibility, and no-horizontal-overflow + assertions all pass; no main-instance data is mutated. + +## Verification + +```bash +(cd apps && pnpm install --frozen-lockfile) +(cd apps/web && pnpm e2e:run tests/task/sidebar-pending-question.spec.ts) +(cd apps/web && pnpm e2e:run --no-build tests/chat/clarification.spec.ts -- --grep "timeout detaches clarification") +(cd apps/web && pnpm e2e:run --no-build --project mobile-chrome tests/task/mobile-sidebar-task-actions.spec.ts) +git diff --check +``` + +Confirm Playwright discovers nonzero tests in both `chromium` and `mobile-chrome` before treating the +runs as evidence. + +## Files likely touched + +- `apps/web/e2e/tests/task/sidebar-pending-question.spec.ts` +- `apps/web/e2e/tests/task/mobile-sidebar-task-actions.spec.ts` +- `apps/web/e2e/helpers/clarification.ts` only if introduced in Task 01 +- `apps/web/e2e/pages/session-page.ts` only if introduced in Task 01 + +## Dependencies + +Tasks 01-04. + +## Parallelism + +Sequential. This is the cross-layer acceptance gate for all prior tasks. + +## Inputs + +- Task 01's exact failing assertions and artifacts. +- Every user-visible scenario in the repair spec. +- E2E managed-runner guidance, production-build requirement, session-state polling, reload proof, and + mobile touch rules. + +## Risks + +- Do not convert a product failure into a test pass with `force`, retries, fixed sleeps, or broader + timeouts. +- Inspect fresh `error-context.md` and screenshots if a failure changes signature. +- The second and third commands intentionally reuse unchanged production artifacts from the first + managed build; rerun without `--no-build` after any source edit. + +## Output contract + +Report discovered test counts, exact command outcomes, relevant artifact paths, and teardown/cleanup +evidence. Reconcile actual files, replace `## Results`, mark all plan checks/results complete only when +green, and leave no temporary capture specs or processes. + +## Results + +- Fresh managed production artifacts built successfully; no retry or widened timeout was used. +- Chromium discovered 3 tests in `sidebar-pending-question.spec.ts`; all 3 passed, including stale + reload suppression and secondary-session ownership. +- Chromium discovered 1 matching detached-timeout test; it passed and accepted the deferred answer. +- `mobile-chrome` discovered 12 tests; all 12 passed. The new `.tap()` owner-selection case verified + drawer dismissal, active secondary session, task URL, question visibility, and horizontal fit. +- `git diff --check` passed. Managed runners exited cleanly and used isolated `/tmp/kandev-e2e-*` + databases/repositories; the read-only :9998 instance was not mutated. diff --git a/docs/public/websocket-api.md b/docs/public/websocket-api.md index c0462dc216..7164821d49 100644 --- a/docs/public/websocket-api.md +++ b/docs/public/websocket-api.md @@ -110,28 +110,28 @@ Send one JSON object in each WebSocket text frame. The server writes each respon } ``` -| Field | Type | Current contract | -|-------|------|------------------| -| `id` | string | Required for a useful request. Generate a unique value and correlate the response or error with it. Notifications omit it. | -| `type` | string | Send `request`. Server output uses `response`, `notification`, or `error`. The current dispatcher does not reject an inbound frame solely because `type` is missing or wrong, so do not treat it as an authorization control. | -| `action` | string | Exact registered action name; names are case-sensitive. | -| `payload` | JSON | Action-specific data. Most handlers expect an object and validate required fields themselves. | -| `timestamp` | RFC 3339 string | Included by the server. Optional and ignored on client requests. | -| `metadata` | object | Optional string-to-string metadata used by selected internal flows. It is not a general request-header mechanism. | +| Field | Type | Current contract | +| ----------- | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | string | Required for a useful request. Generate a unique value and correlate the response or error with it. Notifications omit it. | +| `type` | string | Send `request`. Server output uses `response`, `notification`, or `error`. The current dispatcher does not reject an inbound frame solely because `type` is missing or wrong, so do not treat it as an authorization control. | +| `action` | string | Exact registered action name; names are case-sensitive. | +| `payload` | JSON | Action-specific data. Most handlers expect an object and validate required fields themselves. | +| `timestamp` | RFC 3339 string | Included by the server. Optional and ignored on client requests. | +| `metadata` | object | Optional string-to-string metadata used by selected internal flows. It is not a general request-header mechanism. | Malformed JSON produces a `BAD_REQUEST` error with empty `id` and `action`, because correlation fields could not be parsed. An unregistered action produces `UNKNOWN_ACTION`. Standard codes are `BAD_REQUEST`, `NOT_FOUND`, `INTERNAL_ERROR`, `UNAUTHORIZED`, `FORBIDDEN`, `VALIDATION_ERROR`, `CONFLICT`, and `UNKNOWN_ACTION`; individual handlers may add codes or return an ordinary response with `success:false` instead. Always inspect both envelope type and response payload. ## Transport, concurrency, and loss behavior -| Behavior | Current value | Client consequence | -|----------|---------------|--------------------| -| Maximum inbound message | 32 MiB | A larger request closes/fails the connection. Current file-backed attachments use HTTP staging and send only descriptors over this socket; legacy inline base64 data still consumes the limit. | -| Write deadline | 10 seconds | A peer that cannot accept a frame in time is disconnected. | -| Pong deadline | 60 seconds | The connection closes if the peer stops answering pings. | -| Server ping interval | 54 seconds | WebSocket libraries must process ping/pong control frames. Browsers do this automatically. | -| Per-client outbound queue | 256 frames | When full, the new frame is dropped and the connection stays open. Responses and notifications can therefore be lost under backpressure. | -| Hub-wide broadcast queue | 256 messages | Global publishers block when this internal queue is saturated; delivery to each client can still drop at that client's queue. | -| Request dispatch | One goroutine per inbound message | Handlers execute concurrently and responses can arrive out of request order. Correlate only by `id`. | +| Behavior | Current value | Client consequence | +| ------------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Maximum inbound message | 32 MiB | A larger request closes/fails the connection. Current file-backed attachments use HTTP staging and send only descriptors over this socket; legacy inline base64 data still consumes the limit. | +| Write deadline | 10 seconds | A peer that cannot accept a frame in time is disconnected. | +| Pong deadline | 60 seconds | The connection closes if the peer stops answering pings. | +| Server ping interval | 54 seconds | WebSocket libraries must process ping/pong control frames. Browsers do this automatically. | +| Per-client outbound queue | 256 frames | When full, the new frame is dropped and the connection stays open. Responses and notifications can therefore be lost under backpressure. | +| Hub-wide broadcast queue | 256 messages | Global publishers block when this internal queue is saturated; delivery to each client can still drop at that client's queue. | +| Request dispatch | One goroutine per inbound message | Handlers execute concurrently and responses can arrive out of request order. Correlate only by `id`. | There is no sequence number, durable replay, acknowledgement, or exactly-once guarantee. Notifications are invalidation hints: after a reconnect, gap, or dropped frame, refetch authoritative state through the appropriate list/get request or HTTP route. @@ -155,14 +155,14 @@ Request handlers use the server hub's lifetime context, not the socket's lifetim Subscription actions are handled by the gateway before the normal dispatcher: -| Action | Payload | Result | -|--------|---------|--------| -| `task.subscribe` / `task.unsubscribe` | `{"task_id":"..."}` | Records or removes task interest. Current ordinary task/workflow lifecycle broadcasters are global, so this subscription is not a filter for `task.updated`. | -| `session.subscribe` / `session.unsubscribe` | `{"session_id":"..."}` | Routes session-scoped traffic. Subscribe also pushes currently available initial session data, such as Git status. | -| `session.focus` / `session.unfocus` | `{"session_id":"..."}` | Marks an actively viewed session, changes its backend polling tier, and on focus pushes fresh session data. Focused clients receive session-scoped traffic even during a transient subscribe handoff. | -| `user.subscribe` / `user.unsubscribe` | `{}` or `{"user_id":"default-user"}` | Empty uses Kandev's default user. Any other user is rejected with `FORBIDDEN`. | -| `run.subscribe` / `run.unsubscribe` | `{"run_id":"..."}` | Routes future `run.event.appended` messages for one Office run. No snapshot is replayed. | -| `system.metrics.subscribe` / `system.metrics.unsubscribe` | `{}` | Starts/stops delivery of live `system.metrics.updated` snapshots and contributes to metrics collection interest. | +| Action | Payload | Result | +| --------------------------------------------------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `task.subscribe` / `task.unsubscribe` | `{"task_id":"..."}` | Records or removes task interest. Current ordinary task/workflow lifecycle broadcasters are global, so this subscription is not a filter for `task.updated`. | +| `session.subscribe` / `session.unsubscribe` | `{"session_id":"..."}` | Routes session-scoped traffic. Subscribe also pushes currently available initial session data, such as Git status. | +| `session.focus` / `session.unfocus` | `{"session_id":"..."}` | Marks an actively viewed session, changes its backend polling tier, and on focus pushes fresh session data. Focused clients receive session-scoped traffic even during a transient subscribe handoff. | +| `user.subscribe` / `user.unsubscribe` | `{}` or `{"user_id":"default-user"}` | Empty uses Kandev's default user. Any other user is rejected with `FORBIDDEN`. | +| `run.subscribe` / `run.unsubscribe` | `{"run_id":"..."}` | Routes future `run.event.appended` messages for one Office run. No snapshot is replayed. | +| `system.metrics.subscribe` / `system.metrics.unsubscribe` | `{}` | Starts/stops delivery of live `system.metrics.updated` snapshots and contributes to metrics collection interest. | All subscriptions are connection-local and are removed on disconnect. `session.subscribe` and `session.focus` can send an initial live snapshot, but other subscriptions do not replay missed notifications. For an Office run, fetch the REST snapshot before subscribing and reconcile again after a gap. @@ -266,7 +266,7 @@ websocat ws://127.0.0.1:38429/ws ``` ```json -{"id":"health-1","type":"request","action":"health.check","payload":{}} +{ "id": "health-1", "type": "request", "action": "health.check", "payload": {} } ``` `websocat` is a third-party diagnostic dependency, not bundled with Kandev. Remember that an originless tool is accepted only because the current endpoint trusts its network boundary. @@ -795,19 +795,32 @@ session.todos_updated session.prompt_usage ``` +Semantic `session.message.added`, `session.message.updated`, and +`session.message.deleted` notifications can include `pending_action` with +`"clarification"`, `"permission"`, or explicit `null`. When present, it is the +authoritative per-session input projection after that message mutation and is +paired with `pending_action_revision` (`epoch` plus `sequence`). REST task-session +snapshots carry the same logical clock. Epoch is a decimal, database-backed backend +generation that increases across restarts; sequence increases within one generation. +Clients compare epoch numerically, then sequence. This keeps delayed snapshots from +an earlier generation from replacing current state when HTTP and WebSocket delivery +overlap, even after client state is rebuilt. +When both fields are absent, clients preserve their current projection and +reconcile through normal session refetch after a gap. + File changes are batched for up to 100 ms and flushed immediately at 50 entries. `session.shell.output` also represents shell exit events through its payload. Treat all stream messages as lossy and refresh session/Git status after a gap. ### User-, run-, and metrics-scoped broadcasts -| Routing key | Action | Notes | -|-------------|--------|-------| -| subscribed user | `user.settings.updated` | Requires `user.subscribe`. | -| subscribed user | `session.turn_finished` | Local user notification for a completed agent turn, with task/session/occurrence/title/body fields; the turn ID is sent as both the envelope `id` and payload `occurrence_id`. It is not delivered by `session.subscribe`. | -| subscribed user | `session.clarification_requested` | Local user notification for a structured agent question that needs an answer, with task/session/occurrence/title/body fields; the clarification pending ID is sent as both the envelope `id` and payload `occurrence_id`. It is not delivered by `session.subscribe`. | -| subscribed user | `system.update_available` | Local user notification for a newer Kandev release. Its payload is `{ "version", "url"?, "title", "body", "occurrence_id" }`; `occurrence_id` is the normalized release version/tag and is also the envelope `id`. Subscribe with `user.subscribe`, not `session.subscribe`. | -| subscribed user | `office.inbox_item` | Local notification for a selected Office inbox event, with title/body fields. | -| subscribed run | `run.event.appended` | Future events only; there is no replay cursor. | -| metrics subscribers | `system.metrics.updated` | Live resource snapshot; collection interest follows subscribers. | +| Routing key | Action | Notes | +| ------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| subscribed user | `user.settings.updated` | Requires `user.subscribe`. | +| subscribed user | `session.turn_finished` | Local user notification for a completed agent turn, with task/session/occurrence/title/body fields; the turn ID is sent as both the envelope `id` and payload `occurrence_id`. It is not delivered by `session.subscribe`. | +| subscribed user | `session.clarification_requested` | Local user notification for a structured agent question that needs an answer, with task/session/occurrence/title/body fields; the clarification pending ID is sent as both the envelope `id` and payload `occurrence_id`. It is not delivered by `session.subscribe`. | +| subscribed user | `system.update_available` | Local user notification for a newer Kandev release. Its payload is `{ "version", "url"?, "title", "body", "occurrence_id" }`; `occurrence_id` is the normalized release version/tag and is also the envelope `id`. Subscribe with `user.subscribe`, not `session.subscribe`. | +| subscribed user | `office.inbox_item` | Local notification for a selected Office inbox event, with title/body fields. | +| subscribed run | `run.event.appended` | Future events only; there is no replay cursor. | +| metrics subscribers | `system.metrics.updated` | Live resource snapshot; collection interest follows subscribers. | Routing is an efficiency mechanism, not an access-control boundary. The server does not authenticate resource ownership, global messages can contain IDs for other workspaces, and a client can request arbitrary subscription IDs. diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index a6fdead761..003966a37f 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -73,6 +73,7 @@ Kandev's task model: documents, execution stages, labels, blocker escalation, su | [interrupted-task-indicator](tasks/interrupted-task-indicator.md) | complete | | [labels](tasks/labels.md) | shipped | | [title-length-limit](tasks/title-length-limit.md) | complete | +| [active clarification lifecycle](clarification-active-lifecycle/spec.md) | approved | | [model-unification](tasks/model-unification.md) | draft | | [run-scheduling](tasks/run-scheduling.md) | building | | [without-repositories](tasks/without-repositories.md) | draft | diff --git a/docs/specs/clarification-active-lifecycle/spec.md b/docs/specs/clarification-active-lifecycle/spec.md new file mode 100644 index 0000000000..547e2dab24 --- /dev/null +++ b/docs/specs/clarification-active-lifecycle/spec.md @@ -0,0 +1,462 @@ +--- +status: approved +created: 2026-08-14 +owner: kandev +--- + +# Active Clarification Lifecycle + +## Why + +A clarification can outlive the agent wait that created it. Kandev keeps that detached question +answerable so a timeout or connection loss does not discard required user input. That durability must +not let a question from an older turn remain operational after the session has accepted newer work. + +One stale clarification currently can reappear in chat, restore a task-row question icon, and block a +workflow transition after the user has dismissed a newer question. A real question in a secondary +session can also produce a correct task-row icon while task navigation opens the clean primary session, +hiding the action the icon represents. + +## What + +- A clarification bundle is active only when at least one row in the bundle is pending and the bundle + belongs to the session's current turn. +- A detached bundle remains active and answerable while its turn remains current. Detachment sets + `agent_disconnected=true`; it does not by itself resolve the question. +- After a session successfully enters a terminal state, its current-turn pending clarification bundles + expire so no answerable overlay survives a completed, failed, or cancelled session. +- Acceptance of a newer turn supersedes every pending clarification from an older turn. Superseded + rows remain transcript history but cannot drive a chat overlay, task/session pending projection, + workflow guard, turn-completion detach pass, or late agent resume. +- Deleting every message from the newer turn does not move ownership backward or reactivate an older + clarification. +- All backend consumers derive active clarification state from one repository rule. Event payloads + trigger projection refreshes; they are not a second source of pending truth. +- Repeated detach/completion processing is a semantic no-op after a bundle is already detached. It + emits no duplicate `message.updated` occurrence. +- Detachment claims pending, non-detached rows from the current durable turn in one database update + and publishes only the rows that update returns. A concurrent answer or newer turn cannot be + overwritten by a stale read-modify-write detachment. +- Clarification-pause cancellation snapshots turn authority before detachment. With a wired turn + service, both a specific turn and the absence of a turn are explicit expectations; a first or + successor turn created during detachment cannot be cancelled by the stale pause. Installations + without a turn service retain the legacy unscoped fallback. +- Resolving, rejecting, cancelling, expiring, or deleting one bundle changes only that bundle. It + cannot clear or re-arm another bundle in the same session. +- The chat's Skip action rejects the exact visible bundle through the existing response endpoint. A + live waiter receives the rejection in the same turn. A detached current-turn bundle is persisted as + rejected without resuming the agent. +- An affirmative response to a detached current-turn bundle returns success only after the + orchestrator accepts one resume dispatch within a bounded wait. The response waits for prompt + acknowledgement, not agent-turn completion. Before dispatch, the successor is durably reserved but + marked unpublished so provider frames can reference it without making it current. Immediately before + the external executor call, Kandev durably marks the reservation attempted. That marker is the + at-most-once boundary: a crash can no longer prove whether agentctl accepted the prompt, so restart + preserves the successor and keeps the claimed bundle terminal. Acknowledgement publishes a + recovery-clean `turn.started` payload while the recovery metadata remains durable, then clears that + metadata after the event bus accepts the event. HTTP success requires both operations. A rejected + event publication therefore remains discoverable by startup reconciliation. Recovery atomically + replaces an accepted or ambiguous reservation with a durable start-event outbox marker. Before + admitting work, startup replays `turn.started`, followed by `turn.completed` when the turn is already + terminal, and clears the marker only after the event bus accepts every required event. Failed replay + fails startup and leaves the marker for the next attempt. Every public turn event, including a + completion racing live cleanup or emitted during recovery, strips the private prompt-dispatch fields. + If the predecessor's delayed ready event overlaps this private reservation, ready handling waits for the + reservation to resolve and then revalidates prompt generation before touching turn or workflow state. + It cannot complete the reserved successor or run predecessor completion actions against it. When the + reservation rolls back, a ready event whose predecessor generation still owns the session continues + through normal completion so the predecessor and its queue are not stranded. A generationless ready + event that overlapped the reservation is dropped after resolution because ownership cannot be proven. + If the attempt marker cannot be persisted, dispatch does not occur and a fresh bounded context rolls + back the reservation and session claim even when the transport context was cancelled, so the answer + remains retryable. + If agentctl synchronously rejects the prompt, the reservation is rolled back and the answer can be + restored. If agentctl accepts the prompt but publication or later transport handling fails, the + endpoint returns a server error, performs normal prompt-failure cleanup, and keeps the claimed bundle + terminal because retrying could dispatch the answer twice. Startup + deletes only an empty, unattempted unpublished reservation and restores the exact clarification rows + claimed for its dispatch; an attempt marker or message evidence instead proves dispatch ambiguity and + preserves the successor. Authority and recovery treat boolean `true`, strings `"true"` and `"1"`, + and numeric `1` as equivalent pending/attempted flags across SQLite and PostgreSQL. If reservation + reconciliation is unavailable or fails, orchestrator startup + fails before watcher, scheduler, or prompt admission starts; the next start retries recovery. A + production turn repository must provide this recovery capability through its compile-time contract. + A rejection persists terminal status without resuming the agent. +- Every response atomically claims current-turn ownership and persists a response-delivery recovery + intent before it can reach a live waiter or request a detached resume. A live waiter runs durable + delivery confirmation before returning the response to the agent; enqueue alone does not retire the + intent. Once confirmation starts, it owns its durable operation through completion even if the + responder's bounded wait expires. Its input claim remains immutable, its result remains local to the + callback, and any compensating restore serializes against finalization so only one durable outcome + wins. The detached path retires the intent only at its durable resume boundary. Startup first + reconciles prompt reservations, then restores an unhanded current-turn claim to pending; a terminal + session or newer authoritative turn instead retires the stale intent without reactivating history. + Terminal message updates are published only after delivery succeeds. If detached + resume acceptance fails, the endpoint returns an error and restores the still-current bundle to + pending so the same answer can be retried. Restored rows publish after commit even when synchronous + task-summary acknowledgement fails, preventing clients from retaining the terminal snapshot while the + endpoint still returns the acknowledgement error. A publication or summary-convergence error after + the database restore does not make that retry unsafe; durable pending state remains authoritative. + Once agentctl accepts the prompt, later publication or completion errors cannot roll back the + successor turn or reopen the answer. A primary-answer watchdog carries the clarification turn ID + and revalidates that ID both before fallback and inside serialized prompt admission, so it cannot + dispatch a stale answer into a successor turn. Its fallback keeps the watchdog cancellation context + through authority reads and prompt admission so session activity or service shutdown interrupts + in-flight recovery work. +- A current-turn bundle remains answerable while any sibling question is pending. Recovery claims only + those pending rows, preserves siblings already made terminal by an earlier partial write, and restores + only the claimed rows if detached delivery fails. Primary delivery events and detached recovery derive + one turn identity from the same bundle rule: legacy empty turn IDs do not mask a consistent non-empty + identity, while conflicting non-empty IDs invalidate the identity. +- Any response to a superseded or terminal bundle returns conflict, performs no message mutation, and + initiates no agent resume. Current clients close their obsolete local overlay through the existing + conflict handling. +- Persisted task status summaries reconcile `pending_action` against current-turn repository state on + source events and task-list/boot reads. Existing summaries are repaired, not only missing rows. +- When a task row advertises a pending action, desktop and phone task activation load the task's + sessions from the server and select the newest input-capable session whose `pending_action` matches + the task action. Before applying that response, activation revalidates the task-summary revision and + pending action. If either changes while the task remains present, activation discards the delayed + session choice and opens the task-only route; phone activation also closes the sheet. Overlapping + authoritative loads are generation-guarded per task, so an older response cannot replace a newer + session snapshot. This pending owner outranks remembered-session and primary-session preferences. If + the task still advertises pending input but no matching input-capable owner exists, activation releases + the outgoing session layout and fails closed to the task route without guessing a session. Normal + preference order returns only for a clean task. If the task projection disappears while that + authoritative load is in flight, desktop and phone leave the selection inert instead of navigating to + a deleted task, including tasks using the legacy pending-action projection. A forced load aborted by a + newer load is also inert; it is not treated as a request failure requiring task-only fallback. Each + mounted phone task sheet owns its selection generation, so simultaneous instances cannot invalidate + one another's in-flight task choice. + +## Data model + +No schema change. + +- Clarification questions remain `task_session_messages` rows with + `type = "clarification_request"`. +- Rows in one bundle share `metadata.pending_id`; terminal status remains in `metadata.status`. +- `task_session_messages.turn_id` associates a question with its turn. The newest authoritative durable + `task_session_turns` record for the session identifies the current turn; deleting messages does not + delete that parent turn or move ownership backward. +- A pre-acknowledgement successor carries `metadata.prompt_dispatch_pending=true` plus the source + clarification turn, pending ID, and exact claimed message IDs. An empty row carrying only the pending + marker is not turn authority. Immediately before external dispatch, + `metadata.prompt_dispatch_attempted=true` records the at-most-once boundary and makes the successor + authoritative if the process stops in the ambiguous window. A message referencing the successor is + independent durable evidence of ambiguous acceptance. +- `metadata.agent_disconnected=true` records that no in-memory waiter owns an otherwise active bundle. +- A superseded row may retain `metadata.status = "pending"`. Pending metadata is historical evidence, + not sufficient proof that the request is operational. +- A missing-status row in an older turn is superseded history. Turn ownership takes precedence over + the legacy rule that missing status means pending. +- `TaskSession.pending_action` and `TaskStatusSummary.pending_action` remain bounded derived fields. + They are reconstructable and never become independent clarification state. + +## API surface + +No new route. Task-session responses add `pending_action_revision` beside the existing +`pending_action` projection. + +- `GET /api/v1/tasks/:taskId/task-sessions` continues to expose each session's current derived + `pending_action`; task navigation uses this existing field. +- Semantic `session.message.added`, `session.message.updated`, and `session.message.deleted` + notifications include the authoritative per-session `pending_action` after mutations that can + change it. The field is explicit null when clean and omitted when projection fails or the event is + a replaceable content-only update; clients preserve the prior value when it is omitted. Each + projection includes a `pending_action_revision`, shared with REST task-session snapshots. Its + decimal epoch is a database-backed, monotonically allocated backend generation; sequence orders + reads within that generation. Clients compare both fields and reject any older result, including + an unseen pre-restart epoch delivered after client state has been rebuilt. +- `GET /api/v1/task-sessions/:sessionId/turns` continues to expose durable turn history; + unpublished reservations stay hidden until publication or durable message evidence, including while + an attempt marker makes them internal current-turn authority. Attempted reservations are preserved + across restart because their dispatch outcome is ambiguous. Visible history is ordered ascending by + `started_at`, `created_at`, then `id`, matching the reverse ordering used to select the current turn. +- Task list, workflow snapshot, and boot payloads continue to expose task-level `pending_action` in + the status summary and legacy fallback fields. +- `POST /api/v1/clarification/:pendingId/respond` uses one state-based contract: + - `active_live`: answer or rejection returns success and is delivered to the same-turn waiter. + - `active_detached`: an answer returns success only after the orchestrator acknowledges one resume + dispatch and durably publishes its successor within a bounded wait; it does not wait for the resumed + turn to complete. Rejection returns success and persists without resuming the agent. If resume + acceptance fails, an answer returns a server error and the still-current bundle remains answerable + for retry. If acceptance succeeds but successor publication fails, the endpoint returns a + non-retryable server error and keeps the bundle terminal. + - `superseded_history` or `terminal`: answer or rejection returns conflict, performs no write, and + initiates no agent resume. + - `delivery_claimed`: a concurrent second response returns conflict, performs no write, and + initiates no agent resume because the first response already claimed every pending row. +- `POST /api/v1/clarification/:pendingId/cancel` remains the low-level cancellation path for a request + still owned by the in-memory clarification store. The chat Skip control uses `/respond` with + `rejected=true`, including for detached requests. + +## State machine + +One clarification bundle has five operational states: + +1. `active_live`: rows are pending in the current turn and an in-memory waiter exists. +2. `active_detached`: rows are pending in the current turn, no waiter exists, and + `agent_disconnected=true` records deferred-answer behavior. +3. `delivery_claimed`: current-turn rows carry their provisional terminal answer plus a durable + response-delivery intent, but no handoff boundary has been acknowledged yet. +4. `terminal`: every actionable row is answered, rejected, cancelled, expired, or deleted, with no + outstanding delivery intent. +5. `superseded_history`: rows still carry pending history, but a newer turn is current. + +Transitions: + +- Request creation enters `active_live`. +- Wait timeout, disconnect, or turn teardown moves `active_live -> active_detached` once. +- A response first moves either active state to `delivery_claimed`. Successful answer delivery or Skip + then moves that exact `pending_id` to `terminal`. For a live response, the waiter must durably confirm + consumption before returning it to the agent. A backend restart before any handoff restores a + still-current `delivery_claimed` bundle to its prior active state; if a newer turn or terminal session + already superseded it, recovery retires the intent and preserves terminal history. Cancel, expiry, + or deletion moves an active state directly to `terminal`. A failed detached resume acceptance returns to + `active_detached` while the same turn remains current only when dispatch is known not to have been + accepted and rollback succeeds. A post-attempt crash or post-acceptance publication failure remains + terminal because the prompt may already be running. +- Acceptance of a newer turn moves any older pending bundle to `superseded_history` operationally; + no history rewrite is required. +- Neither `terminal` nor `superseded_history` can become active again. Only the provisional + `delivery_claimed` state is recoverable. A new request creates a new + bundle identity; message deletion cannot reverse this transition. + +## Permissions + +No authorization change. A user can see, answer, or dismiss only clarification data for a task and +session they can already access. Session selection does not broaden task visibility. + +## Failure modes + +- Active-state repository read fails: workflow guarding fails closed, and projections keep the last + known pending value. A later message event or list/boot read retries convergence. +- Terminal-session expiry persistence fails: the terminal session state still quarantines pending + history from task/session projections and interactive overlays; a stale response claim remains + rejected by the terminal-session predicate. +- A forced task-session list cannot project authoritative pending actions: fail the HTTP or WebSocket + request instead of returning a successful list with empty pending ownership. +- Summary compare-and-set loses a race: reload the newer summary, reapply authoritative pending state, + and retry within a bounded loop. Exhaustion is an error, so restored-state acknowledgement is withheld + until the pending action is durably confirmed. Task-list and boot reads explicitly invalidate that + task's stale summary on repair failure so an unchanged browser cache clears it and exposes the + authoritative coarse pending action. Ordinary summary omission remains a partial-response no-op, and + an invalidating response cannot erase a newer live summary received while that read was in flight. + Never overwrite unrelated newer summary fields. +- Summary repair persists but its WebSocket publication fails: the initiating response carries the + corrected summary; other clients converge on their next event or read. +- A stale browser submits an older-turn answer: return conflict, do not update runtime ownership, and + do not dispatch a prompt. +- Detached resume context resolution or orchestrator acceptance fails: use a non-cancelled context with + a finite deadline for acceptance and persistence, withhold terminal message events, restore the + still-current bundle to pending, and return a retryable server error instead of reporting false + success. Attempt to refresh and persist the task summary synchronously from authoritative pending + rows, and publish the committed restored messages even if that acknowledgement fails. A later event + or read repairs the summary cache; the response still reports that the durable answer can be retried. +- Persisting a successor turn or dispatch-attempt marker fails: use a fresh bounded context to roll back + the session claim and any reserved successor before making an external executor call, restore the + still-current bundle, and return a retryable server error. +- Reserved-successor rollback fails or has an ambiguous durable outcome: keep the live reservation + unresolved so ready handling waits and later prompt admission remains blocked until restart recovery + reconciles the row. +- Clarification detachment, expiry persistence, and terminal bundle publication ignore request + cancellation but always use a fresh bounded context, so a database lock or synchronous summary + refresh cannot hold the per-session pause or HTTP response indefinitely. +- Agentctl accepts a detached resume but durable successor publication fails: return a non-retryable + server error, keep the claimed bundle terminal, and make later rollback fail closed so the accepted + answer cannot be dispatched again in-process. +- Live waiter delivery fails unexpectedly: restore the durable claim only if its turn remains current, + report whether retry state was recovered, and never restore it after a successor turn is accepted. A + started confirmation may finish after the responder's bounded wait, but it cannot mutate the + responder's claim snapshot and its durable finalization serializes against that restore. +- Historical partial terminalization leaves pending and terminal siblings in one current-turn bundle: + complete the pending siblings without rewriting terminal history or returning a permanent conflict. +- A malformed persisted pending row has no matching durable turn: drain any live in-memory waiter, but + keep the row inert. If such pre-turn history is encountered, repair it through explicit data cleanup + rather than treating it as current input authority. +- Session loading fails during task activation: retain existing navigation fallback instead of + stranding the user in the task drawer or on an unchanged URL. +- A newer task-summary revision arrives while pending-owner loading is in flight: discard the delayed + owner-session result, open the requested task through the task-only fallback, and let the current + projection render the authoritative pending owner. +- Backend stops after reserving a detached-answer successor but before the attempt marker: startup + restores only the clarification rows claimed for that dispatch and removes the empty reservation. +- Backend stops after the attempt marker but before dispatch acknowledgement: startup fails closed, + preserves the successor as authoritative, and keeps its exact clarification claim terminal. Output + referencing the reservation provides the same conservative authority even without the marker. +- A delayed predecessor ready event arrives between the attempt marker and agentctl acknowledgement: + wait for the live reservation outcome, then reject the event if its prompt generation was superseded; + never complete the reserved successor or evaluate predecessor workflow completion against it. If the + reservation rolls back and the predecessor generation still owns the session, process the ready event + normally instead of stranding the predecessor turn. Drop a generationless event after the wait because + it cannot be correlated safely with the predecessor. +- Unpublished-reservation reconciliation fails during startup: fail startup before event processing or + prompt admission begins so no new turn can supersede an unrecovered clarification claim. +- Response-delivery intent reconciliation fails during startup: fail startup before event processing or + prompt admission begins. Never leave an unhanded terminal claim permanently unanswerable. +- Unpublished-reservation recovery metadata contains a non-string or empty claimed-message ID: fail the + recovery transaction and startup, preserving the reservation and claimed rows for diagnosis and retry. + +## Persistence guarantees + +- Message history remains durable and is not destructively rewritten merely because a newer turn + exists. +- Active clarification state is reconstructable after restart from message status plus the newest + authoritative durable turn. +- Current-turn ownership is reconstructable from durable turn rows even when a turn has no remaining + messages. +- Unpublished detached-answer reservations carry enough recovery identity to restore only their own + claimed rows. A durable attempt marker separates safe rollback from ambiguous dispatch, and startup + reconciliation runs even when no executor record remains. +- Provisional terminal response claims carry a durable delivery intent. Startup restores that exact + current-turn claim when no live or detached handoff became authoritative, while prompt-reservation + recovery owns any detached dispatch that reached its durable reservation boundary. +- Task summaries are caches. Boot and task-list reads correct a stale persisted `pending_action` with + a monotonic revision while preserving all unrelated summary fields. +- No one-off mutation or backfill of an existing installation database is required. Deploying the + corrected derivation makes historical older-turn pending rows inert and repairs their summaries on + normal reads. + +## Scenarios + +- **GIVEN** a clarification wait disconnects and no newer turn exists, **WHEN** the task is reloaded, + **THEN** the detached question remains visible and answerable, its session and task advertise + `clarification`, and workflow completion stays blocked. +- **GIVEN** an older turn retains a detached pending row, **WHEN** the session accepts a newer ordinary + turn, **THEN** the old row remains in history but no overlay, pending projection, detach event, or + workflow barrier derives from it. +- **GIVEN** a newer turn superseded an older pending question, **WHEN** every message in the newer turn + is deleted, **THEN** the durable newer turn remains current and the older question stays inert. +- **GIVEN** an attempted successor reservation is authoritative but intentionally hidden from client + turn history, **WHEN** the session projection explicitly reports no clarification action, **THEN** a + pending question on the visible predecessor stays inert in chat and pending-input indicators. +- **GIVEN** a browser cached an explicit clean session projection, **WHEN** a new current-turn + clarification message arrives, **THEN** the same event advances the session projection to + `clarification` before pending discovery so the question remains visible and answerable. +- **GIVEN** a task-session list request is in flight, **WHEN** a newer semantic message event updates + the same session before the HTTP response resolves, **THEN** the response's older projection + revision cannot replace the WebSocket projection (and the reverse delivery order is equally safe). +- **GIVEN** an old detached question and a newer clarification bundle, **WHEN** the user skips the + newer bundle and reloads, **THEN** neither bundle reappears, the task question icon is absent, and + later turn completion cannot re-arm the old bundle. +- **GIVEN** a persisted summary says `pending_action=clarification` while current-turn repository state + has no pending action, **WHEN** a boot or task-list payload is built, **THEN** the summary revision + advances with no pending action and all unrelated fields remain unchanged. +- **GIVEN** a task's secondary session owns a current clarification while the primary session is + clean, **WHEN** the user activates the task from the desktop sidebar or phone task drawer, **THEN** + Kandev bypasses any cached session list, selects the secondary session, closes the drawer when + applicable, and shows the question. +- **GIVEN** clarification pause observes no active turn, **WHEN** detachment creates the session's first + turn before cancellation registration completes, **THEN** the pause rejects its stale cancellation + and does not cancel or complete that new turn. +- **GIVEN** pending-owner loading began for one task-summary revision, **WHEN** a newer summary changes + the pending action before the session response is applied, **THEN** desktop and phone activation do + not navigate to the obsolete owner, open the selected task through the task-only fallback, and close + the phone drawer. +- **GIVEN** two forced session loads overlap for one task, **WHEN** the older response finishes last, + **THEN** it cannot overwrite the newer session snapshot or revive an obsolete pending owner. +- **GIVEN** a forced session load is pending in the phone task sheet, **WHEN** a responsive-layout + change unmounts it and mounts the tablet sheet before that load finishes, **THEN** the old + continuation cannot navigate, change the active selection, or close the replacement sheet. +- **GIVEN** a pending task has no loaded owner, **WHEN** activation falls back to its task route, + **THEN** the outgoing session layout is released before the active session is cleared. +- **GIVEN** a stale browser still displays a superseded question, **WHEN** it submits an answer, + **THEN** the server returns conflict and does not resume or otherwise prompt the agent. +- **GIVEN** a detached current-turn answer is not accepted by the orchestrator, **WHEN** the response + endpoint fails, **THEN** it returns a server error, keeps the question answerable, and a later retry + delivers exactly one accepted resume request without a failed-dispatch turn superseding the bundle. +- **GIVEN** agentctl accepts a detached answer but successor publication fails, **WHEN** the response + endpoint completes, **THEN** it returns an error, keeps the answer terminal, and does not make that + accepted successor eligible for rollback or in-process redispatch. +- **GIVEN** agentctl accepts a detached answer and later transport handling also fails, **WHEN** prompt + handling returns, **THEN** normal failure cleanup runs and the accepted-answer marker still prevents + the terminal clarification from being reopened. +- **GIVEN** the backend stops after terminalizing a detached answer and reserving its successor but + before marking dispatch attempted, **WHEN** it starts again, **THEN** it deletes the empty + reservation, restores the exact claimed rows to pending, and leaves pre-existing terminal siblings + unchanged. +- **GIVEN** the backend stops after marking a detached-answer dispatch attempted but before observing + acknowledgement, **WHEN** it starts again, **THEN** it preserves the successor as current and keeps + the exact claimed rows terminal rather than risking duplicate answer dispatch. +- **GIVEN** the backend stops after claiming a current-turn response but before either a live waiter or + detached resumer receives it, **WHEN** it starts again, **THEN** startup restores the exact claimed + rows to pending and the same answer can be submitted again. +- **GIVEN** a response is enqueued to a live waiter, **WHEN** durable confirmation has not completed, + **THEN** the waiter does not return the response and restart recovery may safely restore the claim. +- **GIVEN** durable confirmation starts but outlives the responder's bounded wait, **WHEN** the responder + attempts recovery, **THEN** the immutable claim is race-free and repository serialization lets either + finalization or restore win without reopening a successfully finalized response. +- **GIVEN** cancellation removes a live clarification after a responder loads it but before response + delivery is claimed, **WHEN** that responder continues, **THEN** it returns not-found immediately so + the caller can use detached recovery instead of waiting for an orphaned delivery confirmation. If + response delivery wins first, later cancellation cannot preempt its durable confirmation. +- **GIVEN** a primary-answer watchdog survives until another turn supersedes its clarification turn, + **WHEN** its fallback timer expires, **THEN** both the preflight and serialized prompt-admission checks + reject the stale answer without prompting or cancelling the successor. +- **GIVEN** a primary-answer watchdog has entered fallback authority or prompt work, **WHEN** session + activity or service shutdown cancels the watchdog, **THEN** that cancellation reaches the in-flight + repository and prompt calls. +- **GIVEN** a reserved successor is marked dispatch-attempted but remains unpublished, **WHEN** a client + loads turn history before agentctl accepts or rejects it, **THEN** the successor is omitted until + publication or durable message evidence prevents rollback from leaving stale client-only history. +- **GIVEN** a predecessor ready event arrives while a detached-answer successor is reserved, **WHEN** + the successor dispatch resolves, **THEN** the handler revalidates prompt generation and the stale + predecessor cannot complete the successor or run `on_turn_complete` against it; if reservation + rollback leaves that predecessor generation authoritative, its ready event completes normally. +- **GIVEN** reserved-successor deletion returns an error, **WHEN** rollback handling completes, **THEN** + the reservation waiter remains unresolved and another prompt cannot enter that session before + restart recovery. +- **GIVEN** an unpublished reservation stores an attempted marker as boolean `true`, string `"true"` or + `"1"`, or numeric `1`, **WHEN** startup recovery runs, **THEN** it preserves the ambiguous successor + and clears recovery metadata instead of restoring the answer and deleting the turn. +- **GIVEN** a generationless ready event waits on a live reservation, **WHEN** the reservation rolls + back, **THEN** Kandev drops the uncorrelatable event without changing session or workflow state. +- **GIVEN** a cancelled request triggers clarification detach or expiry, **WHEN** repository work + begins, **THEN** it uses a non-cancelled context with a finite deadline. +- **GIVEN** a session transition to completed, failed, or cancelled is durably accepted, **WHEN** the + transition publishes, **THEN** current-turn pending clarification bundles expire before a terminal + session can leave an interactive overlay behind. +- **GIVEN** a detached answer reaches synchronous orchestrator resume, **WHEN** the orchestrator does + not acknowledge it before the bounded deadline, **THEN** Kandev treats acceptance as failed and + restores the still-current bundle. +- **GIVEN** startup cannot reconcile an unpublished prompt reservation, **WHEN** the orchestrator starts, + **THEN** startup returns an error before watcher, scheduler, or prompt admission begins. +- **GIVEN** restart recovery preserves an accepted or ambiguous prompt reservation, **WHEN** its public + start event may have been missed, **THEN** startup durably retains and retries an ordered event replay + until `turn.started` and any already-required `turn.completed` event are accepted before clearing the + recovery marker. +- **GIVEN** an unpublished prompt reservation contains a malformed claimed-message ID list, **WHEN** + startup recovery decodes it, **THEN** recovery fails closed without deleting the reservation or + reopening only a subset of its claimed rows. +- **GIVEN** pending ownership changes while desktop or mobile task selection loads sessions, **WHEN** the + delayed load settles, **THEN** Kandev ignores its stale owner choice but still opens the selected task + through the task-only fallback, and the mobile sheet closes. +- **GIVEN** a task-list refresh observes a detached answer's temporary terminal claim, **WHEN** resume + rejection restores that claim, **THEN** Kandev acknowledges durable summary convergence before + reporting retryability and publishes the restored pending rows so other clients converge. +- **GIVEN** live publication or synchronous summary acknowledgement fails after restored clarification + rows commit, **WHEN** the detached resume returns, **THEN** Kandev reports the convergence error while + still identifying the durably restored answer as safe to retry. +- **GIVEN** the selected task projection disappears while desktop or mobile session loading is in + flight, **WHEN** the delayed load settles, **THEN** Kandev leaves task and session selection unchanged. +- **GIVEN** a newer desktop or mobile forced session load aborts an older load for the same task, + **WHEN** the older continuation handles `AbortError`, **THEN** it leaves the winning task and session + selection unchanged. +- **GIVEN** two phone task sheets are mounted, **WHEN** both have an in-flight task selection, **THEN** + closing or selecting within one sheet does not invalidate the other sheet's selection generation. +- **GIVEN** authoritative pending-action projection fails while listing a task's sessions, **WHEN** the + HTTP or WebSocket list request completes, **THEN** it returns an internal error rather than a false + clean-session response. +- **GIVEN** two request identities produce terminal and pending events close together, **WHEN** the + projector refreshes, **THEN** its result matches current repository state rather than event order. + +## Out of scope + +- Rewriting or deleting historical pending message rows solely to clean old data. +- A dedicated clarification table or schema migration. +- Redesigning the desktop sidebar, phone task drawer, chat cards, or clarification carousel. +- Changing permission-request lifecycle semantics; pending-session navigation reuses the shared + `pending_action` type for permission parity. +- Changing notification history for a clarification that was valid when its notification fired. +- Automatically choosing a non-primary session when the task has no current pending action. diff --git a/docs/specs/platform/bounded-task-status-delivery.md b/docs/specs/platform/bounded-task-status-delivery.md index e1a0398879..8b34e0802d 100644 --- a/docs/specs/platform/bounded-task-status-delivery.md +++ b/docs/specs/platform/bounded-task-status-delivery.md @@ -73,16 +73,30 @@ remain during migration, but switchers use the summary when present. - Pending permission outranks pending clarification for the row's primary state icon. Both outrank generating/background activity, which outranks coarse lifecycle state. This is the existing task-row precedence. -- A session's `pending_action` clears only when the specific request that armed - it resolves — a `message.updated` on that same permission/clarification +- A clarification contributes to `pending_action` only while its bundle is pending in the session's + current authoritative `task_session_turns` record. An empty, unattempted + `prompt_dispatch_pending` reservation does not advance this boundary. A message-backed reservation or + one marked `prompt_dispatch_attempted` is authoritative because dispatch may have occurred. Within + that turn, unrelated rows do not clear the request. A newer authoritative turn supersedes older + pending clarification rows without requiring transcript history to be rewritten; deleting newer-turn + messages cannot move this boundary backward. +- Within the same current turn, a session's `pending_action` clears only when the specific request that + armed it resolves: a `message.updated` on that same permission/clarification request row (matched by type and `pending_id`) reaching a terminal, non-`pending` status, or that row's deletion. An unrelated message on the same session (a tool call, script execution, or any other row that merely carries its own terminal status metadata) must not clear a different session's armed `pending_action`, and must not clear the current session's - `pending_action` unless it is that same request row. Otherwise a session that + same-turn `pending_action` unless it is that same request row. Otherwise a session that is busy streaming ordinary tool activity right after a permission/ clarification request clears the affordance before the user ever answers it. +- Pending-sensitive source events refresh from the bounded repository projection instead of trusting + event order as state. Boot and task-list hydration reconcile the pending field of existing persisted + summaries as well as creating missing summaries. Repairs preserve unrelated fields and advance the + monotonic revision only when pending semantics change. +- When no persisted summary row exists, the first source event rehydrates configured authoritative + session, Git, and pull-request observations before deriving the new row. The event therefore updates + one keyed observation without discarding unchanged siblings from other keys. - `active_error` is independent of the primary state icon. It represents the newest relevant recoverable agent error and clears after an authoritative dismissal or a newer agent response according to the existing error rules. @@ -91,10 +105,13 @@ remain during migration, but switchers use the summary when present. session event. - Git totals aggregate the latest observation for every repository in the task. A multi-repository update must not expose a partial replacement that - forgets unchanged repositories. + forgets unchanged repositories. On projector restart, configured keyed Git + observations are rehydrated even when the persisted aggregate is absent. - Pull-request state aggregates open PRs before terminal PRs and chooses the most attention-worthy current status. Full PR details remain owned by the - GitHub domain and are loaded only by surfaces that need them. + GitHub domain and are loaded only by surfaces that need them. On projector + restart or compare-and-set rebase, configured keyed pull-request observations + are rehydrated even when the persisted aggregate is absent. - A semantic no-op does not increment `revision` or emit an update. - Clients ignore a summary delta whose revision is not newer than the stored revision. @@ -247,6 +264,14 @@ intermediate replacement. to the newer error, **WHEN** the projector restarts and the newer session is deleted, **THEN** the retained session's error becomes the task's `active_error` instead of leaving the summary empty. +- **GIVEN** keyed Git observations committed before their aggregate summary, + **WHEN** the projector restarts with a nil persisted Git aggregate and later + receives one repository event, **THEN** it rehydrates every repository before + rebuilding totals so unchanged siblings remain included. +- **GIVEN** authoritative session, Git, or pull-request observations exist but no + task summary row has been persisted, **WHEN** the projector receives its first + source event, **THEN** it rehydrates every configured source before creating + the row so unchanged siblings remain included. - **GIVEN** two sessions in one task have recoverable errors, **WHEN** both sessions are deleted concurrently while retained-error repair is in flight, **THEN** the final task summary contains no error for either deleted session. @@ -256,6 +281,12 @@ intermediate replacement. unrelated message, **THEN** `pending_action` for the session remains `permission` (the affordance does not flash and disappear before the user answers it). +- **GIVEN** a detached clarification row is pending in an older turn and the current turn has no + pending input, **WHEN** a summary is projected or hydrated, **THEN** the session and task omit + `pending_action` and later turn completion cannot re-arm it. +- **GIVEN** an existing persisted summary advertises clarification but the bounded current-turn query + returns no pending action, **WHEN** a task-list or boot payload is built, **THEN** the persisted and + returned summary advance to a newer revision with the clarification removed. - **GIVEN** notification traffic saturates its queue, **WHEN** the selected session sends `message.add`, **THEN** the correlated response is delivered first or the connection closes for deterministic reconciliation. diff --git a/docs/specs/tasks/subtree-controls.md b/docs/specs/tasks/subtree-controls.md index bab2061b13..aaa9f03797 100644 --- a/docs/specs/tasks/subtree-controls.md +++ b/docs/specs/tasks/subtree-controls.md @@ -1,6 +1,7 @@ --- status: shipped created: 2026-04-28 +updated: 2026-08-14 owner: cfl --- @@ -97,21 +98,26 @@ view) remains unchanged. **API:** `GET /tasks/:id/tree/cost-summary` -### 5. Interaction cancellation +### 5. Clarification dismissal -Pending clarification questions can be explicitly cancelled by the operator. +Pending clarification bundles can be skipped from task chat. -- Only clarification requests in `pending` status (stored in the in-memory - `clarification.Store`) can be cancelled. Requests that have already been answered, - rejected, or expired are not eligible. -- On cancel: the `CancelCh` on the `PendingClarification` is closed, which unblocks any - `WaitForResponse` caller in the agent turn. A continuation wakeup is queued for the - agent so it can proceed with the information that the question was cancelled. -- The clarification message in the task session is updated to `status = "cancelled"`. -- Activity is logged: `task.clarification_cancelled`. +- The chat X control is labelled "Skip all questions" and posts the exact visible bundle to + `POST /clarification/:id/respond` with `rejected=true`. +- When the in-memory waiter still owns the bundle, rejection unblocks `WaitForResponse` in the same + turn and every still-pending question row becomes `status = "rejected"`; terminal siblings remain + unchanged. +- When the current-turn bundle is detached, rejection still persists every still-pending question row + as rejected, preserves terminal siblings, closes the overlay, and completes clarification cleanup + without resuming the agent. +- A pending row from an older turn is superseded history. It cannot reappear as the active overlay, + re-arm a task icon, block workflow completion, or resume the agent from a stale response. +- `POST /clarification/:id/cancel` remains a low-level cancellation operation for a request still in + the in-memory `clarification.Store`. It closes `CancelCh`, persists `status = "cancelled"`, and + publishes the existing cancellation event. It is not the chat Skip path. -**UI:** A "Cancel" button (X icon) next to pending clarification questions in the task -chat thread. The button is not shown for already-answered or expired questions. +The current-turn ownership and restart behavior are defined by +[Active Clarification Lifecycle](../clarification-active-lifecycle/spec.md). --- @@ -125,6 +131,7 @@ POST /tasks/:id/tree/cancel → create cancel hold, cancel wakeups, inte POST /tasks/:id/tree/restore → release cancel hold, restore task statuses GET /tasks/:id/tree/cost-summary → aggregated cost across subtree POST /clarification/:id/cancel → cancel a pending clarification question +POST /clarification/:id/respond → answer or reject the visible clarification bundle ``` ### Preview response @@ -201,7 +208,9 @@ Index: `idx_tree_hold_members_task` on `(task_id)` — supports "is this task ga **GIVEN** a task tree has spent across 5 tasks, **WHEN** the operator opens the root task detail, **THEN** the tree cost card shows the aggregated total from `GET /tasks/:rootId/tree/cost-summary`, including token breakdown. -**GIVEN** an agent has posted a clarification question and the question is still pending, **WHEN** the operator clicks the Cancel (X) button on that question, **THEN** `POST /clarification/:id/cancel` is called, the `PendingClarification.CancelCh` is closed, a continuation wakeup is queued for the agent, and the message updates to `status="cancelled"` in the UI. +**GIVEN** an agent has posted a current-turn clarification bundle, **WHEN** the operator clicks the Skip (X) control, **THEN** `POST /clarification/:id/respond` rejects that exact bundle, every still-pending question updates to `status="rejected"` while terminal siblings remain unchanged, and an already-detached agent is not resumed. + +**GIVEN** a detached clarification remains pending in an older turn, **WHEN** the session accepts newer work, **THEN** the old row remains in history but no active overlay, task icon, workflow barrier, or stale response derives from it. ---