diff --git a/apps/backend/internal/backendapp/gateway.go b/apps/backend/internal/backendapp/gateway.go index 0cbf5a5210..37cc663bae 100644 --- a/apps/backend/internal/backendapp/gateway.go +++ b/apps/backend/internal/backendapp/gateway.go @@ -295,6 +295,17 @@ func provideGateway( projector := statussummary.NewProjector(statussummary.ProjectorConfig{ Store: taskRepo, EventBus: eventBus, + LoadTaskActivity: func(ctx context.Context, taskID string) (*time.Time, error) { + byTask, err := taskRepo.LoadTaskLastActivity(ctx, []string{taskID}) + if err != nil { + return nil, err + } + activityAt, ok := byTask[taskID] + if !ok { + return nil, nil + } + return &activityAt, nil + }, LoadSessionObservations: func(ctx context.Context, taskID string) (statussummary.SessionObservationSnapshot, error) { return loadTaskSessionObservations(ctx, taskRepo, activityProvider, taskID) }, diff --git a/apps/backend/internal/backendapp/services.go b/apps/backend/internal/backendapp/services.go index efd2dbf121..da8b6470d9 100644 --- a/apps/backend/internal/backendapp/services.go +++ b/apps/backend/internal/backendapp/services.go @@ -110,6 +110,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories Reviews: repos.Task, ResourceCleanups: repos.Task, StatusSummaries: repos.Task, + TaskActivity: repos.Task, SubagentContexts: repos.Task, }, eventBus, diff --git a/apps/backend/internal/github/graphql.go b/apps/backend/internal/github/graphql.go index 24d565819c..8e8da8396a 100644 --- a/apps/backend/internal/github/graphql.go +++ b/apps/backend/internal/github/graphql.go @@ -426,7 +426,7 @@ func convertBatchedPRResult(raw *batchedPRResult, owner, repo string, number int reviewState := summarizeReviewState(raw.Reviews.Nodes) checksState := "" if len(raw.Commits.Nodes) > 0 && raw.Commits.Nodes[0].Commit.StatusCheckRollup != nil { - checksState = strings.ToLower(raw.Commits.Nodes[0].Commit.StatusCheckRollup.State) + checksState = normalizeGraphQLCheckRollupState(raw.Commits.Nodes[0].Commit.StatusCheckRollup.State) } unresolved := 0 for _, t := range raw.ReviewThreads.Nodes { @@ -447,6 +447,23 @@ func convertBatchedPRResult(raw *batchedPRResult, owner, repo string, number int } } +// normalizeGraphQLCheckRollupState converts GitHub's GraphQL status-rollup +// enum to the smaller contract shared by REST and TaskPR persistence. +func normalizeGraphQLCheckRollupState(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "": + return "" + case checkStatusSuccess: + return checkStatusSuccess + case checkConclusionFail, "error": + return checkConclusionFail + case "expected", checkStatusPending: + return checkStatusPending + default: + return "" + } +} + // reviewNodesToSamples converts the GraphQL reviewNode shape to the shared // reviewSample slice used by the dedup helpers in client_helpers.go. func reviewNodesToSamples(nodes []reviewNode) []reviewSample { diff --git a/apps/backend/internal/github/graphql_test.go b/apps/backend/internal/github/graphql_test.go index e7e63585f1..9c155c756c 100644 --- a/apps/backend/internal/github/graphql_test.go +++ b/apps/backend/internal/github/graphql_test.go @@ -220,6 +220,29 @@ func TestRunBatchedPRQuery_DecodesAliasesBackToRefs(t *testing.T) { } } +func TestNormalizeGraphQLCheckRollupState_CheckRollup(t *testing.T) { + tests := []struct { + name string + raw string + want string + }{ + {name: "success", raw: "SUCCESS", want: checkStatusSuccess}, + {name: "failure", raw: "FAILURE", want: checkConclusionFail}, + {name: "error", raw: "ERROR", want: checkConclusionFail}, + {name: "pending", raw: "PENDING", want: checkStatusPending}, + {name: "expected", raw: "EXPECTED", want: checkStatusPending}, + {name: "empty", raw: "", want: ""}, + {name: "unknown", raw: "NEUTRAL", want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := normalizeGraphQLCheckRollupState(tt.raw); got != tt.want { + t.Fatalf("normalizeGraphQLCheckRollupState(%q) = %q, want %q", tt.raw, got, tt.want) + } + }) + } +} + func TestRunBatchedPRQuery_PaginatesResolvedReviewThreads(t *testing.T) { exec := &stubGraphQLExecutor{responses: []string{ batchedPRReviewThreadsResponse(t, 102, resolvedReviewThreadNodes(100), true, "cursor-1"), diff --git a/apps/backend/internal/github/service_pr_watch.go b/apps/backend/internal/github/service_pr_watch.go index 5a1491bd60..b26eda8ba0 100644 --- a/apps/backend/internal/github/service_pr_watch.go +++ b/apps/backend/internal/github/service_pr_watch.go @@ -797,6 +797,49 @@ func (s *Service) prepareTaskPRSyncState(ctx context.Context, tp *TaskPR, status } } +func taskPRChangedFields(tp *TaskPR, status *PRStatus, next taskPRSyncState) []string { + if tp == nil || status == nil || status.PR == nil { + return nil + } + changed := make([]string, 0, 16) + changed = appendChangedField(changed, "state", tp.State != next.state) + changed = appendChangedField(changed, "pr_title", tp.PRTitle != status.PR.Title) + changed = appendChangedField(changed, "additions", tp.Additions != status.PR.Additions) + changed = appendChangedField(changed, "deletions", tp.Deletions != status.PR.Deletions) + changed = appendChangedField(changed, "review_state", tp.ReviewState != status.ReviewState) + changed = appendChangedField(changed, "checks_state", tp.ChecksState != status.ChecksState) + changed = appendChangedField(changed, "mergeable_state", tp.MergeableState != next.mergeableState) + changed = appendChangedField(changed, "review_count", tp.ReviewCount != next.reviewCount) + changed = appendChangedField( + changed, + "pending_review_count", + tp.PendingReviewCount != next.pendingReviewCount, + ) + changed = appendChangedField( + changed, + "required_reviews", + !intPtrEqual(tp.RequiredReviews, next.requiredReviews), + ) + changed = appendChangedField(changed, "checks_total", tp.ChecksTotal != next.checksTotal) + changed = appendChangedField(changed, "checks_passing", tp.ChecksPassing != next.checksPassing) + changed = appendChangedField( + changed, + "unresolved_review_threads", + tp.UnresolvedReviewThreads != next.unresolved, + ) + changed = appendChangedField(changed, "base_branch", tp.BaseBranch != next.baseBranch) + changed = appendChangedField(changed, "merged_at", !timeEqual(tp.MergedAt, next.mergedAt)) + changed = appendChangedField(changed, "closed_at", !timeEqual(tp.ClosedAt, next.closedAt)) + return changed +} + +func appendChangedField(changed []string, field string, isChanged bool) []string { + if !isChanged { + return changed + } + return append(changed, field) +} + // SyncTaskPR updates a TaskPR record with the latest PR status. Multi-repo: // the row is found by (task_id, owner, repo, pr_number) since the same // task can have several PRs; the legacy GetTaskPR(taskID) "first match" @@ -815,22 +858,7 @@ func (s *Service) SyncTaskPR(ctx context.Context, taskID string, status *PRStatu } next := s.prepareTaskPRSyncState(ctx, tp, status) - changed := tp.State != next.state || - tp.PRTitle != status.PR.Title || - tp.Additions != status.PR.Additions || - tp.Deletions != status.PR.Deletions || - tp.ReviewState != status.ReviewState || - tp.ChecksState != status.ChecksState || - tp.MergeableState != next.mergeableState || - tp.ReviewCount != next.reviewCount || - tp.PendingReviewCount != next.pendingReviewCount || - !intPtrEqual(tp.RequiredReviews, next.requiredReviews) || - tp.ChecksTotal != next.checksTotal || - tp.ChecksPassing != next.checksPassing || - tp.UnresolvedReviewThreads != next.unresolved || - tp.BaseBranch != next.baseBranch || - !timeEqual(tp.MergedAt, next.mergedAt) || - !timeEqual(tp.ClosedAt, next.closedAt) + changedFields := taskPRChangedFields(tp, status, next) tp.State = next.state tp.PRTitle = status.PR.Title @@ -856,7 +884,18 @@ func (s *Service) SyncTaskPR(ctx context.Context, taskID string, status *PRStatu return fmt.Errorf("update task PR: %w", err) } - if changed && s.eventBus != nil { + if len(changedFields) > 0 { + if s.logger != nil { + s.logger.Debug("task PR semantic state changed", + zap.String("task_id", taskID), + zap.String("pr_owner", tp.Owner), + zap.String("pr_repo", tp.Repo), + zap.Int("pr_number", tp.PRNumber), + zap.Strings("changed_fields", changedFields), + ) + } + } + if len(changedFields) > 0 && s.eventBus != nil { event := bus.NewEvent(events.GitHubTaskPRUpdated, "github", tp) if err := s.eventBus.Publish(ctx, events.GitHubTaskPRUpdated, event); err != nil { s.logger.Debug("failed to publish task PR updated event", zap.Error(err)) diff --git a/apps/backend/internal/github/service_test.go b/apps/backend/internal/github/service_test.go index 26f6ccab7c..4e051d2dce 100644 --- a/apps/backend/internal/github/service_test.go +++ b/apps/backend/internal/github/service_test.go @@ -8,6 +8,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "reflect" "strings" "sync" "testing" @@ -952,6 +953,121 @@ func TestSyncTaskPR_SecondIdenticalSyncNoEvent(t *testing.T) { } } +func TestSyncTaskPR_EquivalentRESTAndGraphQLStatusNoSecondEvent(t *testing.T) { + svc, store, eb := setupSyncTest(t) + ctx := context.Background() + createdAt := time.Date(2026, 8, 17, 10, 0, 0, 0, time.UTC) + updatedAt := createdAt.Add(time.Hour) + pr := &PR{ + Number: 1, + Title: "Same status", + URL: "https://github.com/owner/repo/pull/1", + HTMLURL: "https://github.com/owner/repo/pull/1", + State: "open", + HeadBranch: "feat", + HeadSHA: "abc", + BaseBranch: "main", + AuthorLogin: "alice", + RepoOwner: "owner", + RepoName: "repo", + Mergeable: true, + MergeableState: "clean", + Additions: 3, + Deletions: 1, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + } + restStatus := newPRStatus( + pr, + []PRReview{{Author: "alice", State: reviewStateApproved, CreatedAt: updatedAt}}, + []CheckRun{{Status: checkStatusCompleted, Conclusion: checkConclusionSuccess}}, + ) + raw := &batchedPRResult{ + State: "OPEN", + Title: pr.Title, + URL: pr.URL, + HeadRefName: pr.HeadBranch, + BaseRefName: pr.BaseBranch, + HeadRefOid: pr.HeadSHA, + Additions: pr.Additions, + Deletions: pr.Deletions, + CreatedAt: pr.CreatedAt, + UpdatedAt: pr.UpdatedAt, + } + raw.Author.Login = pr.AuthorLogin + raw.Mergeable = "MERGEABLE" + raw.MergeStatus = "CLEAN" + raw.Reviews.Nodes = []reviewNode{{State: "APPROVED"}} + raw.Reviews.Nodes[0].Author.Login = "alice" + raw.Reviews.Nodes[0].SubmittedAt = updatedAt + raw.Commits.Nodes = []struct { + Commit struct { + StatusCheckRollup *struct { + State string `json:"state"` + } `json:"statusCheckRollup"` + } `json:"commit"` + }{{}} + raw.Commits.Nodes[0].Commit.StatusCheckRollup = &struct { + State string `json:"state"` + }{State: "SUCCESS"} + graphqlStatus := convertBatchedPRResult(raw, "owner", "repo", 1) + if graphqlStatus.ChecksState != restStatus.ChecksState { + t.Fatalf("GraphQL checks state = %q, REST = %q", graphqlStatus.ChecksState, restStatus.ChecksState) + } + if graphqlStatus.ReviewState != restStatus.ReviewState || graphqlStatus.ReviewCount != restStatus.ReviewCount { + t.Fatalf("GraphQL review summary = (%q, %d), REST = (%q, %d)", graphqlStatus.ReviewState, graphqlStatus.ReviewCount, restStatus.ReviewState, restStatus.ReviewCount) + } + if err := store.CreateTaskPR(ctx, &TaskPR{ + TaskID: "t1", + Owner: "owner", + Repo: "repo", + PRNumber: 1, + PRURL: pr.URL, + PRTitle: "Old title", + HeadBranch: pr.HeadBranch, + BaseBranch: pr.BaseBranch, + State: "open", + }); err != nil { + t.Fatalf("create task PR: %v", err) + } + if err := svc.SyncTaskPR(ctx, "t1", restStatus); err != nil { + t.Fatalf("REST sync: %v", err) + } + if err := svc.SyncTaskPR(ctx, "t1", graphqlStatus); err != nil { + t.Fatalf("GraphQL sync: %v", err) + } + if got := eb.publishedCount(); got != 1 { + t.Fatalf("equivalent REST and GraphQL snapshots published %d events, want 1", got) + } +} + +func TestSyncTaskPR_ChangedFields(t *testing.T) { + taskPR := &TaskPR{ + State: "open", + PRTitle: "before", + Additions: 1, + Deletions: 2, + ReviewState: "pending", + ChecksState: "pending", + MergeableState: "blocked", + ReviewCount: 1, + PendingReviewCount: 2, + ChecksTotal: 3, + ChecksPassing: 1, + BaseBranch: "main", + } + status := &PRStatus{PR: &PR{Title: "after", State: "merged", Additions: 4, Deletions: 5, BaseBranch: "release", MergeableState: "clean"}, ReviewState: "approved", ChecksState: "success", ReviewCount: 2, PendingReviewCount: 0, ChecksTotal: 4, ChecksPassing: 4} + next := taskPRSyncState{ + state: "merged", mergeableState: "clean", reviewCount: 2, pendingReviewCount: 0, + checksTotal: 4, checksPassing: 4, baseBranch: "release", + } + want := []string{"state", "pr_title", "additions", "deletions", "review_state", "checks_state", "mergeable_state", "review_count", "pending_review_count", "checks_total", "checks_passing", "base_branch"} + got := taskPRChangedFields(taskPR, status, next) + if !reflect.DeepEqual(got, want) { + t.Fatalf("taskPRChangedFields() = %#v, want %#v", got, want) + } +} + func TestSyncTaskPR_PublishesEventOnMergeableStateChange(t *testing.T) { svc, store, eb := setupSyncTest(t) ctx := context.Background() diff --git a/apps/backend/internal/orchestrator/handlers/queue_handlers.go b/apps/backend/internal/orchestrator/handlers/queue_handlers.go index 469be21c30..253652502d 100644 --- a/apps/backend/internal/orchestrator/handlers/queue_handlers.go +++ b/apps/backend/internal/orchestrator/handlers/queue_handlers.go @@ -253,7 +253,7 @@ func (h *QueueHandlers) wsQueueMessage(ctx context.Context, msg *ws.Message) (*w return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to queue message", nil) } - h.publishStatus(ctx, req.SessionID) + h.publishStatus(ctx, req.SessionID, queued) return ws.NewResponse(msg.ID, msg.Action, queued) } @@ -954,7 +954,7 @@ func queueAccessDeniedResponse(msg *ws.Message) *ws.Message { // publishStatus emits the latest QueueStatus on the event bus so the frontend // updates its store after every mutation. -func (h *QueueHandlers) publishStatus(ctx context.Context, sessionID string) { +func (h *QueueHandlers) publishStatus(ctx context.Context, sessionID string, admitted ...*messagequeue.QueuedMessage) { if h.eventBus == nil { return } @@ -966,6 +966,10 @@ func (h *QueueHandlers) publishStatus(ctx context.Context, sessionID string) { fieldMax: status.Max, "merge_enabled": status.MergeEnabled, } + if len(admitted) > 0 && admitted[0] != nil && admitted[0].QueuedBy != "" && !messagequeue.IsReservedQueuedBy(admitted[0].QueuedBy) { + eventData["queued_by"] = admitted[0].QueuedBy + eventData["queued_at"] = admitted[0].QueuedAt + } if h.sessionTaskResolver != nil { if taskID, err := h.sessionTaskResolver(ctx, sessionID); err != nil { h.logger.Warn("resolve session task for queue status event", diff --git a/apps/backend/internal/orchestrator/handlers/queue_handlers_publish_test.go b/apps/backend/internal/orchestrator/handlers/queue_handlers_publish_test.go index 394703a830..2b296078df 100644 --- a/apps/backend/internal/orchestrator/handlers/queue_handlers_publish_test.go +++ b/apps/backend/internal/orchestrator/handlers/queue_handlers_publish_test.go @@ -9,6 +9,7 @@ import ( "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/orchestrator/messagequeue" + ws "github.com/kandev/kandev/pkg/websocket" "github.com/stretchr/testify/require" ) @@ -73,6 +74,28 @@ func TestPublishStatusIncludesTaskIDWhenResolvable(t *testing.T) { require.Equal(t, "s1", events.lastData["session_id"]) } +func TestWsQueueMessagePublishesUserPromptActivity(t *testing.T) { + handlers, _, events := setupQueueHandlersWithResolver(t, func(_ context.Context, sessionID string) (string, error) { + if sessionID == "s1" { + return "task-1", nil + } + return "", nil + }) + + response, err := handlers.wsQueueMessage(context.Background(), createTestMessage(t, ws.ActionMessageQueueAdd, map[string]interface{}{ + "session_id": "s1", + "task_id": "task-1", + "content": "queued prompt", + "user_id": "user-1", + })) + require.NoError(t, err) + require.NotNil(t, response) + require.NotNil(t, events.lastData) + require.Equal(t, "user-1", events.lastData["queued_by"]) + require.IsType(t, time.Time{}, events.lastData["queued_at"]) + require.False(t, events.lastData["queued_at"].(time.Time).IsZero()) +} + func TestPublishStatusOmitsTaskIDWhenResolutionFails(t *testing.T) { handlers, svc, events := setupQueueHandlersWithResolver(t, func(context.Context, string) (string, error) { return "", errors.New("session gone") diff --git a/apps/backend/internal/orchestrator/messagequeue/auto_merge.go b/apps/backend/internal/orchestrator/messagequeue/auto_merge.go index 068f531551..de1e402a0b 100644 --- a/apps/backend/internal/orchestrator/messagequeue/auto_merge.go +++ b/apps/backend/internal/orchestrator/messagequeue/auto_merge.go @@ -3,6 +3,7 @@ package messagequeue import ( "bytes" "encoding/json" + "time" "github.com/kandev/kandev/internal/entityrefs" "github.com/kandev/kandev/internal/messageconstraints" @@ -13,6 +14,7 @@ type autoMergedValues struct { content string attachments []MessageAttachment metadata map[string]interface{} + queuedAt time.Time } func buildAutoMergedEntry(target, source *QueuedMessage) (*autoMergedValues, bool) { @@ -31,9 +33,17 @@ func buildAutoMergedEntry(target, source *QueuedMessage) (*autoMergedValues, boo content: joinAutoMergeContent(target.Content, source.Content), attachments: attachments, metadata: metadata, + queuedAt: latestQueuedAt(target.QueuedAt, source.QueuedAt), }, true } +func latestQueuedAt(first, second time.Time) time.Time { + if second.After(first) { + return second + } + return first +} + func autoMergeAllowed(target, source *QueuedMessage) bool { if target == nil || source == nil || target.IsDurableLifecycle() || source.IsDurableLifecycle() { return false diff --git a/apps/backend/internal/orchestrator/messagequeue/repository_auto_merge_test.go b/apps/backend/internal/orchestrator/messagequeue/repository_auto_merge_test.go index e7d4725797..db49b3301d 100644 --- a/apps/backend/internal/orchestrator/messagequeue/repository_auto_merge_test.go +++ b/apps/backend/internal/orchestrator/messagequeue/repository_auto_merge_test.go @@ -99,6 +99,9 @@ func TestRepository_AutoMergeIntoAbove_PreservesIdentityAndCombinesAdditiveData( }, }) targetIdentity := autoMergeIdentity(*target) + // The surviving row carries the newest admission timestamp so durable + // activity reconstruction does not lose the folded user prompt. + targetIdentity.QueuedAt = source.QueuedAt.UTC().Truncate(time.Microsecond) merged, didMerge, err := autoRepo.AutoMergeIntoAbove(context.Background(), "session", source.ID) if err != nil || !didMerge { diff --git a/apps/backend/internal/orchestrator/messagequeue/repository_memory.go b/apps/backend/internal/orchestrator/messagequeue/repository_memory.go index d1ffe1f402..b83e29442e 100644 --- a/apps/backend/internal/orchestrator/messagequeue/repository_memory.go +++ b/apps/backend/internal/orchestrator/messagequeue/repository_memory.go @@ -650,6 +650,7 @@ func (r *memoryRepository) MergeIntoAbove(_ context.Context, sessionID, sourceID target.Content = content target.Attachments = attachments target.Metadata = metadata + target.QueuedAt = latestQueuedAt(target.QueuedAt, source.QueuedAt) r.entries[sessionID] = append(list[:sourceIndex], list[sourceIndex+1:]...) if len(r.entries[sessionID]) == 0 { delete(r.entries, sessionID) @@ -694,6 +695,7 @@ func (r *memoryRepository) AutoMergeIntoAbove(_ context.Context, sessionID, sour target.Content = values.content target.Attachments = values.attachments target.Metadata = values.metadata + target.QueuedAt = values.queuedAt r.entries[sessionID] = append(list[:sourceIndex], list[sourceIndex+1:]...) return cloneQueuedMessage(target), true, nil } @@ -722,6 +724,7 @@ func (r *memoryRepository) AutoMergeCandidateIntoAbove(_ context.Context, candid target.Content = values.content target.Attachments = values.attachments target.Metadata = values.metadata + target.QueuedAt = values.queuedAt return cloneQueuedMessage(target), true, nil } diff --git a/apps/backend/internal/orchestrator/messagequeue/repository_postgres_merge_test.go b/apps/backend/internal/orchestrator/messagequeue/repository_postgres_merge_test.go index 36274f54ef..c8cb82103e 100644 --- a/apps/backend/internal/orchestrator/messagequeue/repository_postgres_merge_test.go +++ b/apps/backend/internal/orchestrator/messagequeue/repository_postgres_merge_test.go @@ -756,9 +756,17 @@ func TestPostgresRepository_MergeDrainOrdering_DrainWins(t *testing.T) { func TestPostgresRepository_MergeDrainOrdering_MergeWins(t *testing.T) { repo := newTestPostgresRepo(t) ctx := context.Background() + targetAt := time.Date(2026, 8, 18, 10, 0, 0, 0, time.UTC) + sourceAt := targetAt.Add(time.Minute) target := insertTestEntry(t, repo, "s1", "t1", "first", "user", nil, nil) source := insertTestEntry(t, repo, "s1", "t1", "second", "user", nil, nil) + sqlRepo := repo.(*sqliteRepository) + for id, queuedAt := range map[string]time.Time{target.ID: targetAt, source.ID: sourceAt} { + if _, err := sqlRepo.db.ExecContext(ctx, sqlRepo.db.Rebind(`UPDATE queued_messages SET queued_at = ? WHERE id = ?`), queuedAt, id); err != nil { + t.Fatalf("set queued_at for %s: %v", id, err) + } + } merged, err := repo.MergeIntoAbove(ctx, "s1", source.ID, "user") if err != nil { @@ -774,6 +782,9 @@ func TestPostgresRepository_MergeDrainOrdering_MergeWins(t *testing.T) { if drained.Content != "first\n\nsecond" { t.Errorf("drained content = %q, want combined content", drained.Content) } + if !merged.QueuedAt.Equal(sourceAt) || !drained.QueuedAt.Equal(sourceAt) { + t.Errorf("merged queued_at = %s, drained queued_at = %s, want %s", merged.QueuedAt, drained.QueuedAt, sourceAt) + } } // TestPostgresRepository_MergeIntoAbove_ReferenceOverflow asserts the overflow diff --git a/apps/backend/internal/orchestrator/messagequeue/repository_sqlite.go b/apps/backend/internal/orchestrator/messagequeue/repository_sqlite.go index b9af325049..f36ee4e005 100644 --- a/apps/backend/internal/orchestrator/messagequeue/repository_sqlite.go +++ b/apps/backend/internal/orchestrator/messagequeue/repository_sqlite.go @@ -152,6 +152,7 @@ func (r *sqliteRepository) initSchema() error { queued_by TEXT NOT NULL DEFAULT '' ); CREATE INDEX IF NOT EXISTS idx_queued_messages_session_position ON queued_messages(session_id, position); + CREATE INDEX IF NOT EXISTS idx_queued_messages_task_activity ON queued_messages(task_id, queued_by, queued_at); CREATE TABLE IF NOT EXISTS lifecycle_queue_generations ( task_id TEXT PRIMARY KEY, @@ -1562,6 +1563,7 @@ func (r *sqliteRepository) MergeIntoAbove(ctx context.Context, sessionID, source merged.Content = content merged.Attachments = attachments merged.Metadata = metadata + merged.QueuedAt = latestQueuedAt(target.QueuedAt, source.QueuedAt) return &merged, nil } @@ -1611,6 +1613,7 @@ func (r *sqliteRepository) AutoMergeIntoAbove(ctx context.Context, sessionID, so target.Content = values.content target.Attachments = values.attachments target.Metadata = values.metadata + target.QueuedAt = values.queuedAt return target, true, nil } @@ -1675,15 +1678,16 @@ func (r *sqliteRepository) AutoMergeCandidateIntoAbove(ctx context.Context, cand // the head), violating FIFO drain order. res, err := tx.ExecContext(ctx, r.db.Rebind(` UPDATE queued_messages - SET content = ?, attachments_json = ?, metadata_json = ? + SET content = ?, attachments_json = ?, metadata_json = ?, queued_at = ? WHERE id = ? AND session_id = ? AND position = ? AND content = ? AND attachments_json = ? AND metadata_json = ? - `), values.content, attachmentsJSON, metadataJSON, + AND queued_at = ? + `), values.content, attachmentsJSON, metadataJSON, values.queuedAt, target.ID, candidate.SessionID, target.Position, - storedContent, storedAttachmentsJSON, storedMetadataJSON) + storedContent, storedAttachmentsJSON, storedMetadataJSON, target.QueuedAt) if err != nil { return nil, false, fmt.Errorf("update automatic candidate merge target: %w", err) } @@ -1704,6 +1708,7 @@ func (r *sqliteRepository) AutoMergeCandidateIntoAbove(ctx context.Context, cand target.Content = values.content target.Attachments = values.attachments target.Metadata = values.metadata + target.QueuedAt = values.queuedAt return target, true, nil } @@ -1869,11 +1874,12 @@ func applyMergeWrites(ctx context.Context, r *sqliteRepository, tx *sqlx.Tx, tar if err != nil { return err } + queuedAt := latestQueuedAt(target.QueuedAt, source.QueuedAt) res, err := tx.ExecContext(ctx, r.db.Rebind(` UPDATE queued_messages - SET content = ?, attachments_json = ?, metadata_json = ? + SET content = ?, attachments_json = ?, metadata_json = ?, queued_at = ? WHERE id = ? AND session_id = ? - `), content, attachmentsJSON, metadataJSON, target.ID, sessionID) + `), content, attachmentsJSON, metadataJSON, queuedAt, target.ID, sessionID) if err != nil { return fmt.Errorf("update merge target: %w", err) } diff --git a/apps/backend/internal/orchestrator/messagequeue/repository_sqlite_merge_test.go b/apps/backend/internal/orchestrator/messagequeue/repository_sqlite_merge_test.go index 8934963008..1606f8be76 100644 --- a/apps/backend/internal/orchestrator/messagequeue/repository_sqlite_merge_test.go +++ b/apps/backend/internal/orchestrator/messagequeue/repository_sqlite_merge_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "testing" + "time" "github.com/kandev/kandev/internal/entityrefs" apiv1 "github.com/kandev/kandev/pkg/api/v1" @@ -200,6 +201,8 @@ func TestSQLiteRepository_MergeDrainOrdering_MergeWins(t *testing.T) { func TestSQLiteRepository_MergeIntoAbove_UserMergesIntoUser(t *testing.T) { repo := newTestSQLiteRepo(t) ctx := context.Background() + targetAt := time.Date(2026, 8, 18, 10, 0, 0, 0, time.UTC) + sourceAt := targetAt.Add(time.Minute) target := insertTestEntry(t, repo, "s1", "t1", "first", "user", []MessageAttachment{{Type: "image", Data: "a", MimeType: "image/png"}}, @@ -211,6 +214,12 @@ func TestSQLiteRepository_MergeIntoAbove_UserMergesIntoUser(t *testing.T) { map[string]interface{}{ MetadataEntityReferences: entityRefs("1", "2"), }) + sqliteRepo := repo.(*sqliteRepository) + for id, queuedAt := range map[string]time.Time{target.ID: targetAt, source.ID: sourceAt} { + if _, err := sqliteRepo.db.ExecContext(ctx, `UPDATE queued_messages SET queued_at = ? WHERE id = ?`, queuedAt, id); err != nil { + t.Fatalf("set queued_at for %s: %v", id, err) + } + } merged, err := repo.MergeIntoAbove(ctx, "s1", source.ID, "user") if err != nil { @@ -222,6 +231,9 @@ func TestSQLiteRepository_MergeIntoAbove_UserMergesIntoUser(t *testing.T) { if merged.ID != target.ID { t.Errorf("merged id = %s, want target id %s", merged.ID, target.ID) } + if !merged.QueuedAt.Equal(sourceAt) { + t.Errorf("merged queued_at = %s, want %s", merged.QueuedAt, sourceAt) + } if merged.Content != "first\n\nsecond" { t.Errorf("merged content = %q, want %q", merged.Content, "first\n\nsecond") } @@ -235,6 +247,13 @@ func TestSQLiteRepository_MergeIntoAbove_UserMergesIntoUser(t *testing.T) { if merged.QueuedBy != "user" { t.Errorf("merged queued_by = %q, want user", merged.QueuedBy) } + entries, err := repo.ListBySession(ctx, "s1") + if err != nil { + t.Fatalf("list merged row: %v", err) + } + if len(entries) != 1 || !entries[0].QueuedAt.Equal(sourceAt) { + t.Fatalf("persisted merged queued_at = %#v, want %s", entries, sourceAt) + } count, _ := repo.CountBySession(ctx, "s1") if count != 1 { t.Errorf("count after merge = %d, want 1", count) diff --git a/apps/backend/internal/orchestrator/messagequeue/repository_sqlite_test.go b/apps/backend/internal/orchestrator/messagequeue/repository_sqlite_test.go index 477a235518..05568f1fe8 100644 --- a/apps/backend/internal/orchestrator/messagequeue/repository_sqlite_test.go +++ b/apps/backend/internal/orchestrator/messagequeue/repository_sqlite_test.go @@ -83,6 +83,31 @@ func TestSQLiteRepository_InsertList(t *testing.T) { } } +func TestSQLiteRepository_HasTaskActivityIndex(t *testing.T) { + repo := newTestSQLiteRepo(t) + sqliteRepo := repo.(*sqliteRepository) + rows, err := sqliteRepo.db.Queryx(`PRAGMA index_info('idx_queued_messages_task_activity')`) + if err != nil { + t.Fatalf("inspect task activity index: %v", err) + } + defer func() { _ = rows.Close() }() + + wantColumns := []string{"task_id", "queued_by", "queued_at"} + for index, want := range wantColumns { + if !rows.Next() { + t.Fatalf("task activity index ended at column %d, want %q", index, want) + } + var seqno, columnID int + var column string + if err := rows.Scan(&seqno, &columnID, &column); err != nil { + t.Fatalf("scan task activity index column %d: %v", index, err) + } + if column != want { + t.Fatalf("task activity index column %d = %q, want %q", index, column, want) + } + } +} + func TestSQLiteRepository_InsertRejectsOverflow(t *testing.T) { repo := newTestSQLiteRepo(t) ctx := context.Background() diff --git a/apps/backend/internal/orchestrator/messagequeue/service.go b/apps/backend/internal/orchestrator/messagequeue/service.go index 0cf0fcf9db..25b7ad6307 100644 --- a/apps/backend/internal/orchestrator/messagequeue/service.go +++ b/apps/backend/internal/orchestrator/messagequeue/service.go @@ -15,6 +15,7 @@ import ( "fmt" "sync" "sync/atomic" + "time" "github.com/kandev/kandev/internal/common/logger" "go.uber.org/zap" @@ -195,6 +196,7 @@ func (s *Service) queueMessageWithMetadataAdmission(ctx context.Context, session PlanMode: planMode, Attachments: attachments, Metadata: copyMessageMetadata(metadata, 0), + QueuedAt: time.Now().UTC(), QueuedBy: userID, } merged, didMerge, mergeErr := s.repo.AutoMergeCandidateIntoAbove(admittedCtx, candidate) diff --git a/apps/backend/internal/orchestrator/messagequeue/service_test.go b/apps/backend/internal/orchestrator/messagequeue/service_test.go index ea3458ad57..b364fcc376 100644 --- a/apps/backend/internal/orchestrator/messagequeue/service_test.go +++ b/apps/backend/internal/orchestrator/messagequeue/service_test.go @@ -991,12 +991,14 @@ func TestMemoryRepository_MergeIntoAbove(t *testing.T) { target := &QueuedMessage{ SessionID: "s1", TaskID: "t1", Content: "first", QueuedBy: "alice", + QueuedAt: time.Date(2026, 8, 18, 10, 0, 0, 0, time.UTC), Attachments: []MessageAttachment{{Type: "image", Data: "a", MimeType: "image/png"}}, Metadata: map[string]interface{}{MetadataEntityReferences: []apiv1.EntityReference{ref}}, } require.NoError(t, repo.Insert(ctx, target, 0)) source := &QueuedMessage{ SessionID: "s1", TaskID: "t1", Content: "second", QueuedBy: "alice", + QueuedAt: target.QueuedAt.Add(time.Minute), Attachments: []MessageAttachment{{Type: "file", Data: "b", MimeType: "text/plain"}}, Metadata: map[string]interface{}{MetadataEntityReferences: []apiv1.EntityReference{ref}}, } @@ -1006,6 +1008,7 @@ func TestMemoryRepository_MergeIntoAbove(t *testing.T) { require.NoError(t, err) assert.Equal(t, target.ID, merged.ID) assert.Equal(t, "first\n\nsecond", merged.Content) + assert.Equal(t, source.QueuedAt, merged.QueuedAt) assert.Len(t, merged.Attachments, 2) assert.Len(t, entityrefs.NormalizePersisted(merged.Metadata[MetadataEntityReferences]), 1) @@ -1013,6 +1016,7 @@ func TestMemoryRepository_MergeIntoAbove(t *testing.T) { require.NoError(t, err) assert.Len(t, entries, 1) assert.Equal(t, "first\n\nsecond", entries[0].Content) + assert.Equal(t, source.QueuedAt, entries[0].QueuedAt) } // TestMemoryRepository_MergeIntoAbove_MixedKindsRejected covers the memory repo diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index 2da98f7ce1..7bb5634022 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -158,6 +158,12 @@ type TaskStatusSummaryRepository interface { DeleteTaskStatusSummary(ctx context.Context, taskID string) error } +// TaskActivityRepository reconstructs the bounded task activity timestamp +// from authoritative task, prompt, and turn rows. +type TaskActivityRepository interface { + LoadTaskLastActivity(ctx context.Context, taskIDs []string) (map[string]time.Time, error) +} + // TaskRepoRepository handles the task↔repository junction table (models.TaskRepository rows). // Named TaskRepoRepository to reduce reader confusion with the TaskRepository sub-interface above. type TaskRepoRepository interface { diff --git a/apps/backend/internal/task/repository/sqlite/task_status_summary.go b/apps/backend/internal/task/repository/sqlite/task_status_summary.go index cfef7a2103..5a0dfe0755 100644 --- a/apps/backend/internal/task/repository/sqlite/task_status_summary.go +++ b/apps/backend/internal/task/repository/sqlite/task_status_summary.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "time" "github.com/kandev/kandev/internal/task/statussummary" @@ -67,6 +68,116 @@ func (r *Repository) LoadTaskStatusSummaries(ctx context.Context, taskIDs []stri return result, nil } +// LoadTaskLastActivity returns the latest bounded activity timestamp for each +// requested task. Every source is filtered by task ID inside its union branch +// so the batch remains scoped and does not scan unrelated task history. +func (r *Repository) LoadTaskLastActivity(ctx context.Context, taskIDs []string) (map[string]time.Time, error) { + result := make(map[string]time.Time, len(taskIDs)) + if len(taskIDs) == 0 { + return result, nil + } + + for _, chunk := range chunkIDs(taskIDs, sqliteMaxHostParams/6) { + placeholders, ids := buildInPlaceholders(chunk) + query := ` + WITH activity AS ( + SELECT id AS task_id, created_at AS activity_at + FROM tasks + WHERE id IN (` + placeholders + `) + UNION ALL + SELECT id AS task_id, updated_at AS activity_at + FROM tasks + WHERE id IN (` + placeholders + `) + UNION ALL + SELECT task_id, created_at AS activity_at + FROM task_session_messages + WHERE task_id IN (` + placeholders + `) + AND author_type = 'user' + UNION ALL + SELECT task_id, queued_at AS activity_at + FROM queued_messages + WHERE task_id IN (` + placeholders + `) + AND queued_by NOT IN ('agent', 'workflow', 'server') + UNION ALL + SELECT task_id, started_at AS activity_at + FROM task_session_turns + WHERE task_id IN (` + placeholders + `) + AND started_at IS NOT NULL + UNION ALL + SELECT task_id, completed_at AS activity_at + FROM task_session_turns + WHERE task_id IN (` + placeholders + `) + AND completed_at IS NOT NULL + ) + SELECT task_id, MAX(activity_at) AS activity_at + FROM activity + GROUP BY task_id` + args := make([]interface{}, 0, len(ids)*6) + for range 6 { + args = append(args, ids...) + } + rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), args...) + if err != nil { + return nil, fmt.Errorf("load task last activity: %w", err) + } + for rows.Next() { + var ( + taskID string + rawActivityAt interface{} + ) + if err := rows.Scan(&taskID, &rawActivityAt); err != nil { + _ = rows.Close() + return nil, fmt.Errorf("scan task last activity: %w", err) + } + activityAt, err := parseTaskActivityTime(rawActivityAt) + if err != nil { + _ = rows.Close() + return nil, fmt.Errorf("parse task last activity %q: %w", taskID, err) + } + result[taskID] = activityAt + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return nil, fmt.Errorf("read task last activity: %w", err) + } + _ = rows.Close() + } + return result, nil +} + +func parseTaskActivityTime(value interface{}) (time.Time, error) { + switch value := value.(type) { + case time.Time: + return value, nil + case string: + return parseTaskActivityTimeString(value) + case []byte: + return parseTaskActivityTimeString(string(value)) + default: + return time.Time{}, fmt.Errorf("unsupported timestamp type %T", value) + } +} + +func parseTaskActivityTimeString(value string) (time.Time, error) { + if parsed, err := time.Parse(time.RFC3339Nano, value); err == nil { + return parsed, nil + } + if normalized := strings.Replace(value, " ", "T", 1); normalized != value { + if parsed, err := time.Parse(time.RFC3339Nano, normalized); err == nil { + return parsed, nil + } + } + for _, layout := range []string{ + "2006-01-02 15:04:05.999999999", + "2006-01-02 15:04:05", + } { + if parsed, err := time.ParseInLocation(layout, value, time.UTC); err == nil { + return parsed, nil + } + } + return time.Time{}, fmt.Errorf("invalid timestamp %q", value) +} + // CompareAndUpdateTaskStatusSummary inserts a new row or replaces an older // semantic value. The revision and semantic comparison live in the same SQL // statement, so concurrent source observations cannot overwrite a newer row. diff --git a/apps/backend/internal/task/repository/sqlite/task_status_summary_postgres_test.go b/apps/backend/internal/task/repository/sqlite/task_status_summary_postgres_test.go index 3486c11c2a..cbd62205c2 100644 --- a/apps/backend/internal/task/repository/sqlite/task_status_summary_postgres_test.go +++ b/apps/backend/internal/task/repository/sqlite/task_status_summary_postgres_test.go @@ -5,6 +5,8 @@ import ( "testing" "time" + "github.com/kandev/kandev/internal/orchestrator/messagequeue" + "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/statussummary" "github.com/kandev/kandev/internal/testutil" ) @@ -53,3 +55,76 @@ func TestPostgresTaskStatusSummarySchemaReplay(t *testing.T) { t.Fatalf("postgres summary = %#v", loaded["task-summary-postgres"]) } } + +func TestPostgresTaskLastActivityBatch(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("initialize postgres schema: %v", err) + } + ctx := context.Background() + base := time.Date(2026, time.August, 17, 10, 0, 0, 0, time.UTC) + if _, err := db.Exec(db.Rebind(` + INSERT INTO tasks (id, workspace_id, title, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `), "task-activity-postgres", "workspace-activity-postgres", "Postgres activity", base, base.Add(time.Hour)); err != nil { + t.Fatalf("seed postgres task: %v", err) + } + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ID: "session-activity-postgres", TaskID: "task-activity-postgres"}); err != nil { + t.Fatalf("create postgres session: %v", err) + } + completedAt := base.Add(3 * time.Hour) + if err := repo.CreateTurn(ctx, &models.Turn{ + ID: "turn-activity-postgres", TaskSessionID: "session-activity-postgres", TaskID: "task-activity-postgres", + StartedAt: base.Add(2 * time.Hour), CompletedAt: &completedAt, + }); err != nil { + t.Fatalf("create postgres turn: %v", err) + } + if err := repo.CreateMessage(ctx, &models.Message{ + ID: "message-activity-postgres", TaskSessionID: "session-activity-postgres", TaskID: "task-activity-postgres", + TurnID: "turn-activity-postgres", AuthorType: models.MessageAuthorUser, Content: "prompt", + CreatedAt: base.Add(4 * time.Hour), + }); err != nil { + t.Fatalf("create postgres message: %v", err) + } + queueRepo, err := messagequeue.NewSQLiteRepository(db, db) + if err != nil { + t.Fatalf("initialize postgres queue repository: %v", err) + } + queuedAt := base.Add(5 * time.Hour) + queuedTarget := &messagequeue.QueuedMessage{ + ID: "queued-activity-postgres-user-target", SessionID: "session-activity-postgres", TaskID: "task-activity-postgres", + Content: "queued target", QueuedAt: queuedAt.Add(-time.Minute), QueuedBy: "user-1", + } + if err := queueRepo.Insert(ctx, queuedTarget, 10); err != nil { + t.Fatalf("insert postgres queued target: %v", err) + } + queuedSource := &messagequeue.QueuedMessage{ + ID: "queued-activity-postgres-user", SessionID: "session-activity-postgres", TaskID: "task-activity-postgres", + Content: "queued prompt", QueuedAt: queuedAt, QueuedBy: "user-1", + } + if err := queueRepo.Insert(ctx, queuedSource, 10); err != nil { + t.Fatalf("insert postgres queued prompt: %v", err) + } + merged, err := queueRepo.MergeIntoAbove(ctx, queuedSource.SessionID, queuedSource.ID, queuedSource.QueuedBy) + if err != nil { + t.Fatalf("merge postgres queued prompts: %v", err) + } + if merged == nil || !merged.QueuedAt.Equal(queuedAt) { + t.Fatalf("merged postgres queued activity = %#v, want %s", merged, queuedAt) + } + if err := queueRepo.Insert(ctx, &messagequeue.QueuedMessage{ + ID: "queued-activity-postgres-agent", SessionID: "session-activity-postgres", TaskID: "task-activity-postgres", + Content: "queued agent prompt", QueuedAt: base.Add(6 * time.Hour), QueuedBy: messagequeue.QueuedByAgent, + }, 10); err != nil { + t.Fatalf("insert postgres queued agent prompt: %v", err) + } + + got, err := repo.LoadTaskLastActivity(ctx, []string{"task-activity-postgres"}) + if err != nil { + t.Fatalf("load postgres task activity: %v", err) + } + if gotAt, ok := got["task-activity-postgres"]; !ok || !gotAt.Equal(queuedAt) { + t.Fatalf("postgres activity = %v, %v; want %v", gotAt, ok, queuedAt) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/task_status_summary_test.go b/apps/backend/internal/task/repository/sqlite/task_status_summary_test.go index ed21735028..ab53ecaceb 100644 --- a/apps/backend/internal/task/repository/sqlite/task_status_summary_test.go +++ b/apps/backend/internal/task/repository/sqlite/task_status_summary_test.go @@ -10,6 +10,8 @@ import ( "github.com/jmoiron/sqlx" dbutil "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/orchestrator/messagequeue" + "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/statussummary" ) @@ -151,6 +153,145 @@ func TestTaskStatusSummaryBatchCompareAndUpdate(t *testing.T) { } } +func TestTaskLastActivityBatch(t *testing.T) { + repo, db := newTaskStatusSummaryTestRepo(t) + ctx := context.Background() + base := time.Date(2026, time.August, 17, 10, 0, 0, 0, time.UTC) + seedTaskAt := func(taskID string, createdAt, updatedAt time.Time) { + t.Helper() + if _, err := db.Exec(db.Rebind(` + INSERT INTO tasks (id, workspace_id, title, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `), taskID, "workspace-activity", taskID, createdAt, updatedAt); err != nil { + t.Fatalf("seed task %q: %v", taskID, err) + } + } + seedTaskAt("task-activity-turn", base, base.Add(time.Hour)) + seedTaskAt("task-activity-active", base.Add(2*time.Hour), base.Add(3*time.Hour)) + seedTaskAt("task-activity-no-session", base.Add(4*time.Hour), base.Add(5*time.Hour)) + seedTaskAt("task-activity-queued", base.Add(4*time.Hour), base.Add(5*time.Hour)) + + queueRepo, err := messagequeue.NewSQLiteRepository(db, db) + if err != nil { + t.Fatalf("initialize queue repository: %v", err) + } + queuedAt := base.Add(9 * time.Hour) + queuedTargetAt := queuedAt.Add(-time.Minute) + queuedTarget := &messagequeue.QueuedMessage{ + ID: "queued-activity-user-target", SessionID: "session-activity-queued", TaskID: "task-activity-queued", + Content: "queued target", QueuedAt: queuedTargetAt, QueuedBy: "user-1", + } + if err := queueRepo.Insert(ctx, queuedTarget, 10); err != nil { + t.Fatalf("insert queued user target: %v", err) + } + queuedSource := &messagequeue.QueuedMessage{ + ID: "queued-activity-user", SessionID: "session-activity-queued", TaskID: "task-activity-queued", + Content: "queued prompt", QueuedAt: queuedAt, QueuedBy: "user-1", + } + if err := queueRepo.Insert(ctx, queuedSource, 10); err != nil { + t.Fatalf("insert queued user prompt: %v", err) + } + merged, err := queueRepo.MergeIntoAbove(ctx, queuedSource.SessionID, queuedSource.ID, queuedSource.QueuedBy) + if err != nil { + t.Fatalf("merge queued user prompts: %v", err) + } + if merged == nil || !merged.QueuedAt.Equal(queuedAt) { + t.Fatalf("merged queued activity = %#v, want %s", merged, queuedAt) + } + if err := queueRepo.Insert(ctx, &messagequeue.QueuedMessage{ + ID: "queued-activity-agent", SessionID: "session-activity-queued", TaskID: "task-activity-queued", + Content: "queued agent prompt", QueuedAt: base.Add(12 * time.Hour), QueuedBy: messagequeue.QueuedByAgent, + }, 10); err != nil { + t.Fatalf("insert queued agent prompt: %v", err) + } + + for _, session := range []*models.TaskSession{ + {ID: "session-activity-turn", TaskID: "task-activity-turn"}, + {ID: "session-activity-active", TaskID: "task-activity-active"}, + } { + if err := repo.CreateTaskSession(ctx, session); err != nil { + t.Fatalf("create session %q: %v", session.ID, err) + } + } + completedAt := base.Add(8 * time.Hour) + if err := repo.CreateTurn(ctx, &models.Turn{ + ID: "turn-activity-completed", + TaskSessionID: "session-activity-turn", + TaskID: "task-activity-turn", + StartedAt: base.Add(6 * time.Hour), + CompletedAt: &completedAt, + }); err != nil { + t.Fatalf("create completed turn: %v", err) + } + if err := repo.CreateMessage(ctx, &models.Message{ + ID: "message-activity-user", + TaskSessionID: "session-activity-turn", + TaskID: "task-activity-turn", + TurnID: "turn-activity-completed", + AuthorType: models.MessageAuthorUser, + Content: "user prompt", + CreatedAt: base.Add(7 * time.Hour), + }); err != nil { + t.Fatalf("create user message: %v", err) + } + if err := repo.CreateMessage(ctx, &models.Message{ + ID: "message-activity-agent", + TaskSessionID: "session-activity-turn", + TaskID: "task-activity-turn", + TurnID: "turn-activity-completed", + AuthorType: models.MessageAuthorAgent, + Content: "agent output", + CreatedAt: base.Add(12 * time.Hour), + }); err != nil { + t.Fatalf("create agent message: %v", err) + } + if err := repo.CreateTurn(ctx, &models.Turn{ + ID: "turn-activity-active", + TaskSessionID: "session-activity-active", + TaskID: "task-activity-active", + StartedAt: base.Add(10 * time.Hour), + }); err != nil { + t.Fatalf("create active turn: %v", err) + } + if err := repo.CreateMessage(ctx, &models.Message{ + ID: "message-activity-agent-active", + TaskSessionID: "session-activity-active", + TaskID: "task-activity-active", + TurnID: "turn-activity-active", + AuthorType: models.MessageAuthorAgent, + Content: "streamed output", + CreatedAt: base.Add(11 * time.Hour), + }); err != nil { + t.Fatalf("create active agent message: %v", err) + } + + got, err := repo.LoadTaskLastActivity(ctx, []string{ + "task-activity-turn", + "task-activity-active", + "task-activity-no-session", + "task-activity-queued", + "missing-task", + }) + if err != nil { + t.Fatalf("load task activity: %v", err) + } + want := map[string]time.Time{ + "task-activity-turn": completedAt, + "task-activity-active": base.Add(10 * time.Hour), + "task-activity-no-session": base.Add(5 * time.Hour), + "task-activity-queued": queuedAt, + } + if len(got) != len(want) { + t.Fatalf("activity rows = %#v, want %#v", got, want) + } + for taskID, wantAt := range want { + gotAt, ok := got[taskID] + if !ok || !gotAt.Equal(wantAt) { + t.Errorf("activity[%q] = %v, %v; want %v", taskID, gotAt, ok, wantAt) + } + } +} + func TestTaskStatusSummaryConcurrentUpdatesRemainMonotonic(t *testing.T) { repo, db := newTaskStatusSummaryTestRepo(t) ctx := context.Background() diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index 968f10623f..93021f1761 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -278,6 +278,7 @@ type Repos struct { Reviews repository.ReviewRepository ResourceCleanups repository.TaskResourceCleanupRepository StatusSummaries repository.TaskStatusSummaryRepository + TaskActivity repository.TaskActivityRepository SubagentContexts repository.SubagentContextRepository } @@ -301,6 +302,7 @@ type Service struct { reviews repository.ReviewRepository resourceCleanups repository.TaskResourceCleanupRepository statusSummaries repository.TaskStatusSummaryRepository + taskActivity repository.TaskActivityRepository subagentContexts repository.SubagentContextRepository attachmentSvc *AttachmentService statusSummaryPRs TaskStatusSummaryPRReader @@ -440,6 +442,7 @@ func NewService(repos Repos, eventBus bus.EventBus, log *logger.Logger, discover reviews: repos.Reviews, resourceCleanups: repos.ResourceCleanups, statusSummaries: repos.StatusSummaries, + taskActivity: repos.TaskActivity, subagentContexts: repos.SubagentContexts, eventBus: eventBus, logger: log, diff --git a/apps/backend/internal/task/service/service_events.go b/apps/backend/internal/task/service/service_events.go index 19020c8cc5..6bbb6f398e 100644 --- a/apps/backend/internal/task/service/service_events.go +++ b/apps/backend/internal/task/service/service_events.go @@ -378,8 +378,8 @@ func (s *Service) publishTaskEventNow(ctx context.Context, eventType string, tas "priority": task.Priority, "position": task.Position, "wip_admitted": task.WIPAdmitted, - "created_at": task.CreatedAt.Format(time.RFC3339), - "updated_at": task.UpdatedAt.Format(time.RFC3339), + "created_at": task.CreatedAt.Format(time.RFC3339Nano), + "updated_at": task.UpdatedAt.Format(time.RFC3339Nano), "is_ephemeral": task.IsEphemeral, "autopilot": task.Autopilot, // Consumers that restore quick-chat tabs filter on origin, so it has to @@ -388,7 +388,7 @@ func (s *Service) publishTaskEventNow(ctx context.Context, eventType string, tas } data["queued_for_step_id"] = task.QueuedForStepID if task.QueuedAt != nil { - data["queued_at"] = task.QueuedAt.Format(time.RFC3339) + data["queued_at"] = task.QueuedAt.Format(time.RFC3339Nano) } else { data["queued_at"] = nil } @@ -407,7 +407,7 @@ func (s *Service) publishTaskEventNow(ctx context.Context, eventType string, tas } data["archived_at"] = nil if task.ArchivedAt != nil { - data["archived_at"] = task.ArchivedAt.Format(time.RFC3339) + data["archived_at"] = task.ArchivedAt.Format(time.RFC3339Nano) } // Orchestrator-originated events fetch the task via the raw repo.GetTask, // which does not populate Repositories. Load on demand so the payload diff --git a/apps/backend/internal/task/service/service_events_test.go b/apps/backend/internal/task/service/service_events_test.go index f7933f44c7..9d75b54414 100644 --- a/apps/backend/internal/task/service/service_events_test.go +++ b/apps/backend/internal/task/service/service_events_test.go @@ -122,6 +122,42 @@ func TestTaskPublication_ActivityRefreshDoesNotOvertakeOrdinaryUpdate(t *testing <-activityDone } +func TestTaskPublication_PreservesSameSecondActivityOrdering(t *testing.T) { + svc, eventBus, _ := createTestService(t) + messageAt := time.Date(2026, 8, 18, 10, 20, 0, 100_000_000, time.UTC) + taskUpdatedAt := messageAt.Add(800 * time.Millisecond) + + svc.publishTaskEventNow(context.Background(), events.TaskUpdated, &models.Task{ + ID: "task-precision", + WorkspaceID: "ws-1", + CreatedAt: messageAt.Add(-time.Minute), + UpdatedAt: taskUpdatedAt, + }, nil, nil, nil, nil) + + published := eventBus.GetPublishedEvents() + if len(published) != 1 { + t.Fatalf("published events = %d, want 1", len(published)) + } + data, ok := published[0].Data.(map[string]interface{}) + if !ok { + t.Fatalf("event data type = %T, want map[string]interface{}", published[0].Data) + } + updatedAt, ok := data["updated_at"].(string) + if !ok { + t.Fatalf("updated_at type = %T, want string", data["updated_at"]) + } + parsed, err := time.Parse(time.RFC3339Nano, updatedAt) + if err != nil { + t.Fatalf("parse updated_at %q: %v", updatedAt, err) + } + if !parsed.Equal(taskUpdatedAt) { + t.Fatalf("published updated_at = %s, want %s", parsed, taskUpdatedAt) + } + if !parsed.After(messageAt) { + t.Fatalf("task mutation at %s was not ordered after same-second message at %s", parsed, messageAt) + } +} + func TestTaskPublication_QueuedActivityOutlivesCallerCancellation(t *testing.T) { svc, eventBus, repo := createTestServiceWithSessionsRepo(t, func(repo *sqliterepo.Repository) repository.SessionRepository { return cancellationAwareSessionRepository{SessionRepository: repo} 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 0a67efcbdb..1441760983 100644 --- a/apps/backend/internal/task/service/service_status_summary_rebuild.go +++ b/apps/backend/internal/task/service/service_status_summary_rebuild.go @@ -53,8 +53,9 @@ func (s *Service) ReconcileTaskStatusSummaries( if s == nil || s.statusSummaries == nil || len(tasks) == 0 { return summaries, nil } + activityByTask, activityObserved := s.loadSummaryActivity(ctx, taskIDs(tasks)) failedTaskIDs, reconcileErr := s.reconcileExistingSummaries( - ctx, tasks, sessionsByTask, pendingBySession, summaries, + ctx, tasks, sessionsByTask, pendingBySession, summaries, activityByTask, activityObserved, ) rebuildTasks := tasks if len(failedTaskIDs) > 0 { @@ -69,7 +70,7 @@ func (s *Service) ReconcileTaskStatusSummaries( } } summaries = s.rebuildMissingSummaries( - ctx, rebuildTasks, sessionsByTask, pendingBySession, summaries, + ctx, rebuildTasks, sessionsByTask, pendingBySession, summaries, activityByTask, activityObserved, ) return summaries, reconcileErr } @@ -80,6 +81,8 @@ func (s *Service) reconcileExistingSummaries( sessionsByTask map[string][]*models.TaskSession, pendingBySession map[string]models.TaskPendingAction, summaries map[string]*statussummary.TaskStatusSummary, + activityByTask map[string]time.Time, + activityObserved bool, ) (map[string]struct{}, error) { var reconcileErr error failedTaskIDs := make(map[string]struct{}) @@ -94,6 +97,8 @@ func (s *Service) reconcileExistingSummaries( task, summaries[task.ID], action, + activityByTask[task.ID], + activityObserved, ) if err != nil { if reconciled != nil { @@ -127,6 +132,8 @@ func (s *Service) rebuildMissingSummaries( sessionsByTask map[string][]*models.TaskSession, pendingBySession map[string]models.TaskPendingAction, summaries map[string]*statussummary.TaskStatusSummary, + activityByTask map[string]time.Time, + activityObserved bool, ) map[string]*statussummary.TaskStatusSummary { missing := missingSummaryTasks(tasks, summaries) if len(missing) == 0 { @@ -135,8 +142,10 @@ func (s *Service) rebuildMissingSummaries( prByTask, prObserved := s.loadSummaryPRs(ctx, taskIDs(missing)) gitBySession, gitObserved := s.loadSummaryGit(ctx, sessionIDsForTasks(missing, sessionsByTask)) queuedByTask := s.loadQueuedSummaryCounts(ctx, taskIDs(missing)) + activityAtByTask := activityByTask now := time.Now().UTC() for _, task := range missing { + activityAt := activityAtByTask[task.ID] s.rebuildMissingSummary(ctx, task, summaries, s.rebuildInput( sessionsByTask[task.ID], pendingBySession, @@ -145,6 +154,8 @@ func (s *Service) rebuildMissingSummaries( prByTask[task.ID], prObserved, queuedByTask[task.ID], + activityAt, + activityObserved, now, )) } @@ -162,6 +173,20 @@ func (s *Service) loadQueuedSummaryCounts(ctx context.Context, taskIDs []string) return map[string]int{} } +func (s *Service) loadSummaryActivity(ctx context.Context, taskIDs []string) (map[string]time.Time, bool) { + if s.taskActivity == nil || len(taskIDs) == 0 { + return nil, false + } + activityByTask, err := s.taskActivity.LoadTaskLastActivity(ctx, taskIDs) + if err != nil { + if s.logger != nil { + s.logger.Warn("failed to load task activity for status summary repair", zap.Error(err)) + } + return nil, false + } + return activityByTask, true +} + func (s *Service) rebuildMissingSummary( ctx context.Context, task *models.Task, @@ -211,9 +236,14 @@ func (s *Service) reconcileExistingSummary( task *models.Task, current *statussummary.TaskStatusSummary, pendingAction string, + authoritativeActivity time.Time, + activityObserved bool, ) (*statussummary.TaskStatusSummary, error) { for attempt := 0; attempt < maxSummaryReconcileAttempts && current != nil; attempt++ { - if current.PendingAction == pendingAction { + activityNeedsRepair := activityObserved && + (authoritativeActivity.After(time.Time{}) && + (current.LastActivityAt == nil || authoritativeActivity.After(*current.LastActivityAt))) + if current.PendingAction == pendingAction && !activityNeedsRepair { return current, nil } if err := prepareSummaryReconcileAttempt(ctx, attempt, current.Revision); err != nil { @@ -221,6 +251,9 @@ func (s *Service) reconcileExistingSummary( } next := *current next.PendingAction = pendingAction + if activityObserved && authoritativeActivity.After(time.Time{}) { + next.LastActivityAt = maxSummaryActivity(current.LastActivityAt, authoritativeActivity) + } next.Revision = current.Revision + 1 next.UpdatedAt = advancedSummaryTime(current.UpdatedAt, time.Now().UTC()) if err := next.Validate(); err != nil { @@ -259,13 +292,32 @@ func (s *Service) reconcileExistingSummary( } pendingAction = pendingActionForTask(refreshedSessions, pendingBySession) } - if current != nil && current.PendingAction == pendingAction { + activityNeedsRepair := activityObserved && + (authoritativeActivity.After(time.Time{}) && + (current != nil && (current.LastActivityAt == nil || authoritativeActivity.After(*current.LastActivityAt)))) + if current != nil && current.PendingAction == pendingAction && !activityNeedsRepair { return current, nil } s.logSummaryReconcileExhaustion(task.ID, current) return nil, errors.New("exhausted compare-and-set retries") } +func maxSummaryActivity(current *time.Time, candidate time.Time) *time.Time { + if candidate.IsZero() { + if current == nil { + return nil + } + copy := current.UTC() + return © + } + if current != nil && !candidate.After(*current) { + copy := current.UTC() + return © + } + copy := candidate.UTC() + return © +} + func (s *Service) logSummaryReconcileExhaustion( taskID string, current *statussummary.TaskStatusSummary, @@ -447,18 +499,25 @@ func (s *Service) rebuildInput( prs []statussummary.PullRequestInput, prObserved bool, queuedPromptCount int, + lastActivityAt time.Time, + activityObserved bool, now time.Time, ) statussummary.RebuildInput { input := statussummary.RebuildInput{ Sessions: make([]statussummary.RebuildSession, 0, len(sessions)), PendingActions: make(map[string]string), ActivityObserved: s.foregroundActivity != nil, + LastActivityAt: nil, PullRequests: prs, PRObserved: prObserved, GitObserved: gitObserved, QueuedPromptCount: maxInt(queuedPromptCount, 0), Now: now, } + if activityObserved && !lastActivityAt.IsZero() { + activityCopy := lastActivityAt.UTC() + input.LastActivityAt = &activityCopy + } countProvider, hasCountProvider := s.foregroundActivity.(activeSubagentCountProvider) for _, session := range sessions { if session == nil || session.ID == "" { 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 6b154fe3a5..5ff9979098 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 @@ -6,12 +6,14 @@ import ( "testing" "time" + "github.com/jmoiron/sqlx" "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/orchestrator/messagequeue" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/repository" "github.com/kandev/kandev/internal/task/statussummary" @@ -415,6 +417,68 @@ func TestReconcileTaskStatusSummariesRepairsExistingPendingOnly(t *testing.T) { } } +func TestStatusSummaryActivityRebuildBackfillsAndPreservesNewerStoredValue(t *testing.T) { + svc, _, repo := createTestService(t) + svc.statusSummaries = repo + svc.taskActivity = repo + queueDB := sqlx.NewDb(repo.DB(), "sqlite3") + if _, err := messagequeue.NewSQLiteRepository(queueDB, queueDB); err != nil { + t.Fatalf("initialize queue repository: %v", err) + } + ctx := context.Background() + createTaskWithoutRepositories(t, ctx, repo) + base := time.Date(2026, time.August, 17, 10, 0, 0, 0, time.UTC) + expected := base.Add(3 * time.Hour) + if _, err := repo.DB().Exec( + `UPDATE tasks SET created_at = ?, updated_at = ? WHERE id = ?`, + base, expected, "task-1", + ); err != nil { + t.Fatalf("set task timestamps: %v", err) + } + + tasks := []*models.Task{{ID: "task-1", WorkspaceID: "ws-1"}} + got, err := svc.ReconcileTaskStatusSummaries( + ctx, + tasks, + map[string][]*models.TaskSession{}, + map[string]models.TaskPendingAction{}, + map[string]*statussummary.TaskStatusSummary{}, + ) + if err != nil { + t.Fatalf("rebuild missing status summary: %v", err) + } + backfilled := got["task-1"] + if backfilled == nil || backfilled.LastActivityAt == nil || !backfilled.LastActivityAt.Equal(expected) { + t.Fatalf("backfilled activity = %+v, want %v", backfilled, expected) + } + + newer := expected.Add(time.Hour) + stored := statussummary.TaskStatusSummary{ + Revision: 9, + LastActivityAt: &newer, + } + accepted, err := repo.CompareAndUpdateTaskStatusSummary(ctx, &statussummary.StoredTaskStatusSummary{ + TaskID: "task-1", WorkspaceID: "ws-1", Summary: stored, + }) + if err != nil || !accepted { + t.Fatalf("seed newer summary: accepted=%v err=%v", accepted, err) + } + got, err = svc.ReconcileTaskStatusSummaries( + ctx, + tasks, + map[string][]*models.TaskSession{}, + map[string]models.TaskPendingAction{}, + map[string]*statussummary.TaskStatusSummary{"task-1": &stored}, + ) + if err != nil { + t.Fatalf("reconcile newer status summary: %v", err) + } + preserved := got["task-1"] + if preserved == nil || preserved.Revision != stored.Revision || preserved.LastActivityAt == nil || !preserved.LastActivityAt.Equal(newer) { + t.Fatalf("newer stored activity = %+v, want revision %d at %v", preserved, stored.Revision, newer) + } +} + func TestReconcileTaskStatusSummariesInvalidatesStaleSummaryOnRepairFailure(t *testing.T) { svc, _, repo := createTestService(t) repairErr := errors.New("status summary write failed") @@ -584,6 +648,8 @@ func TestReconcileExistingSummaryStopsBeforeRetryWhenContextIsCanceled(t *testin &models.Task{ID: "task-1", WorkspaceID: "ws-1"}, stored, "", + time.Time{}, + false, ) if !errors.Is(err, context.Canceled) { t.Fatalf("reconcileExistingSummary error = %v, want context canceled", err) diff --git a/apps/backend/internal/task/statussummary/constants.go b/apps/backend/internal/task/statussummary/constants.go index 09ed401cc8..b81e4095d5 100644 --- a/apps/backend/internal/task/statussummary/constants.go +++ b/apps/backend/internal/task/statussummary/constants.go @@ -8,6 +8,7 @@ const ( messageTypeClarificationRequest = "clarification_request" messageTypePermissionRequest = "permission_request" + messageTypeUser = "user" messageTypeError = "error" messageTypeStatus = "status" messageTypeAgent = "agent" diff --git a/apps/backend/internal/task/statussummary/model.go b/apps/backend/internal/task/statussummary/model.go index 73da925a8f..ba2ffbd063 100644 --- a/apps/backend/internal/task/statussummary/model.go +++ b/apps/backend/internal/task/statussummary/model.go @@ -28,6 +28,7 @@ const ( type TaskStatusSummary struct { Revision uint64 `json:"revision"` UpdatedAt time.Time `json:"updated_at"` + LastActivityAt *time.Time `json:"last_activity_at,omitempty"` PrimarySession *PrimarySessionSummary `json:"primary_session,omitempty"` ForegroundActivity string `json:"foreground_activity,omitempty"` ActiveSubagentCount int `json:"active_subagent_count,omitempty"` @@ -186,6 +187,7 @@ func (s TaskStatusSummary) SemanticJSON() ([]byte, error) { return nil, err } return json.Marshal(semanticPayload{ + LastActivityAt: s.LastActivityAt, PrimarySession: s.PrimarySession, ForegroundActivity: s.ForegroundActivity, ActiveSubagentCount: s.ActiveSubagentCount, @@ -198,6 +200,7 @@ func (s TaskStatusSummary) SemanticJSON() ([]byte, error) { } type semanticPayload struct { + LastActivityAt *time.Time `json:"last_activity_at,omitempty"` PrimarySession *PrimarySessionSummary `json:"primary_session,omitempty"` ForegroundActivity string `json:"foreground_activity,omitempty"` ActiveSubagentCount int `json:"active_subagent_count,omitempty"` diff --git a/apps/backend/internal/task/statussummary/model_test.go b/apps/backend/internal/task/statussummary/model_test.go index 35c31ac2e8..721f24a04f 100644 --- a/apps/backend/internal/task/statussummary/model_test.go +++ b/apps/backend/internal/task/statussummary/model_test.go @@ -29,6 +29,34 @@ func TestTaskStatusSummarySemanticEqualityIgnoresTransportMetadata(t *testing.T) } } +func TestTaskStatusSummaryLastActivityIsSemanticAndBackwardCompatible(t *testing.T) { + activity := time.Date(2026, time.August, 17, 12, 0, 0, 0, time.UTC) + first := TaskStatusSummary{LastActivityAt: &activity} + second := first + if first.SemanticEqual(second) == false { + t.Fatal("equal last activity timestamps should compare equal") + } + later := activity.Add(time.Minute) + second.LastActivityAt = &later + if first.SemanticEqual(second) { + t.Fatal("different last activity timestamps must not compare equal") + } + payload, err := first.SemanticJSON() + if err != nil { + t.Fatalf("last activity semantic JSON: %v", err) + } + if !strings.Contains(string(payload), `"last_activity_at"`) { + t.Fatalf("last activity missing from semantic JSON: %s", payload) + } + var decoded TaskStatusSummary + if err := json.Unmarshal([]byte(`{"revision": 4, "updated_at": "2026-08-17T10:00:00Z"}`), &decoded); err != nil { + t.Fatalf("decode legacy summary: %v", err) + } + if decoded.LastActivityAt != nil { + t.Fatalf("legacy summary activity = %v, want nil", decoded.LastActivityAt) + } +} + func TestTaskStatusSummarySemanticJSONIsBoundedAndOmitsTransportMetadata(t *testing.T) { summary := TaskStatusSummary{ Revision: 4, diff --git a/apps/backend/internal/task/statussummary/projector.go b/apps/backend/internal/task/statussummary/projector.go index 6003fa851a..27513f1415 100644 --- a/apps/backend/internal/task/statussummary/projector.go +++ b/apps/backend/internal/task/statussummary/projector.go @@ -54,6 +54,10 @@ type SessionObservationSnapshot struct { // observations for one task. type SessionObservationLoader func(context.Context, string) (SessionObservationSnapshot, error) +// TaskActivityLoader rehydrates the durable maximum activity timestamp for a +// task when a projector starts with a legacy or incomplete summary row. +type TaskActivityLoader func(context.Context, string) (*time.Time, 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) @@ -78,6 +82,7 @@ type ProjectorConfig struct { LoadGitObservations GitObservationLoader LoadPendingActions PendingActionLoader LoadSessionObservations SessionObservationLoader + LoadTaskActivity TaskActivityLoader LoadPullRequests PullRequestLoader // CountQueuedPrompts returns the number of prompts currently en-queued for // a task across all of its sessions (pending semantics identical to @@ -99,6 +104,7 @@ type Projector struct { loadGitObservations GitObservationLoader loadPendingActions PendingActionLoader loadSessionObservations SessionObservationLoader + loadTaskActivity TaskActivityLoader loadPullRequests PullRequestLoader countQueuedPrompts func(context.Context, string) (int, error) logger *logger.Logger @@ -121,6 +127,7 @@ type projectionState struct { revision uint64 current *TaskStatusSummary queuedCount int + lastActivityAt *time.Time sessions map[string]sessionObservation pending map[string]string pendingRequests map[string]pendingRequestIdentity @@ -184,6 +191,7 @@ func NewProjector(cfg ProjectorConfig) *Projector { loadGitObservations: cfg.LoadGitObservations, loadPendingActions: cfg.LoadPendingActions, loadSessionObservations: cfg.LoadSessionObservations, + loadTaskActivity: cfg.LoadTaskActivity, loadPullRequests: cfg.LoadPullRequests, countQueuedPrompts: cfg.CountQueuedPrompts, logger: log.WithFields(zap.String("component", "task-status-summary-projector")), @@ -201,13 +209,17 @@ func (p *Projector) Start(ctx context.Context) error { return nil } patterns := []string{ + events.TaskCreated, events.TaskUpdated, + events.TaskStateChanged, events.TaskSessionStateChanged, events.TaskSessionActivityChanged, events.TaskSessionErrorChanged, events.MessageAdded, events.MessageUpdated, events.MessageDeleted, + events.TurnStarted, + events.TurnCompleted, events.ClarificationAnswered, events.ClarificationPrimaryAnswered, events.ClarificationCancelled, @@ -304,16 +316,16 @@ func (p *Projector) handleEvent(ctx context.Context, event *bus.Event) error { } if event.Type == events.MessageQueueStatusChanged { + activityChanged := applyTaskActivityEventLocked(state, event.Type, data) + pendingChanged := false if p.loadPendingActions != nil && !state.pendingObserved { - pendingChanged, refreshErr := p.refreshPendingLocked(ctx, taskID, state) + var refreshErr error + 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) + return p.applyQueueStatusEvent(ctx, state, taskID, pendingChanged || activityChanged, event.Type, data) } refreshPending := p.loadPendingActions != nil && @@ -391,46 +403,36 @@ const maxQueueCountPersistAttempts = 3 // authoritative queue store. The event payload's per-session count is not // reused: the badge is per-task across all sessions, and the queue may have // changed between the status snapshot and this projection. -func (p *Projector) applyQueueStatusEvent(ctx context.Context, state *projectionState, taskID string) error { - if p.countQueuedPrompts == nil { - return nil - } +func (p *Projector) applyQueueStatusEvent( + ctx context.Context, + state *projectionState, + taskID string, + sourceChanged bool, + eventType string, + eventData map[string]interface{}, +) error { for attempt := 0; attempt < maxQueueCountPersistAttempts; attempt++ { - count, err := p.countQueuedPrompts(ctx, taskID) + count, err := p.loadQueueCount(ctx, taskID, state) if err != nil { - return fmt.Errorf("count queued prompts for task %q: %w", taskID, err) + if persistErr := p.persistQueueSourceOnCountError(ctx, taskID, state, sourceChanged); persistErr != nil { + return persistErr + } + return err } - if count == state.queuedCount { + if !sourceChanged && count == state.queuedCount { return nil } - previousCount := state.queuedCount - state.queuedCount = count - accepted, err := p.persistAndPublishLocked(ctx, taskID, state) + accepted, err := p.persistQueueStatus(ctx, taskID, state, count) if err != nil { - // Keep in-memory state aligned with the last persisted value so a - // later zero-count event can retry instead of short-circuiting. - state.queuedCount = previousCount - // Delete cascades the summary row (FK) before the post-commit - // queue-status event. With warm state the recount hits persist - // rather than resolveWorkspace. Only suppress a verified gone-task - // failure; transient DB errors must propagate. - if count == 0 && isGoneTaskPersistErr(err) { - p.logger.Debug("skipping queue status persist for gone task", - zap.String("task_id", taskID), - zap.Error(err)) - return nil - } return err } 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 { + if err := p.rebaseQueueStatusEvent(ctx, taskID, state, eventType, eventData); err != nil { return err } + sourceChanged = true } // 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 @@ -441,6 +443,75 @@ func (p *Projector) applyQueueStatusEvent(ctx context.Context, state *projection return nil } +func (p *Projector) loadQueueCount(ctx context.Context, taskID string, state *projectionState) (int, error) { + if p.countQueuedPrompts == nil { + return state.queuedCount, nil + } + count, err := p.countQueuedPrompts(ctx, taskID) + if err != nil { + return 0, fmt.Errorf("count queued prompts for task %q: %w", taskID, err) + } + return count, nil +} + +func (p *Projector) persistQueueSourceOnCountError( + ctx context.Context, + taskID string, + state *projectionState, + sourceChanged bool, +) error { + if !sourceChanged { + return nil + } + _, err := p.persistAndPublishLocked(ctx, taskID, state) + return err +} + +func (p *Projector) persistQueueStatus(ctx context.Context, taskID string, state *projectionState, count int) (bool, error) { + previousCount := state.queuedCount + state.queuedCount = count + accepted, err := p.persistAndPublishLocked(ctx, taskID, state) + if err == nil { + return accepted, nil + } + // Keep in-memory state aligned with the last persisted value so a later + // zero-count event can retry instead of short-circuiting. + state.queuedCount = previousCount + // Delete cascades the summary row (FK) before the post-commit queue-status + // event. With warm state the recount hits persist rather than resolveWorkspace. + // Only suppress a verified gone-task failure; transient DB errors propagate. + if count == 0 && isGoneTaskPersistErr(err) { + p.logger.Debug("skipping queue status persist for gone task", + zap.String("task_id", taskID), + zap.Error(err)) + return true, nil + } + return false, err +} + +func (p *Projector) rebaseQueueStatusEvent( + ctx context.Context, + taskID string, + state *projectionState, + eventType string, + eventData map[string]interface{}, +) error { + // 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 + } + if eventType != "" { + applyTaskActivityEventLocked(state, eventType, eventData) + } + if p.loadPendingActions == nil { + return nil + } + _, err := p.refreshPendingLocked(ctx, taskID, state) + return err +} + func (p *Projector) lockTask(taskID string) func() { p.taskLockMu.Lock() lock := p.taskLocks[taskID] @@ -504,6 +575,7 @@ func (p *Projector) persistAndPublishLocked(ctx context.Context, taskID string, state.current = cloneSummary(stored) state.revision = stored.Revision state.queuedCount = stored.QueuedPromptCount + state.lastActivityAt = maxTimePtr(state.lastActivityAt, stored.LastActivityAt) } return false, nil } diff --git a/apps/backend/internal/task/statussummary/projector_derive.go b/apps/backend/internal/task/statussummary/projector_derive.go index c9cd7ee60d..996b03de2c 100644 --- a/apps/backend/internal/task/statussummary/projector_derive.go +++ b/apps/backend/internal/task/statussummary/projector_derive.go @@ -15,6 +15,7 @@ func deriveSummary(state *projectionState) TaskStatusSummary { Git: deriveGitSummary(state), PullRequest: derivePullRequestSummary(state), QueuedPromptCount: state.queuedCount, + LastActivityAt: cloneTimePtr(state.lastActivityAt), } } diff --git a/apps/backend/internal/task/statussummary/projector_events.go b/apps/backend/internal/task/statussummary/projector_events.go index 66c18edbba..9fd8388d9f 100644 --- a/apps/backend/internal/task/statussummary/projector_events.go +++ b/apps/backend/internal/task/statussummary/projector_events.go @@ -4,52 +4,111 @@ import ( "context" "fmt" "strings" + "time" "github.com/kandev/kandev/internal/events" ) func (p *Projector) applySourceEventLocked(state *projectionState, eventType string, data map[string]interface{}) bool { + activityChanged := applyTaskActivityEventLocked(state, eventType, data) + var sourceChanged bool switch eventType { + case events.TaskCreated, events.TaskStateChanged: + // These lifecycle events have no additional bounded status fields. Their + // persisted timestamps are handled by applyTaskActivityEventLocked. + sourceChanged = false case events.TaskUpdated: - return p.applyTaskUpdatedEventLocked(state, data) + sourceChanged = p.applyTaskUpdatedEventLocked(state, data) case events.TaskSessionStateChanged: - return p.applySessionEventLocked(state, data) + sourceChanged = p.applySessionEventLocked(state, data) case events.TaskSessionActivityChanged: - return p.applyActivityEventLocked(state, data) + sourceChanged = p.applyActivityEventLocked(state, data) case events.TaskSessionErrorChanged: - return p.applyErrorEventLocked(state, data) + sourceChanged = p.applyErrorEventLocked(state, data) case events.MessageAdded, events.MessageUpdated, events.MessageDeleted: - return p.applyMessageEventLocked(state, eventType, data) + sourceChanged = p.applyMessageEventLocked(state, eventType, data) + case events.TurnStarted, events.TurnCompleted: + // Turn timestamps are the bounded activity source. The turn payload does + // not contribute another task-row status field. + sourceChanged = false case events.ClarificationAnswered, events.ClarificationPrimaryAnswered, events.ClarificationCancelled: if p.loadPendingActions != nil { - return false + sourceChanged = false + break } - return p.clearPendingLocked(state, stringField(data, "session_id")) + sourceChanged = p.clearPendingLocked(state, stringField(data, "session_id")) case events.ClarificationStaleDismissed: if p.loadPendingActions != nil { - return false + sourceChanged = false + break } sessionID := stringField(data, "session_id") dismissedID := stringField(data, "pending_id") if dismissedID != "" { if current, ok := state.pendingRequests[sessionID]; ok && current.pendingID != dismissedID { - return false + sourceChanged = false + break } } - return p.clearPendingLocked(state, sessionID) + sourceChanged = p.clearPendingLocked(state, sessionID) case events.PermissionRequestReceived: if p.loadPendingActions != nil { + sourceChanged = false + break + } + sourceChanged = applyPermissionEventLocked(state, data) + case events.GitEvent: + sourceChanged = p.applyGitEventLocked(state, data) + case events.GitHubTaskPRUpdated: + sourceChanged = p.applyPREventLocked(state, data) + } + return activityChanged || sourceChanged +} + +func applyTaskActivityEventLocked(state *projectionState, eventType string, data map[string]interface{}) bool { + var candidate time.Time + switch eventType { + case events.TaskCreated, events.TaskUpdated, events.TaskStateChanged: + candidate = timeValue(data["updated_at"]) + if candidate.IsZero() { + candidate = timeValue(data["created_at"]) + } + case events.MessageAdded: + if stringField(data, "author_type") != messageTypeUser { return false } - return applyPermissionEventLocked(state, data) - case events.GitEvent: - return p.applyGitEventLocked(state, data) - case events.GitHubTaskPRUpdated: - return p.applyPREventLocked(state, data) + candidate = timeValue(data["created_at"]) + case events.MessageQueueStatusChanged: + if !isUserQueuedPrompt(data) { + return false + } + candidate = timeValue(data["queued_at"]) + case events.TurnStarted: + candidate = timeValue(data["started_at"]) + case events.TurnCompleted: + candidate = timeValue(data["completed_at"]) default: return false } + return advanceTaskActivity(state, candidate) +} + +func isUserQueuedPrompt(data map[string]interface{}) bool { + queuedBy := stringField(data, "queued_by") + return queuedBy != "" && queuedBy != "agent" && queuedBy != "workflow" && queuedBy != "server" +} + +func advanceTaskActivity(state *projectionState, candidate time.Time) bool { + if candidate.IsZero() { + return false + } + candidate = candidate.UTC() + if state.lastActivityAt != nil && !candidate.After(*state.lastActivityAt) { + return false + } + state.lastActivityAt = &candidate + return true } func applyPermissionEventLocked(state *projectionState, data map[string]interface{}) bool { @@ -218,18 +277,20 @@ func (p *Projector) ensureState(ctx context.Context, taskID string) (*projection } func (p *Projector) restorePersistedState(ctx context.Context, taskID string, state *projectionState) error { - if p.store == nil { - return nil + if p.store != nil { + rows, err := p.store.LoadTaskStatusSummaries(ctx, []string{taskID}) + if err != nil { + return fmt.Errorf("load task status summary %q: %w", taskID, err) + } + summary := rows[taskID] + if summary != nil { + state.current = cloneSummary(summary) + state.revision = summary.Revision + applySummaryBaseline(state, summary) + } } - rows, err := p.store.LoadTaskStatusSummaries(ctx, []string{taskID}) - if err != nil { - return fmt.Errorf("load task status summary %q: %w", taskID, err) - } - summary := rows[taskID] - if summary != nil { - state.current = cloneSummary(summary) - state.revision = summary.Revision - applySummaryBaseline(state, summary) + if err := p.restoreTaskActivity(ctx, taskID, state); err != nil { + return err } if err := p.restoreSessionObservations(ctx, taskID, state); err != nil { return err @@ -246,6 +307,7 @@ func (p *Projector) restorePersistedState(ctx context.Context, taskID string, st func applySummaryBaseline(state *projectionState, summary *TaskStatusSummary) { state.queuedCount = summary.QueuedPromptCount state.taskPending = summary.PendingAction + state.lastActivityAt = maxTimePtr(state.lastActivityAt, summary.LastActivityAt) if summary.PrimarySession != nil && summary.PrimarySession.ID != "" { state.sessions[summary.PrimarySession.ID] = sessionObservation{ id: summary.PrimarySession.ID, @@ -267,6 +329,18 @@ func applySummaryBaseline(state *projectionState, summary *TaskStatusSummary) { } } +func (p *Projector) restoreTaskActivity(ctx context.Context, taskID string, state *projectionState) error { + if p.loadTaskActivity == nil || state.lastActivityAt != nil { + return nil + } + activityAt, err := p.loadTaskActivity(ctx, taskID) + if err != nil { + return fmt.Errorf("load task activity for task status summary %q: %w", taskID, err) + } + state.lastActivityAt = maxTimePtr(state.lastActivityAt, activityAt) + return nil +} + // 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. @@ -279,8 +353,10 @@ func (p *Projector) rebaseProjectionStateFromCurrent( return nil } current := state.current + previousLastActivityAt := cloneTimePtr(state.lastActivityAt) state.sessions = make(map[string]sessionObservation) state.activityObserved = false + state.lastActivityAt = previousLastActivityAt state.pending = make(map[string]string) state.pendingRequests = make(map[string]pendingRequestIdentity) state.pendingObserved = false @@ -293,6 +369,9 @@ func (p *Projector) rebaseProjectionStateFromCurrent( state.prBaseline = nil state.prObserved = false applySummaryBaseline(state, current) + if err := p.restoreTaskActivity(ctx, taskID, state); err != nil { + return err + } if err := p.restoreSessionObservations(ctx, taskID, state); err != nil { return err } diff --git a/apps/backend/internal/task/statussummary/projector_helpers.go b/apps/backend/internal/task/statussummary/projector_helpers.go index 269678bfb3..98ce5aa344 100644 --- a/apps/backend/internal/task/statussummary/projector_helpers.go +++ b/apps/backend/internal/task/statussummary/projector_helpers.go @@ -282,6 +282,10 @@ func timeValue(value interface{}) time.Time { switch value := value.(type) { case time.Time: return value + case *time.Time: + if value != nil { + return *value + } case string: parsed, err := time.Parse(time.RFC3339Nano, value) if err == nil { @@ -291,6 +295,26 @@ func timeValue(value interface{}) time.Time { return time.Time{} } +func cloneTimePtr(value *time.Time) *time.Time { + if value == nil || value.IsZero() { + return nil + } + copy := value.UTC() + return © +} + +func maxTimePtr(left, right *time.Time) *time.Time { + left = cloneTimePtr(left) + right = cloneTimePtr(right) + if left == nil { + return right + } + if right == nil || !right.After(*left) { + return left + } + return right +} + func maxInt(value, minimum int) int { if value < minimum { return minimum diff --git a/apps/backend/internal/task/statussummary/projector_queued_test.go b/apps/backend/internal/task/statussummary/projector_queued_test.go index d95ccccf77..10cc44576f 100644 --- a/apps/backend/internal/task/statussummary/projector_queued_test.go +++ b/apps/backend/internal/task/statussummary/projector_queued_test.go @@ -39,6 +39,40 @@ func TestProjectorQueueEventUpdatesQueuedPromptCount(t *testing.T) { } } +func TestProjectorQueueEventTracksUserPromptActivity(t *testing.T) { + projector, store, eventBus, updates, _ := newProjectorTest(t) + const taskID = "task-queued-activity" + queuedAt := time.Date(2026, 8, 1, 19, 0, 0, 0, time.UTC) + + projector.countQueuedPrompts = func(context.Context, string) (int, error) { + return 1, nil + } + publishProjectorEvent(t, eventBus, events.MessageQueueStatusChanged, events.MessageQueueStatusChanged, map[string]interface{}{ + "task_id": taskID, + "queued_by": "user-1", + "queued_at": queuedAt, + }) + + summary := store.summary(taskID) + if summary == nil || summary.LastActivityAt == nil || !summary.LastActivityAt.Equal(queuedAt) { + t.Fatalf("queued user activity = %+v, want %s", summary, queuedAt) + } + if got := updates.Load(); got != 1 { + t.Fatalf("queued user admission published %d summary updates, want 1", got) + } + + // Queue status events without a user-owned admission must remain count-only + // bookkeeping, even when they carry a newer timestamp-shaped value. + publishProjectorEvent(t, eventBus, events.MessageQueueStatusChanged, events.MessageQueueStatusChanged, map[string]interface{}{ + "task_id": taskID, + "queued_by": "agent", + "queued_at": queuedAt.Add(time.Hour), + }) + if got := store.summary(taskID).LastActivityAt; got == nil || !got.Equal(queuedAt) { + t.Fatalf("agent queue activity changed last activity to %v, want %s", got, queuedAt) + } +} + func TestProjectorQueueEventWithUnchangedCountDoesNotRepublish(t *testing.T) { projector, store, eventBus, updates, _ := newProjectorTest(t) const taskID = "task-queued-unchanged" diff --git a/apps/backend/internal/task/statussummary/projector_test.go b/apps/backend/internal/task/statussummary/projector_test.go index ff40a4c87d..017f59ad80 100644 --- a/apps/backend/internal/task/statussummary/projector_test.go +++ b/apps/backend/internal/task/statussummary/projector_test.go @@ -334,6 +334,170 @@ func TestProjectorDerivesBoundedStatusAcrossSources(t *testing.T) { } } +func TestProjectorLastActivityTracksBoundedSourcesMonotonically(t *testing.T) { + _, store, eventBus, _, _ := newProjectorTest(t) + const taskID = "task-last-activity-projector" + const sessionID = "session-last-activity-projector" + + publishProjectorEvent(t, eventBus, events.TaskCreated, events.TaskCreated, map[string]interface{}{ + "task_id": taskID, + "workspace_id": "workspace-1", + "created_at": "2026-08-01T18:01:00Z", + "updated_at": "2026-08-01T18:01:00Z", + }) + assertProjectedLastActivity(t, store.summary(taskID), "2026-08-01T18:01:00Z") + + // Focus and subscription changes are operational state, not task activity. + publishProjectorEvent(t, eventBus, events.TaskSessionActivityChanged, events.TaskSessionActivityChanged, map[string]interface{}{ + "task_id": taskID, + "workspace_id": "workspace-1", + "session_id": sessionID, + "foreground_activity": "background", + "updated_at": "2026-08-01T18:02:00Z", + }) + assertProjectedLastActivity(t, store.summary(taskID), "2026-08-01T18:01:00Z") + + // Provider polling can refresh a PR summary without making the task active. + publishProjectorEvent(t, eventBus, events.GitHubTaskPRUpdated, events.GitHubTaskPRUpdated, map[string]interface{}{ + "task_id": taskID, + "workspace_id": "workspace-1", + "repository_id": "repo-last-activity", + "state": "open", + "pr_number": 12, + "pr_url": "https://example.test/pr/12", + "updated_at": "2026-08-01T18:03:00Z", + }) + assertProjectedLastActivity(t, store.summary(taskID), "2026-08-01T18:01:00Z") + + // Agent-authored output and streamed/message-update bookkeeping do not move + // activity. The user-authored message below is the first message source that + // should move it. + publishProjectorEvent(t, eventBus, events.MessageAdded, events.MessageAdded, map[string]interface{}{ + "task_id": taskID, + "workspace_id": "workspace-1", + "session_id": sessionID, + "author_type": "agent", + "type": "text", + "created_at": "2026-08-01T18:04:00Z", + }) + publishProjectorEvent(t, eventBus, events.MessageUpdated, events.MessageUpdated, map[string]interface{}{ + "task_id": taskID, + "workspace_id": "workspace-1", + "session_id": sessionID, + "author_type": "agent", + "type": "text", + "updated_at": "2026-08-01T18:04:30Z", + }) + assertProjectedLastActivity(t, store.summary(taskID), "2026-08-01T18:01:00Z") + + publishProjectorEvent(t, eventBus, events.MessageAdded, events.MessageAdded, map[string]interface{}{ + "task_id": taskID, + "workspace_id": "workspace-1", + "session_id": sessionID, + "author_type": "user", + "type": "text", + "created_at": "2026-08-01T18:05:00Z", + }) + assertProjectedLastActivity(t, store.summary(taskID), "2026-08-01T18:05:00Z") + + publishProjectorEvent(t, eventBus, events.TurnStarted, events.TurnStarted, map[string]interface{}{ + "task_id": taskID, + "workspace_id": "workspace-1", + "session_id": sessionID, + "started_at": "2026-08-01T18:06:00Z", + }) + assertProjectedLastActivity(t, store.summary(taskID), "2026-08-01T18:06:00Z") + + publishProjectorEvent(t, eventBus, events.TurnCompleted, events.TurnCompleted, map[string]interface{}{ + "task_id": taskID, + "workspace_id": "workspace-1", + "session_id": sessionID, + "completed_at": "2026-08-01T18:07:00Z", + }) + assertProjectedLastActivity(t, store.summary(taskID), "2026-08-01T18:07:00Z") + + // State-only transitions are activity even when they carry no other + // bounded-summary field. + publishProjectorEvent(t, eventBus, events.TaskStateChanged, events.TaskStateChanged, map[string]interface{}{ + "task_id": taskID, + "workspace_id": "workspace-1", + "new_state": "in_progress", + "updated_at": "2026-08-01T18:08:00Z", + }) + assertProjectedLastActivity(t, store.summary(taskID), "2026-08-01T18:08:00Z") + + // Queue bookkeeping is deliberately ignored, and an older persisted update + // cannot move the monotonic maximum backwards. + publishProjectorEvent(t, eventBus, events.MessageQueueStatusChanged, events.MessageQueueStatusChanged, map[string]interface{}{ + "task_id": taskID, + "workspace_id": "workspace-1", + "updated_at": "2026-08-01T18:09:00Z", + }) + publishProjectorEvent(t, eventBus, events.TaskUpdated, events.TaskUpdated, map[string]interface{}{ + "task_id": taskID, + "workspace_id": "workspace-1", + "updated_at": "2026-08-01T18:07:30Z", + }) + assertProjectedLastActivity(t, store.summary(taskID), "2026-08-01T18:08:00Z") + + publishProjectorEvent(t, eventBus, events.TaskUpdated, events.TaskUpdated, map[string]interface{}{ + "task_id": taskID, + "workspace_id": "workspace-1", + "updated_at": "2026-08-01T18:10:00Z", + }) + assertProjectedLastActivity(t, store.summary(taskID), "2026-08-01T18:10:00Z") +} + +func TestProjectorLastActivityRehydratesFromDurableLoader(t *testing.T) { + const taskID = "task-last-activity-rehydrate" + want := time.Date(2026, 8, 1, 18, 11, 0, 0, time.UTC) + store := newProjectorTestStore() + store.rows[taskID] = &StoredTaskStatusSummary{ + TaskID: taskID, + WorkspaceID: "workspace-1", + Summary: TaskStatusSummary{ + Revision: 4, + UpdatedAt: want.Add(-time.Minute), + }, + } + var calls atomic.Int64 + projector := NewProjector(ProjectorConfig{ + Store: store, + ResolveWorkspace: func(context.Context, string) (string, error) { + return "workspace-1", nil + }, + LoadTaskActivity: func(context.Context, string) (*time.Time, error) { + calls.Add(1) + return &want, nil + }, + }) + + state, err := projector.ensureState(context.Background(), taskID) + if err != nil { + t.Fatalf("restore projector state: %v", err) + } + if state.lastActivityAt == nil || !state.lastActivityAt.Equal(want) { + t.Fatalf("rehydrated last activity = %v, want %v", state.lastActivityAt, want) + } + if calls.Load() != 1 { + t.Fatalf("durable activity loader calls = %d, want 1", calls.Load()) + } +} + +func assertProjectedLastActivity(t *testing.T, summary *TaskStatusSummary, want string) { + t.Helper() + if summary == nil || summary.LastActivityAt == nil { + t.Fatalf("projected last activity = %+v, want %s", summary, want) + } + expected, err := time.Parse(time.RFC3339, want) + if err != nil { + t.Fatalf("parse expected activity %q: %v", want, err) + } + if !summary.LastActivityAt.Equal(expected) { + t.Fatalf("projected last activity = %s, want %s", summary.LastActivityAt.Format(time.RFC3339), want) + } +} + // Session events carry a `session_metadata` snapshot taken when the publisher // read the session, so one taken before the error was cleared must not re-arm an // error this projection already cleared. diff --git a/apps/backend/internal/task/statussummary/rebuild.go b/apps/backend/internal/task/statussummary/rebuild.go index 291056ccbe..49421b8479 100644 --- a/apps/backend/internal/task/statussummary/rebuild.go +++ b/apps/backend/internal/task/statussummary/rebuild.go @@ -50,6 +50,7 @@ type RebuildInput struct { Sessions []RebuildSession PendingActions map[string]string ActivityObserved bool + LastActivityAt *time.Time Git []RebuildGit GitObserved bool PullRequests []PullRequestInput @@ -112,7 +113,12 @@ func BuildFromAuthoritative(input RebuildInput) TaskStatusSummary { } applyPullRequestInputs(state, input.PullRequests) state.queuedCount = maxInt(input.QueuedPromptCount, 0) - return deriveSummary(state) + summary := deriveSummary(state) + if input.LastActivityAt != nil { + lastActivityAt := input.LastActivityAt.UTC() + summary.LastActivityAt = &lastActivityAt + } + return summary } func normalizeRebuildError(input *ActiveErrorSummary, now time.Time) *ActiveErrorSummary { diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet-props.ts b/apps/web/components/task/mobile/session-task-switcher-sheet-props.ts new file mode 100644 index 0000000000..50e598b461 --- /dev/null +++ b/apps/web/components/task/mobile/session-task-switcher-sheet-props.ts @@ -0,0 +1,56 @@ +import type { TaskSwitcherProps } from "../task-switcher"; +import type { MobileTaskListProps } from "./session-task-switcher-sheet"; + +/** Maps mobile view state and actions to the shared task switcher rows. */ +export function buildMobileTaskSwitcherProps( + props: MobileTaskListProps, + helpers: { + grouped: TaskSwitcherProps["grouped"]; + collapsedGroupKeys: string[]; + onToggleGroup: (groupKey: string) => void; + collapsedSubtaskParentIds: string[]; + onToggleSubtasks: (parentTaskId: string) => void; + onTogglePin: (taskId: string) => void; + onReorderGroup: (groupTaskIds: string[]) => void; + onReorderSubtasks: (parentTaskId: string, orderedSubtaskIds: string[]) => void; + pinnedTaskIds: string[]; + showActivityTime: boolean; + }, +): TaskSwitcherProps { + return { + grouped: helpers.grouped, + workflows: props.workflows, + stepsByWorkflowId: props.stepsByWorkflowId, + activeTaskId: props.activeTaskId, + selectedTaskId: props.selectedTaskId, + collapsedGroupKeys: helpers.collapsedGroupKeys, + showActivityTime: helpers.showActivityTime, + onToggleGroup: helpers.onToggleGroup, + collapsedSubtaskParentIds: helpers.collapsedSubtaskParentIds, + onToggleSubtasks: helpers.onToggleSubtasks, + onSelectTask: props.onSelectTask, + onEditTask: props.onEditTask, + onRenameTask: props.onRenameTask, + onCreateSubtask: props.onCreateSubtask, + onArchiveTask: props.onArchiveTask, + onDeleteTask: props.onDeleteTask, + onDetachTask: props.onDetachTask, + onNestTask: props.onNestTask, + onLinkPullRequest: props.onLinkPullRequest, + onLinkIssue: props.onLinkIssue, + onLinkMergeRequest: props.onLinkMergeRequest, + onLinkJiraTicket: props.onLinkJiraTicket, + onLinkLinearIssue: props.onLinkLinearIssue, + onLinkSentryIssue: props.onLinkSentryIssue, + onTogglePin: helpers.onTogglePin, + onReorderGroup: helpers.onReorderGroup, + onReorderSubtasks: helpers.onReorderSubtasks, + pinnedTaskIds: helpers.pinnedTaskIds, + deletingTaskId: props.deletingTaskId, + isLoading: props.isLoading, + loadError: props.loadError, + onRetryLoad: props.onRetryLoad, + retryLabel: props.retryLabel, + totalTaskCount: props.tasks.length, + }; +} 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 0392a177f1..592fa402cb 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx +++ b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx @@ -8,7 +8,7 @@ import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@kandev/ui/dra import { Button } from "@kandev/ui/button"; import { QuickChatSheetButton } from "./quick-chat-sheet-button"; import { TaskSwitcher } from "../task-switcher"; -import type { TaskSwitcherItem, TaskSwitcherProps } from "../task-switcher"; +import type { TaskSwitcherItem } from "../task-switcher"; import { SidebarFilterBar } from "../sidebar-filter/sidebar-filter-bar"; import type { StepDef } from "../task-switcher-context-menu"; import type { TaskMoveWorkflow } from "../task-move-context-menu"; @@ -39,7 +39,7 @@ import { useQuickChatLauncher } from "@/hooks/use-quick-chat-launcher"; import { useMobileTaskRename } from "./use-mobile-task-rename"; import { SidebarTaskEditDialog, useSidebarTaskEdit } from "../task-session-sidebar-edit"; import { usePortForwardingVisibility } from "../port-forwarding-visibility-provider"; - +import { buildMobileTaskSwitcherProps } from "./session-task-switcher-sheet-props"; type SessionTaskSwitcherSheetProps = { open: boolean; onOpenChange: (open: boolean) => void; @@ -47,7 +47,6 @@ type SessionTaskSwitcherSheetProps = { workflowId: string | null; presentation?: "sheet" | "drawer"; }; - export function useTaskSheetSelectionController() { const [selectionController] = useState(createTaskSheetSelectionController); useEffect( @@ -58,7 +57,6 @@ export function useTaskSheetSelectionController() { ); return selectionController; } - function useMobileTaskLinking(workspaceId: string | null) { const store = useAppStoreApi(); const actions = useSidebarLinkActions(store); @@ -80,7 +78,7 @@ function useSidebarGroupToggle(viewId: string) { ); } -type MobileTaskListProps = { +export type MobileTaskListProps = { tasks: TaskSwitcherItem[]; workflows: TaskMoveWorkflow[]; stepsByWorkflowId: Record; @@ -107,62 +105,6 @@ type MobileTaskListProps = { retryLabel?: string; }; -/** - * Assembles the TaskSwitcher props for the mobile task list, mirroring the - * desktop `buildTaskSwitcherProps` so the prop-forwarding surface stays a - * thin mapping layer. - */ -function buildMobileTaskSwitcherProps( - props: MobileTaskListProps, - helpers: { - grouped: TaskSwitcherProps["grouped"]; - collapsedGroupKeys: string[]; - onToggleGroup: (groupKey: string) => void; - collapsedSubtaskParentIds: string[]; - onToggleSubtasks: (parentTaskId: string) => void; - onTogglePin: (taskId: string) => void; - onReorderGroup: (groupTaskIds: string[]) => void; - onReorderSubtasks: (parentTaskId: string, orderedSubtaskIds: string[]) => void; - pinnedTaskIds: string[]; - }, -): TaskSwitcherProps { - return { - grouped: helpers.grouped, - workflows: props.workflows, - stepsByWorkflowId: props.stepsByWorkflowId, - activeTaskId: props.activeTaskId, - selectedTaskId: props.selectedTaskId, - collapsedGroupKeys: helpers.collapsedGroupKeys, - onToggleGroup: helpers.onToggleGroup, - collapsedSubtaskParentIds: helpers.collapsedSubtaskParentIds, - onToggleSubtasks: helpers.onToggleSubtasks, - onSelectTask: props.onSelectTask, - onEditTask: props.onEditTask, - onRenameTask: props.onRenameTask, - onCreateSubtask: props.onCreateSubtask, - onArchiveTask: props.onArchiveTask, - onDeleteTask: props.onDeleteTask, - onDetachTask: props.onDetachTask, - onNestTask: props.onNestTask, - onLinkPullRequest: props.onLinkPullRequest, - onLinkIssue: props.onLinkIssue, - onLinkMergeRequest: props.onLinkMergeRequest, - onLinkJiraTicket: props.onLinkJiraTicket, - onLinkLinearIssue: props.onLinkLinearIssue, - onLinkSentryIssue: props.onLinkSentryIssue, - onTogglePin: helpers.onTogglePin, - onReorderGroup: helpers.onReorderGroup, - onReorderSubtasks: helpers.onReorderSubtasks, - pinnedTaskIds: helpers.pinnedTaskIds, - deletingTaskId: props.deletingTaskId, - isLoading: props.isLoading, - loadError: props.loadError, - onRetryLoad: props.onRetryLoad, - retryLabel: props.retryLabel, - totalTaskCount: props.tasks.length, - }; -} - /** * The mobile task tree surface: renders the shared TaskSwitcher with the * mobile drawer's view state (grouping, ordering, collapse, reorder, nest). @@ -201,6 +143,7 @@ export function MobileTaskList(props: MobileTaskListProps) { onReorderGroup: handleReorderGroup, onReorderSubtasks: handleReorderSubtasks, pinnedTaskIds, + showActivityTime: view.sort.key === "lastActivityAt", }); return ; } diff --git a/apps/web/components/task/sidebar-filter/sort-picker.tsx b/apps/web/components/task/sidebar-filter/sort-picker.tsx index cfc2837bbe..7a514fdc0e 100644 --- a/apps/web/components/task/sidebar-filter/sort-picker.tsx +++ b/apps/web/components/task/sidebar-filter/sort-picker.tsx @@ -12,6 +12,7 @@ import { useTranslation } from "react-i18next"; const SORT_OPTIONS: Array<{ key: SortKey; labelKey: string }> = [ { key: "state", labelKey: "task:sortStatus" }, { key: "updatedAt", labelKey: "task:sortUpdated" }, + { key: "lastActivityAt", labelKey: "task:sortLastActivity" }, { key: "createdAt", labelKey: "task:sortCreated" }, { key: "title", labelKey: "task:sortTitle" }, { key: "custom", labelKey: "task:sortCustom" }, diff --git a/apps/web/components/task/task-item-stats-row.tsx b/apps/web/components/task/task-item-stats-row.tsx index c7f0b5f232..6ce53c73f9 100644 --- a/apps/web/components/task/task-item-stats-row.tsx +++ b/apps/web/components/task/task-item-stats-row.tsx @@ -64,7 +64,13 @@ export function TaskItemStatsRow({ return ( {updatedAt && ( - {formatRelativeTime(updatedAt)} + + {formatRelativeTime(updatedAt)} + )} {prInfo && #{prInfo.number}} {queuedCount ? ( diff --git a/apps/web/components/task/task-item.test.tsx b/apps/web/components/task/task-item.test.tsx index b29cbc7464..30b9da8f39 100644 --- a/apps/web/components/task/task-item.test.tsx +++ b/apps/web/components/task/task-item.test.tsx @@ -7,6 +7,7 @@ import type { TaskPR } from "@/lib/types/github"; import type { TaskMR } from "@/lib/types/gitlab"; import { TaskItem } from "./task-item"; import { TooltipProvider } from "@kandev/ui/tooltip"; +import { formatRelativeTime } from "@/lib/utils"; const TURN_FINISHED_ICON_TEST_ID = "task-state-turn-finished"; const WORKFLOW_COMPLETE_ICON_TEST_ID = "task-state-workflow-complete"; @@ -577,6 +578,37 @@ describe("TaskItem queued prompt count badge", () => { }); }); +describe("TaskItem activity timestamp", () => { + it("shows activity time only when the active view sorts by activity", () => { + const activityAt = "2026-07-20T00:00:00Z"; + const updatedAt = "2026-07-24T00:00:00Z"; + const { rerender } = renderTaskItem({ + updatedAt, + lastActivityAt: activityAt, + showActivityTime: true, + }); + + expect(screen.getByTestId("sidebar-task-time").textContent).toBe( + formatRelativeTime(activityAt), + ); + + rerender( + + + + + , + ); + expect(screen.getByTestId("sidebar-task-time").textContent).toBe(formatRelativeTime(updatedAt)); + }); +}); + describe("TaskItem contribution badges", () => { it("renders the PR badge before the MR badge when the task has both (AC2)", () => { renderTaskItem( diff --git a/apps/web/components/task/task-item.tsx b/apps/web/components/task/task-item.tsx index 3698eb2ab1..8b57b61198 100644 --- a/apps/web/components/task/task-item.tsx +++ b/apps/web/components/task/task-item.tsx @@ -68,6 +68,8 @@ type TaskItemProps = { remoteExecutorType?: string; remoteExecutorName?: string; updatedAt?: string; + lastActivityAt?: string; + showActivityTime?: boolean; menuOpen?: boolean; isDeleting?: boolean; taskId?: string; @@ -324,6 +326,8 @@ function TaskItemContent({ isPinned, repositories, updatedAt, + lastActivityAt, + showActivityTime, prInfo, queuedCount, wipQueue, @@ -341,6 +345,8 @@ function TaskItemContent({ isPinned?: boolean; repositories?: string[]; updatedAt?: string; + lastActivityAt?: string; + showActivityTime?: boolean; prInfo?: { number: number; state: string; aggregateState?: string }; queuedCount?: number; wipQueue?: WipQueueStatus; @@ -384,7 +390,7 @@ function TaskItemContent({ )} [0]; type SidebarContext = Parameters[1]; const UPDATED_AT = "2026-07-22T00:00:00Z"; +const ACTIVITY_AT = "2026-07-24T00:00:00Z"; +const CREATED_AT = "2026-07-20T00:00:00Z"; +const SUMMARY_UPDATED_AT = "2026-07-25T00:00:00Z"; function emptyContext(): SidebarContext { return { @@ -122,3 +125,41 @@ describe("buildSidebarItem", () => { expect(item.queuedCount).toBeUndefined(); }); }); + +describe("buildSidebarItem activity", () => { + it("maps summary activity and falls back through task update to creation", () => { + const projected = buildSidebarItem( + task({ + updatedAt: UPDATED_AT, + createdAt: CREATED_AT, + statusSummary: { + revision: 6, + updated_at: SUMMARY_UPDATED_AT, + last_activity_at: ACTIVITY_AT, + }, + }), + emptyContext(), + ); + expect(projected.updatedAt).toBe(SUMMARY_UPDATED_AT); + expect(projected.lastActivityAt).toBe(ACTIVITY_AT); + + const updatedFallback = buildSidebarItem( + task({ + updatedAt: UPDATED_AT, + createdAt: CREATED_AT, + statusSummary: { revision: 7, updated_at: SUMMARY_UPDATED_AT }, + }), + emptyContext(), + ); + expect(updatedFallback.lastActivityAt).toBe(UPDATED_AT); + + const createdFallback = buildSidebarItem( + task({ + createdAt: CREATED_AT, + statusSummary: { revision: 8, updated_at: SUMMARY_UPDATED_AT }, + }), + emptyContext(), + ); + expect(createdFallback.lastActivityAt).toBe(CREATED_AT); + }); +}); diff --git a/apps/web/components/task/task-session-sidebar-item.ts b/apps/web/components/task/task-session-sidebar-item.ts index 693e85030d..b14108da0c 100644 --- a/apps/web/components/task/task-session-sidebar-item.ts +++ b/apps/web/components/task/task-session-sidebar-item.ts @@ -68,6 +68,13 @@ function issueInfoForTask(task: KanbanState["tasks"][number]) { return { url: task.issueUrl, number: task.issueNumber }; } +function sidebarLastActivityAt( + summary: TaskStatusSummary | null | undefined, + task: KanbanState["tasks"][number], +) { + return summary?.last_activity_at ?? task.updatedAt ?? task.createdAt; +} + function sidebarSessionStatus( summary: TaskStatusSummary | null | undefined, task: KanbanState["tasks"][number], @@ -85,6 +92,7 @@ function sidebarSessionStatus( foregroundActivity: hasSummary ? summary?.foreground_activity : task.foregroundActivity, primarySessionId: hasSummary ? (primarySession?.id ?? null) : (task.primarySessionId ?? null), updatedAt: hasSummary ? summary?.updated_at : (task.updatedAt ?? task.createdAt), + lastActivityAt: sidebarLastActivityAt(summary, task), }; } diff --git a/apps/web/components/task/task-session-sidebar-switcher-props.ts b/apps/web/components/task/task-session-sidebar-switcher-props.ts index 9401387301..4e5c31e2af 100644 --- a/apps/web/components/task/task-session-sidebar-switcher-props.ts +++ b/apps/web/components/task/task-session-sidebar-switcher-props.ts @@ -17,7 +17,10 @@ export function buildTaskSwitcherProps(args: { stepsByWorkflowId: TaskSwitcherComponentProps["stepsByWorkflowId"]; highlightedTaskId: string | null; highlightedSelectedTaskId: string | null; - effectiveView: { collapsedGroups: TaskSwitcherComponentProps["collapsedGroupKeys"] }; + effectiveView: { + collapsedGroups: TaskSwitcherComponentProps["collapsedGroupKeys"]; + sort: { key: string }; + }; handleToggleGroup: TaskSwitcherComponentProps["onToggleGroup"]; collapsedSubtaskParents: TaskSwitcherComponentProps["collapsedSubtaskParentIds"]; toggleSubtaskCollapsed: TaskSwitcherComponentProps["onToggleSubtasks"]; @@ -43,6 +46,7 @@ export function buildTaskSwitcherProps(args: { activeTaskId: args.highlightedTaskId, selectedTaskId: args.highlightedSelectedTaskId, collapsedGroupKeys: args.effectiveView.collapsedGroups, + showActivityTime: args.effectiveView.sort.key === "lastActivityAt", onToggleGroup: args.handleToggleGroup, collapsedSubtaskParentIds: args.collapsedSubtaskParents, onToggleSubtasks: args.toggleSubtaskCollapsed, diff --git a/apps/web/components/task/task-switcher-row.tsx b/apps/web/components/task/task-switcher-row.tsx index b2f6465331..30be5c254d 100644 --- a/apps/web/components/task/task-switcher-row.tsx +++ b/apps/web/components/task/task-switcher-row.tsx @@ -21,6 +21,7 @@ export type TaskRowProps = { stepsByWorkflowId?: Record; activeTaskId: string | null; selectedTaskId: string | null; + showActivityTime?: boolean; onSelectTask: (taskId: string) => void; onEditTask?: (task: TaskSwitcherItem) => void; onRenameTask?: (taskId: string, currentTitle: string) => void; @@ -91,6 +92,7 @@ type TaskRowItemProps = Pick< | "subtaskToggle" | "activeTaskId" | "selectedTaskId" + | "showActivityTime" | "onSelectTask" | "selectedTaskIds" | "onToggleSelectTask" @@ -107,6 +109,7 @@ function TaskRowItem({ subtaskToggle, activeTaskId, selectedTaskId, + showActivityTime, onSelectTask, selectedTaskIds, onToggleSelectTask, @@ -154,6 +157,8 @@ function TaskRowItem({ hasPendingClarification={task.hasPendingClarification} hasPendingPermission={task.hasPendingPermission} updatedAt={task.updatedAt} + lastActivityAt={task.lastActivityAt} + showActivityTime={showActivityTime} repositories={task.repositories} prInfo={task.prInfo} queuedCount={task.queuedCount} diff --git a/apps/web/components/task/task-switcher-types.ts b/apps/web/components/task/task-switcher-types.ts index 340768d7e0..37cb8fdf7c 100644 --- a/apps/web/components/task/task-switcher-types.ts +++ b/apps/web/components/task/task-switcher-types.ts @@ -36,6 +36,7 @@ export type TaskSwitcherItem = { remoteExecutorType?: string; remoteExecutorName?: string; updatedAt?: string; + lastActivityAt?: string; createdAt?: string; isArchived?: boolean; primarySessionId?: string | null; @@ -91,6 +92,7 @@ export type TaskSwitcherProps = { onRetryLoad?: () => void; retryLabel?: string; totalTaskCount?: number; + showActivityTime?: boolean; // Multi-select (cmd/shift click). When the selection is non-empty, plain // clicks toggle instead of navigating; the context menu acts on the selection. selectedTaskIds?: Set; diff --git a/apps/web/components/task/task-switcher.tsx b/apps/web/components/task/task-switcher.tsx index bd9dc76a91..64e6821309 100644 --- a/apps/web/components/task/task-switcher.tsx +++ b/apps/web/components/task/task-switcher.tsx @@ -20,6 +20,7 @@ function buildTaskRowProps(props: TaskSwitcherProps): TaskRowBaseProps { stepsByWorkflowId: props.stepsByWorkflowId, activeTaskId: props.activeTaskId, selectedTaskId: props.selectedTaskId, + showActivityTime: props.showActivityTime, onSelectTask: props.onSelectTask, onEditTask: props.onEditTask, onRenameTask: props.onRenameTask, diff --git a/apps/web/e2e/tests/task/mobile-sidebar-views.spec.ts b/apps/web/e2e/tests/task/mobile-sidebar-views.spec.ts index ef06c093a2..f60d433f55 100644 --- a/apps/web/e2e/tests/task/mobile-sidebar-views.spec.ts +++ b/apps/web/e2e/tests/task/mobile-sidebar-views.spec.ts @@ -55,6 +55,16 @@ async function addTitleFilter(testPage: Page, sheet: Locator, value: string): Pr await popover.getByTestId("filter-value-input").fill(value); } +async function taskRowOrder(sheet: Locator, taskIds: string[]) { + const rows = await sheet.locator("[data-task-row-id]").all(); + const order: string[] = []; + for (const row of rows) { + const id = await row.getAttribute("data-task-row-id"); + if (id && taskIds.includes(id)) order.push(id); + } + return order; +} + test.describe("Mobile sidebar — view system", () => { test("direct creation stays touch-reachable, viewport-safe, and persists", async ({ testPage, @@ -244,6 +254,63 @@ test.describe("Mobile sidebar — view system", () => { await expect(sheet.getByText("Fix auth bug")).toBeVisible(); }); + test("mobile last activity sort is touch reachable, persistent, and contained", async ({ + testPage, + apiClient, + seedData, + }) => { + const sheet = await seedAndOpenSheet(testPage, apiClient, seedData, [ + "Mobile activity old", + "Mobile activity new", + ]); + const listed = await apiClient.listTasks(seedData.workspaceId); + const oldTask = listed.tasks.find((task) => task.title === "Mobile activity old"); + const newTask = listed.tasks.find((task) => task.title === "Mobile activity new"); + expect(oldTask?.id).toBeTruthy(); + expect(newTask?.id).toBeTruthy(); + await apiClient.updateTaskTitle(oldTask!.id, "Mobile activity old touched"); + + const gear = sheet.getByTestId("sidebar-filter-gear"); + await gear.tap(); + const popover = testPage.getByTestId("sidebar-filter-popover"); + await expect(popover).toBeVisible(); + await popover.getByTestId("sort-key-select").tap(); + await testPage.getByRole("option", { name: "Last activity", exact: true }).tap(); + const direction = popover.getByTestId("sort-direction-toggle"); + if ((await direction.getAttribute("data-direction")) !== "desc") await direction.tap(); + await popover.getByTestId("view-save-as-button").tap(); + await popover.getByTestId("view-save-as-name-input").fill("Mobile last activity"); + await popover.getByTestId("view-save-as-confirm").tap(); + await testPage.keyboard.press("Escape"); + + const taskIds = [oldTask!.id, newTask!.id]; + await expect.poll(() => taskRowOrder(sheet, taskIds)).toEqual(taskIds); + const viewport = testPage.viewportSize(); + const list = sheet.getByTestId("mobile-task-switcher-list"); + const sheetBox = await sheet.boundingBox(); + const listBox = await list.boundingBox(); + expect(viewport).not.toBeNull(); + expect(sheetBox).not.toBeNull(); + expect(listBox).not.toBeNull(); + expect(listBox!.x).toBeGreaterThanOrEqual(sheetBox!.x); + expect(listBox!.x + listBox!.width).toBeLessThanOrEqual(sheetBox!.x + sheetBox!.width); + await expect(list).toHaveCSS("overflow-y", "auto"); + expect( + await testPage.evaluate( + () => document.documentElement.scrollWidth <= document.documentElement.clientWidth, + ), + ).toBe(true); + + await testPage.reload(); + await new SessionPage(testPage).waitForLoad(); + await testPage.getByTestId("mobile-session-menu").tap(); + const reloadedSheet = testPage.getByRole("dialog", { name: "Tasks" }); + await expect( + reloadedSheet.getByTestId("sidebar-view-chip").filter({ hasText: "Mobile last activity" }), + ).toHaveAttribute("data-active", "true"); + await expect.poll(() => taskRowOrder(reloadedSheet, taskIds)).toEqual(taskIds); + }); + test("many saved views scroll without covering fixed actions", async ({ testPage, apiClient, diff --git a/apps/web/e2e/tests/task/sidebar-filter.spec.ts b/apps/web/e2e/tests/task/sidebar-filter.spec.ts index 40a7d08262..1225f0701a 100644 --- a/apps/web/e2e/tests/task/sidebar-filter.spec.ts +++ b/apps/web/e2e/tests/task/sidebar-filter.spec.ts @@ -75,6 +75,25 @@ async function saveTitleView( await filters.close(); } +async function taskRowOrder(surface: import("@playwright/test").Locator, taskIds: string[]) { + const rows = await surface.locator("[data-task-row-id]").all(); + const order: string[] = []; + for (const row of rows) { + const id = await row.getAttribute("data-task-row-id"); + if (id && taskIds.includes(id)) order.push(id); + } + return order; +} + +async function taskActivityAt( + apiClient: import("../../helpers/api-client").ApiClient, + workspaceId: string, + taskId: string, +): Promise { + const result = await apiClient.listTasks(workspaceId); + return result.tasks.find((task) => task.id === taskId)?.status_summary?.last_activity_at ?? null; +} + test.describe("Sidebar filter bar — popover basics", () => { test("gear opens popover; ESC closes it", async ({ testPage, apiClient, seedData }) => { const { filters } = await openWithSeed(testPage, apiClient, seedData, ["Basics Task"]); @@ -306,6 +325,106 @@ test.describe("Sidebar filter — group + sort", () => { const flipped = await toggle.getAttribute("data-direction"); expect(flipped).not.toBe(initial); }); + + test("sorts by last activity, persists, and ignores provider-only refresh", async ({ + testPage, + apiClient, + seedData, + }) => { + const oldTask = await apiClient.createTask(seedData.workspaceId, "Activity old", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + const newTask = await apiClient.createTask(seedData.workspaceId, "Activity new", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + await apiClient.updateTaskTitle(oldTask.id, "Activity old touched"); + const navTask = await apiClient.createTask(seedData.workspaceId, "Activity sort nav", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + + await testPage.goto(`/t/${navTask.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + const filters = new SidebarFilterPopoverPage(testPage); + await filters.open(); + await filters.setGroup("None"); + await filters.setSort("Last activity", "desc"); + await filters.saveAs("Last activity view"); + await filters.close(); + + await expect + .poll(() => taskRowOrder(session.sidebar, [oldTask.id, newTask.id])) + .toEqual([oldTask.id, newTask.id]); + let activityAt: string | null = null; + await expect + .poll(async () => { + activityAt = await taskActivityAt(apiClient, seedData.workspaceId, oldTask.id); + return activityAt; + }) + .toBeTruthy(); + const oldRow = session.sidebar.locator(`[data-task-row-id="${oldTask.id}"]`); + await expect(oldRow.getByTestId("sidebar-task-time")).toHaveAttribute( + "data-time-value", + activityAt!, + ); + + await testPage.reload(); + await new SessionPage(testPage).waitForLoad(); + const reloadedFilters = new SidebarFilterPopoverPage(testPage); + await reloadedFilters.expectActiveViewChip("Last activity view"); + await expect + .poll(() => taskRowOrder(session.sidebar, [oldTask.id, newTask.id])) + .toEqual([oldTask.id, newTask.id]); + + await apiClient.mockGitHubAssociateTaskPR({ + task_id: newTask.id, + workspace_id: seedData.workspaceId, + repository_id: seedData.repositoryId, + owner: "activity-owner", + repo: "activity-repo", + pr_number: 7, + pr_url: "https://github.com/activity-owner/activity-repo/pull/7", + pr_title: "Activity PR", + head_branch: "activity", + base_branch: "main", + author_login: "activity-owner", + checks_state: "failure", + }); + await expect + .poll(async () => { + const tasks = await apiClient.listTasks(seedData.workspaceId); + return tasks.tasks.find((task) => task.id === newTask.id)?.status_summary?.pull_request + ?.aggregate_state; + }) + .toBe("failure"); + await apiClient.mockGitHubAssociateTaskPR({ + task_id: newTask.id, + workspace_id: seedData.workspaceId, + repository_id: seedData.repositoryId, + owner: "activity-owner", + repo: "activity-repo", + pr_number: 7, + pr_url: "https://github.com/activity-owner/activity-repo/pull/7", + pr_title: "Activity PR refreshed", + head_branch: "activity", + base_branch: "main", + author_login: "activity-owner", + checks_state: "success", + }); + await expect + .poll(async () => { + const tasks = await apiClient.listTasks(seedData.workspaceId); + return tasks.tasks.find((task) => task.id === newTask.id)?.status_summary?.pull_request + ?.aggregate_state; + }) + .toBe("passing"); + await expect + .poll(() => taskRowOrder(session.sidebar, [oldTask.id, newTask.id])) + .toEqual([oldTask.id, newTask.id]); + }); }); test.describe("Sidebar filter — saved views CRUD", () => { diff --git a/apps/web/lib/sidebar/apply-view.test.ts b/apps/web/lib/sidebar/apply-view.test.ts index 34b7e26d8a..ad191bde05 100644 --- a/apps/web/lib/sidebar/apply-view.test.ts +++ b/apps/web/lib/sidebar/apply-view.test.ts @@ -219,9 +219,12 @@ describe("applyFilters — titleMatch + combos", () => { }); describe("applySort", () => { - const a = task({ id: "a", state: "REVIEW", updatedAt: "2026-04-01", title: "Zeta" }); - const b = task({ id: "b", sessionState: "RUNNING", updatedAt: "2026-04-05", title: "Alpha" }); - const c = task({ id: "c", updatedAt: "2026-04-03", title: "Mu" }); + const early = "2026-04-01"; + const middle = "2026-04-03"; + const late = "2026-04-05"; + const a = task({ id: "a", state: "REVIEW", updatedAt: early, title: "Zeta" }); + const b = task({ id: "b", sessionState: "RUNNING", updatedAt: late, title: "Alpha" }); + const c = task({ id: "c", updatedAt: middle, title: "Mu" }); it("sorts by state bucket asc (review, in_progress, backlog)", () => { const out = applySort([c, b, a], { key: "state", direction: "asc" }); @@ -238,6 +241,31 @@ describe("applySort", () => { expect(out.map((t) => t.id)).toEqual(["b", "c", "a"]); }); + it("sorts by lastActivityAt with update and creation fallbacks", () => { + const out = applySort( + [ + task({ id: "created-fallback", createdAt: early }), + task({ id: "updated-fallback", updatedAt: middle, createdAt: early }), + task({ + id: "activity", + lastActivityAt: late, + updatedAt: "2026-04-02", + createdAt: early, + }), + ], + { key: "lastActivityAt", direction: "desc" }, + ); + expect(out.map((t) => t.id)).toEqual(["activity", "updated-fallback", "created-fallback"]); + }); + + it("keeps lastActivityAt stable for equal timestamps", () => { + const out = applySort( + [task({ id: "first", lastActivityAt: late }), task({ id: "second", lastActivityAt: late })], + { key: "lastActivityAt", direction: "asc" }, + ); + expect(out.map((t) => t.id)).toEqual(["first", "second"]); + }); + it("sorts by title asc", () => { const out = applySort([a, b, c], { key: "title", direction: "asc" }); expect(out.map((t) => t.id)).toEqual(["b", "c", "a"]); diff --git a/apps/web/lib/sidebar/apply-view.ts b/apps/web/lib/sidebar/apply-view.ts index 7570a5d853..a69f81d377 100644 --- a/apps/web/lib/sidebar/apply-view.ts +++ b/apps/web/lib/sidebar/apply-view.ts @@ -117,6 +117,10 @@ export function applyFilters( type SortComparator = (a: TaskSwitcherItem, b: TaskSwitcherItem) => number; +function lastActivitySortValue(task: TaskSwitcherItem): string { + return task.lastActivityAt ?? task.updatedAt ?? task.createdAt ?? ""; +} + const SORT_COMPARATORS: Record, SortComparator> = { state: (a, b) => { const bucket = STATE_BUCKET_ORDER[getStateBucket(a)] - STATE_BUCKET_ORDER[getStateBucket(b)]; @@ -125,6 +129,7 @@ const SORT_COMPARATORS: Record, SortComparator> = { return (b.createdAt ?? "").localeCompare(a.createdAt ?? ""); }, updatedAt: (a, b) => (a.updatedAt ?? "").localeCompare(b.updatedAt ?? ""), + lastActivityAt: (a, b) => lastActivitySortValue(a).localeCompare(lastActivitySortValue(b)), createdAt: (a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""), title: (a, b) => (a.title ?? "").localeCompare(b.title ?? ""), }; diff --git a/apps/web/lib/state/slices/ui/sidebar-view-types.ts b/apps/web/lib/state/slices/ui/sidebar-view-types.ts index bbf9f22fa2..4f2f88bdfe 100644 --- a/apps/web/lib/state/slices/ui/sidebar-view-types.ts +++ b/apps/web/lib/state/slices/ui/sidebar-view-types.ts @@ -22,7 +22,7 @@ export type FilterClause = { value: FilterValue; }; -export type SortKey = "state" | "updatedAt" | "createdAt" | "title" | "custom"; +export type SortKey = "state" | "updatedAt" | "lastActivityAt" | "createdAt" | "title" | "custom"; export type SortDirection = "asc" | "desc"; export type SortSpec = { key: SortKey; direction: SortDirection }; diff --git a/apps/web/lib/state/slices/ui/sidebar-view-wire.test.ts b/apps/web/lib/state/slices/ui/sidebar-view-wire.test.ts index 5e9db31669..b89c527336 100644 --- a/apps/web/lib/state/slices/ui/sidebar-view-wire.test.ts +++ b/apps/web/lib/state/slices/ui/sidebar-view-wire.test.ts @@ -10,7 +10,7 @@ const view: SidebarView = { { id: "c2", dimension: "state", op: "in", value: ["review", "in_progress"] }, { id: "c3", dimension: "titleMatch", op: "matches", value: "fix " }, ], - sort: { key: "updatedAt", direction: "desc" }, + sort: { key: "lastActivityAt", direction: "desc" }, group: "workflow", collapsedGroups: ["backlog", "review"], }; diff --git a/apps/web/lib/state/slices/ui/ui-slice-migration.test.ts b/apps/web/lib/state/slices/ui/ui-slice-migration.test.ts index 7e60bc4e67..9591e623ae 100644 --- a/apps/web/lib/state/slices/ui/ui-slice-migration.test.ts +++ b/apps/web/lib/state/slices/ui/ui-slice-migration.test.ts @@ -84,3 +84,15 @@ describe("migrateSidebarViewDraft archived compatibility", () => { expect(migrateSidebarViewDraft(draft)).toEqual(draft); }); }); + +describe("migrate sidebar activity sort", () => { + it("preserves lastActivityAt on saved views and drafts", () => { + const view = makeSidebarView("view-activity", "Recent activity"); + view.sort = { key: "lastActivityAt", direction: "desc" }; + const draft = makeSidebarDraft("view-activity"); + draft.sort = { key: "lastActivityAt", direction: "asc" }; + + expect(migrateView(view).sort).toEqual(view.sort); + expect(migrateSidebarViewDraft(draft).sort).toEqual(draft.sort); + }); +}); diff --git a/apps/web/lib/state/slices/ui/ui-slice.ts b/apps/web/lib/state/slices/ui/ui-slice.ts index 00778b6ee1..952de752e4 100644 --- a/apps/web/lib/state/slices/ui/ui-slice.ts +++ b/apps/web/lib/state/slices/ui/ui-slice.ts @@ -46,6 +46,7 @@ export const KNOWN_DIMENSIONS = new Set([ export const KNOWN_SORT_KEYS = new Set([ "state", "updatedAt", + "lastActivityAt", "createdAt", "title", "custom", diff --git a/apps/web/lib/types/task-status-summary.ts b/apps/web/lib/types/task-status-summary.ts index 8f02e33e17..4f26812902 100644 --- a/apps/web/lib/types/task-status-summary.ts +++ b/apps/web/lib/types/task-status-summary.ts @@ -3,6 +3,8 @@ import type { ForegroundActivity, TaskPendingAction, TaskSessionState } from "./ export type TaskStatusSummary = { revision: number; updated_at: string; + /** Semantic task activity, separate from summary projection freshness. */ + last_activity_at?: string; primary_session?: { id: string; state: TaskSessionState; diff --git a/apps/web/src/locales/en/task.json b/apps/web/src/locales/en/task.json index d608253427..8a8c54bb2c 100644 --- a/apps/web/src/locales/en/task.json +++ b/apps/web/src/locales/en/task.json @@ -1261,6 +1261,7 @@ "sortCreated": "Created", "sortCustom": "Custom", "sortDirection": "Sort direction {{direction}}", + "sortLastActivity": "Last activity", "sortStatus": "Status", "sortTitle": "Title", "sortUpdated": "Updated", diff --git a/apps/web/src/locales/pseudo/task.json b/apps/web/src/locales/pseudo/task.json index 7a7b8a23aa..a73aca2291 100644 --- a/apps/web/src/locales/pseudo/task.json +++ b/apps/web/src/locales/pseudo/task.json @@ -1261,6 +1261,7 @@ "sortCreated": "Ćŕēàţēď", "sortCustom": "Ćũśţōḿ", "sortDirection": "Śōŕţ ďĩŕēćţĩōń {{direction}}", + "sortLastActivity": "Ĺàśţ àćţĩṿĩţŷ", "sortStatus": "Śţàţũś", "sortTitle": "Ţĩţĺē", "sortUpdated": "Ũƥďàţēď", diff --git a/apps/web/src/locales/pt-pt/task.json b/apps/web/src/locales/pt-pt/task.json index b7c0cf2496..65badc30f6 100644 --- a/apps/web/src/locales/pt-pt/task.json +++ b/apps/web/src/locales/pt-pt/task.json @@ -1224,6 +1224,7 @@ "sortCreated": "Criação", "sortCustom": "Personalizada", "sortDirection": "Sentido da ordenação {{direction}}", + "sortLastActivity": "Última atividade", "sortStatus": "Estado", "sortTitle": "Título", "sortUpdated": "Atualização", diff --git a/apps/web/src/locales/zh-cn/task.json b/apps/web/src/locales/zh-cn/task.json index 892ada4294..ed2dc790b1 100644 --- a/apps/web/src/locales/zh-cn/task.json +++ b/apps/web/src/locales/zh-cn/task.json @@ -1223,6 +1223,7 @@ "sortCreated": "创建时间", "sortCustom": "自定义", "sortDirection": "排序方向 {{direction}}", + "sortLastActivity": "最后活动", "sortStatus": "状态", "sortTitle": "标题", "sortUpdated": "更新时间", diff --git a/apps/web/src/locales/zh-hk/task.json b/apps/web/src/locales/zh-hk/task.json index 978a54bc37..2719b9d7a4 100644 --- a/apps/web/src/locales/zh-hk/task.json +++ b/apps/web/src/locales/zh-hk/task.json @@ -1223,6 +1223,7 @@ "sortCreated": "建立時間", "sortCustom": "自訂", "sortDirection": "排序方向 {{direction}}", + "sortLastActivity": "最後活動", "sortStatus": "狀態", "sortTitle": "標題", "sortUpdated": "更新時間", diff --git a/apps/web/src/locales/zh-tw/task.json b/apps/web/src/locales/zh-tw/task.json index 6093ab2376..a052da9f1b 100644 --- a/apps/web/src/locales/zh-tw/task.json +++ b/apps/web/src/locales/zh-tw/task.json @@ -1223,6 +1223,7 @@ "sortCreated": "建立時間", "sortCustom": "自訂", "sortDirection": "排序方向 {{direction}}", + "sortLastActivity": "最後活動", "sortStatus": "狀態", "sortTitle": "標題", "sortUpdated": "更新時間", diff --git a/docs/decisions/2026-08-17-separate-task-activity-from-summary-freshness.md b/docs/decisions/2026-08-17-separate-task-activity-from-summary-freshness.md new file mode 100644 index 0000000000..30a47293ea --- /dev/null +++ b/docs/decisions/2026-08-17-separate-task-activity-from-summary-freshness.md @@ -0,0 +1,67 @@ +# ADR-2026-08-17-separate-task-activity-from-summary-freshness: Separate Task Activity From Summary Freshness + +**Status:** accepted +**Date:** 2026-08-17 +**Area:** backend, frontend, protocol + +## Context + +Task sidebar rows currently use `TaskStatusSummary.updated_at` as their visible +time and saved-view sort value. That field records when any projected status +changed. A pull-request poll can therefore make many idle tasks look recently +active even when no user opened them and no agent ran. + +Users need both concepts. Projection freshness is useful for status diagnostics, +while task activity is useful for finding recent work. + +## Decision + +Kandev will keep `TaskStatusSummary.updated_at` as transport and projection +freshness. It will add semantic field `last_activity_at` to the same bounded, +rebuildable summary. + +`last_activity_at` is the latest durable time from these sources: + +- task creation or a persisted task mutation +- a user-authored prompt, including a prompt that enters the queue +- an agent turn start or completion + +The timestamp does not advance for task focus, session subscription, Git or +pull-request polling, queue bookkeeping, status-summary repair, or session +metadata maintenance. Live projection uses source timestamps and applies a +monotonic maximum. Rebuilds batch the same durable task, message, and turn +sources and preserve a newer stored value. + +The saved-view wire key is `lastActivityAt`. The existing `updatedAt` key keeps +its behavior, so stored views remain compatible and users can choose either +meaning. + +This decision extends +[ADR-2026-08-01-separate-task-summary-session-stream-traffic](2026-08-01-separate-task-summary-session-stream-traffic.md). +Task-list consumers continue to use the bounded task summary and do not load +background session streams. + +## Consequences + +Idle tasks stay stable when background provider status changes. Desktop and +mobile saved views can sort by meaningful work without opening task sessions. + +The backend needs a batched activity query and more bounded projector inputs. +Turn milestones prevent per-chunk agent output from producing task-list update +traffic. A running turn becomes recent when it starts and advances again when +it completes. + +Older summaries need a one-time semantic repair. During partial rollout, the +frontend falls back to the task update or creation time when +`last_activity_at` is absent. + +## Alternatives Considered + +- **Reuse summary `updated_at`.** Rejected because provider and Git status + changes are not user or agent activity. +- **Use only `tasks.updated_at`.** Rejected because prompts and agent turns do + not always mutate the task row. +- **Derive activity in each browser.** Rejected because background session + history is not loaded and task switchers must not subscribe to it. +- **Write every activity to the task row.** Rejected because message and turn + domains gain extra task writes and mix activity with task edits. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index dab1b9def5..ecf212d8ff 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -172,4 +172,5 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 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 | +| 2026-08-17-separate-task-activity-from-summary-freshness | [Separate Task Activity From Summary Freshness](2026-08-17-separate-task-activity-from-summary-freshness.md) | accepted | backend, frontend, protocol | 2026-08-17 | | 2026-08-17-release-pr-ruleset-bypass | [Give Stable Release PRs an Administrator Token Bypass](2026-08-17-release-pr-ruleset-bypass.md) | accepted | infra, workflow, security | 2026-08-17 | diff --git a/docs/plans/sidebar-last-activity-sort/plan.md b/docs/plans/sidebar-last-activity-sort/plan.md new file mode 100644 index 0000000000..3e217d2149 --- /dev/null +++ b/docs/plans/sidebar-last-activity-sort/plan.md @@ -0,0 +1,210 @@ +--- +spec: docs/specs/ui/sidebar-last-activity-sort.md +created: 2026-08-17 +status: completed +--- + +# Implementation Plan: Sidebar Last Activity Sort + +## Overview + +Add a durable task activity timestamp to the bounded task-status summary. Keep +summary freshness separate, then expose the activity timestamp through a new +saved-view sort on desktop and mobile. + +The work also closes the observed GitHub refresh loop. REST and batched GraphQL +status snapshots must converge on one semantic task-PR value, and diagnostics +must name fields that cause a real update. + +## Confirmed current behavior + +- `apps/web/components/task/task-session-sidebar-item.ts` maps summary + `updated_at` to `TaskSwitcherItem.updatedAt`. +- `apps/web/lib/sidebar/apply-view.ts` uses `updatedAt` for the saved-view + `updatedAt` comparator. +- `apps/web/components/task/task-item-stats-row.tsx` renders the same value as + the row's relative time. +- `apps/backend/internal/task/statussummary/projector.go` stamps + `TaskStatusSummary.UpdatedAt` when any semantic summary field changes. +- The projector subscribes to `github.task_pr.updated`, so provider status can + refresh the visible time of an idle task. +- Sidebar views persist arbitrary sort strings through portable user settings. + Frontend `KNOWN_SORT_KEYS` controls which keys survive migration. +- Desktop and phone surfaces share `SortPicker`, `applySort`, saved-view state, + and user-settings synchronization. + +## Architecture + +### Timestamp ownership + +Extend `TaskStatusSummary` with semantic `LastActivityAt`. Keep `UpdatedAt` as +transport and projection freshness. + +Add a narrow `TaskActivityRepository` to the task service. Its batch query +returns the maximum of: + +- `tasks.created_at` and `tasks.updated_at` +- user-authored `task_session_messages.created_at` +- non-reserved user-owned `queued_messages.queued_at` +- `task_session_turns.started_at` and non-null `completed_at` + +The query accepts task IDs, returns one map, and uses the existing task/message +and turn indexes. Add an index only if query-plan evidence shows a missing +leading task key on a production-sized fixture. + +### Live projection and repair + +Add `lastActivityAt` to projector state and persisted semantic JSON. Rehydrate +it from the stored summary and a narrow activity loader. + +Subscribe to `task.created`, `task.state_changed`, `turn.started`, and +`turn.completed` in addition to the existing task, message, and queue events. +Apply timestamps as follows: + +- `task.created`, `task.updated`, and `task.state_changed`: payload + `updated_at`, with `created_at` as the creation fallback +- `message.added`: `created_at` only for `author_type=user` +- successful user-owned queue admission: `queued_at` +- `turn.started`: `started_at` +- `turn.completed`: `completed_at` + +Use a monotonic maximum. Foreground-activity task events carry the task row's +unchanged timestamp and therefore cannot advance task activity. + +Task-list reconciliation batch-loads activity for all requested tasks. It +repairs missing fields and preserves a newer stored value during compare-and-set +retries. The complete replacement summary continues to use the existing event. + +### GitHub refresh stability + +Normalize GraphQL check-rollup states to the documented `success`, `failure`, +`pending`, or empty contract before `SyncTaskPR` compares fields. Exercise the +same semantic fixture through REST feedback and batched GraphQL conversion. + +Refactor the `SyncTaskPR` comparison into a helper that returns changed field +names. Publish only when that list is non-empty. Log the names at debug level +with task and PR identity, without old or new values. + +### Saved-view and row behavior + +Add `lastActivityAt` to `SortKey`, `KNOWN_SORT_KEYS`, `SORT_OPTIONS`, and the +comparator table. Thread `lastActivityAt` from `status_summary` into +`TaskSwitcherItem`, with task update and creation fallbacks. + +When the effective view uses this key, rows display activity time. Other views +retain the existing time selection. Keep stable input order for equal values. + +Add localized **Last activity** copy to English, Portuguese, Simplified +Chinese, Hong Kong Chinese, Taiwan Chinese, and pseudo catalogs. Run the +Traditional Chinese generator instead of translating both variants by hand. + +## Mobile design contract + +- **Outcome and entry:** desktop opens the Tasks section view editor. Phones + open `SessionTaskSwitcherSheet`, then the existing filter gear. +- **Exemplar:** keep + `apps/web/components/task/mobile/session-task-switcher-sheet.tsx` and the + shared `SidebarFilterPopover` composition. +- **Hierarchy and presentation:** the sort remains a short temporary choice in + the existing selector. No route, drawer, or nested overlay is added. +- **Interaction:** the existing selector row is the touch target. Selection + updates the shared draft and live task order. +- **Scroll and geometry:** the task list remains the only vertical scroll body. + The existing drawer and portaled selector remain inside the Pixel 5 viewport + with no document horizontal overflow. +- **Shared state:** saved-view state, activity mapping, comparator, direction, + persistence, and rollback are shared between desktop and mobile. + +## Tests + +Use Red-Green-Refactor for each implementation task. Run each focused test +before production edits and record the expected failure. Run the same test +after the smallest implementation, then refactor while it remains green. + +### Backend + +- GitHub conversion tests cover every GraphQL rollup state and prove an + equivalent REST/GraphQL sequence emits no second task-PR update. +- Activity repository tests cover tasks without sessions, queued user prompts, + active turns, completed turns, multiple sessions, and batched task IDs. +- Summary model tests cover semantic JSON, equality, compatibility with absent + activity, and monotonic timestamps. +- Projector tests cover every included source, including state-only task + transitions. They prove focus-adjacent status, PR, Git, queue, agent-message, + and older replay events do not advance activity. +- Rebuild tests cover field backfill, no N+1 reads, compare-and-set retry, and + preservation of a newer stored timestamp. + +### Frontend + +- Comparator tests cover ascending, descending, equal times, and fallback. +- Migration, wire, hydration, and live user-settings tests preserve + `lastActivityAt` in saved views and drafts. +- Sidebar mapping and rendered-row tests select activity time only for the new + sort. +- Locale checks cover the new label in every required catalog. + +### E2E + +- Desktop creates a view, selects **Last activity**, verifies order and row + time, saves it, reloads, and verifies persistence. +- The backend regression proves a provider status refresh does not change the + activity timestamp. The desktop flow verifies that resulting sort order. +- Mobile selects the same option from the task-switcher drawer and verifies + order, persistence, touch reachability, viewport containment, scroll + ownership, and zero horizontal overflow. + +## Verification commands + +```bash +make -C apps/backend test lint build +cd apps && pnpm install --frozen-lockfile +cd apps && pnpm --filter @kandev/web exec vitest run lib/sidebar/apply-view.test.ts lib/state/slices/ui/ui-slice-migration.test.ts lib/state/slices/ui/sidebar-view-wire.test.ts components/task/task-session-sidebar-item.test.ts +cd apps/web && pnpm run typecheck +cd apps && pnpm --filter @kandev/web lint +cd apps/web && pnpm run i18n:zh-hant && pnpm run i18n:check && pnpm run i18n:ratchet +cd apps/web && pnpm e2e:run tests/task/sidebar-filter.spec.ts -- --grep "last activity" +cd apps/web && pnpm e2e:run --no-build --project mobile-chrome tests/task/mobile-sidebar-views.spec.ts -- --grep "last activity" +``` + +## Implementation waves + +- [Task 01: Stabilize GitHub task-PR refreshes](task-01-stabilize-github-pr-refresh.md), wave 1 +- [Task 02: Add durable activity reconstruction](task-02-add-activity-reconstruction.md), wave 1 +- [Task 03: Project task activity live](task-03-project-live-task-activity.md), wave 2, after Task 02 +- [Task 04: Add the saved-view sort](task-04-add-sidebar-sort.md), wave 3, after Task 03 +- [Task 05: Prove desktop and mobile behavior](task-05-prove-sidebar-activity-sort.md), wave 4, after Tasks 01 and 04 + +This package does not authorize implementation subagents. Implementation stays +in the user-controlled primary session. + +## Implementation result + +All five tasks are complete. The implementation includes durable activity +reconstruction, live bounded projection, GitHub refresh normalization, the +desktop/mobile saved-view sort, localized catalogs, and production-build +browser coverage. + +Targeted checks passed during implementation: + +- Backend focused GitHub, activity repository, summary, rebuild, projector, and + backend-app tests. +- Frontend focused tests, typecheck, lint, i18n completeness, and i18n ratchet. +- One desktop Chromium E2E test and one mobile Chromium E2E test, both through + the managed production-build runner. + +The full backend test, lint, and build matrix passed as the final pre-PR gate. + +## Risks and boundaries + +- `updated_at` and `last_activity_at` must remain separate in semantic equality, + persistence, DTOs, and UI mapping. +- Event timestamps can arrive out of order. Every live and rebuild path needs a + monotonic maximum. +- Counting all agent messages recreates high-frequency task-list traffic. + Turn start and completion are the bounded agent milestones. +- Existing saved views and default view creation must not change. +- The GitHub normalization test must exercise both real conversion paths. A + handcrafted `PRStatus` pair does not detect future adapter drift. +- Public documentation does not describe sidebar sort choices today. No public + documentation update is required. diff --git a/docs/plans/sidebar-last-activity-sort/task-01-stabilize-github-pr-refresh.md b/docs/plans/sidebar-last-activity-sort/task-01-stabilize-github-pr-refresh.md new file mode 100644 index 0000000000..a98c4d6afa --- /dev/null +++ b/docs/plans/sidebar-last-activity-sort/task-01-stabilize-github-pr-refresh.md @@ -0,0 +1,54 @@ +--- +id: "01-stabilize-github-pr-refresh" +title: "Stabilize GitHub task-PR refreshes" +status: completed +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/ui/sidebar-last-activity-sort.md" +--- + +# Task 01: Stabilize GitHub task-PR refreshes + +Make REST feedback and batched GraphQL polling converge on one task-PR status. +Add safe evidence for any future semantic update. + +## Acceptance + +- GraphQL check-rollup values map to the documented task-PR check states. +- Equivalent REST and GraphQL snapshots do not persist or publish a second + semantic task-PR update. +- A real update logs only changed field names with task and PR identity, then + publishes the existing event once. + +## Verification + +```bash +cd apps/backend && go test ./internal/github -run 'CheckRollup|Equivalent.*Status|SyncTaskPR.*ChangedFields' +``` + +## Files likely touched + +- `apps/backend/internal/github/graphql.go` +- `apps/backend/internal/github/graphql_test.go` +- `apps/backend/internal/github/service_pr_watch.go` +- `apps/backend/internal/github/service_pr_feedback_sync_test.go` +- `apps/backend/internal/github/service_pr_watch_batched_budget_test.go` + +## Dependencies + +None. + +## Risks + +- The regression must call both conversion paths before `SyncTaskPR`. +- Logs must not include provider field values, comments, titles, or tokens. +- Normalization must preserve the existing empty state for absent checks. + +## Result + +- RED: the focused tests failed because the normalization and changed-field + helper were not present. +- GREEN: `go test ./internal/github -run + 'CheckRollup|Equivalent.*Status|SyncTaskPR.*ChangedFields'` passed (10 tests). +- Regression: `go test ./internal/github` passed (1572 tests). diff --git a/docs/plans/sidebar-last-activity-sort/task-02-add-activity-reconstruction.md b/docs/plans/sidebar-last-activity-sort/task-02-add-activity-reconstruction.md new file mode 100644 index 0000000000..3a1d429dde --- /dev/null +++ b/docs/plans/sidebar-last-activity-sort/task-02-add-activity-reconstruction.md @@ -0,0 +1,60 @@ +--- +id: "02-add-activity-reconstruction" +title: "Add durable activity reconstruction" +status: completed +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/ui/sidebar-last-activity-sort.md" +--- + +# Task 02: Add durable activity reconstruction + +Create the authoritative batched read that reconstructs task activity from +task, user-message, queued-prompt, and turn records. + +## Acceptance + +- One repository call returns the latest activity for every requested task ID. +- The result includes task creation and mutation, user prompts including + non-reserved queued prompts, and turn start or completion. It excludes agent + messages, reserved queue entries, and session metadata timestamps. +- SQLite and Postgres behavior is covered without an N+1 query. + +## Verification + +```bash +cd apps/backend && go test ./internal/task/repository/sqlite -run 'TaskLastActivity' +cd apps/backend && go test ./internal/task/service -run 'StatusSummary.*Activity.*Rebuild' +``` + +## Files likely touched + +- `apps/backend/internal/task/repository/interface.go` +- `apps/backend/internal/task/repository/sqlite/task_status_summary.go` +- `apps/backend/internal/task/repository/sqlite/task_status_summary_test.go` +- `apps/backend/internal/task/repository/sqlite/task_status_summary_postgres_test.go` +- `apps/backend/internal/task/service/service.go` +- `apps/backend/internal/backendapp/services.go` + +## Dependencies + +None. + +## Risks + +- The task-ID filter must apply inside every union branch. +- Null completion times must not erase a turn start. +- Add an index only after query-plan evidence shows the existing indexes are + insufficient. + +## Result + +- RED: the repository test failed before `LoadTaskLastActivity` existed. +- GREEN: `go test ./internal/task/repository/sqlite -run 'TaskLastActivity'` + passed (1 SQLite test). The Postgres counterpart is environment-gated. +- GREEN: `go test ./internal/task/service -run + 'StatusSummary.*Activity.*Rebuild'` passed (1 rebuild test). +- GREEN: queued user prompts are included in the same batched max query while + agent-owned queued rows remain excluded; affected repository, projector, and + handler packages passed 1,031 tests. diff --git a/docs/plans/sidebar-last-activity-sort/task-03-project-live-task-activity.md b/docs/plans/sidebar-last-activity-sort/task-03-project-live-task-activity.md new file mode 100644 index 0000000000..dfb51e9eb3 --- /dev/null +++ b/docs/plans/sidebar-last-activity-sort/task-03-project-live-task-activity.md @@ -0,0 +1,78 @@ +--- +id: "03-project-live-task-activity" +title: "Project task activity live" +status: completed +wave: 2 +depends_on: ["02-add-activity-reconstruction"] +plan: "plan.md" +spec: "../../specs/ui/sidebar-last-activity-sort.md" +--- + +# Task 03: Project task activity live + +Extend the bounded task summary with a monotonic activity time and repair older +summary rows from the new batched source. + +## Acceptance + +- Summary JSON, equality, persistence, DTOs, and replacement events carry + optional `last_activity_at` without changing `updated_at`. +- Task creation, task update, state-only task transition, user-message, user + queue admission, and turn events advance activity by source time. Queue + bookkeeping, excluded background status, and older replay events do not + advance it. +- Initial hydration repairs missing values in batch and preserves newer stored + activity through compare-and-set retries. + +## Verification + +```bash +cd apps/backend && go test ./internal/task/statussummary -run 'LastActivity' +cd apps/backend && go test ./internal/task/service -run 'StatusSummary.*LastActivity' +cd apps/backend && go test ./internal/backendapp -run 'StatusSummary' +``` + +## Files likely touched + +- `apps/backend/internal/task/statussummary/model.go` +- `apps/backend/internal/task/statussummary/model_test.go` +- `apps/backend/internal/task/statussummary/projector.go` +- `apps/backend/internal/task/statussummary/projector_events.go` +- `apps/backend/internal/task/statussummary/projector_derive.go` +- `apps/backend/internal/task/statussummary/projector_test.go` +- `apps/backend/internal/task/statussummary/projector_restart_rehydration_test.go` +- `apps/backend/internal/task/statussummary/rebuild.go` +- `apps/backend/internal/task/statussummary/rebuild_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/backendapp/gateway.go` + +## Dependencies + +Task 02. + +## Risks + +- A status-only event must not copy projection time into activity time. +- State transitions publish `task.state_changed` without a matching + `task.updated` on several service paths, so the projector must consume both. +- Message events count only user-authored additions. +- Repair and live projection must share the same monotonic rule. + +## Result + +- Added optional semantic `last_activity_at` to live summary replacement + payloads and projector state, with durable loader rehydration. +- Subscribed to task creation, state-only transitions, user queue admission, + and turn start and completion. User-authored message additions and queued + admissions use their persisted source time; background status, provider, + queue bookkeeping, agent-message, and older replay events do not advance the + monotonic maximum. Queue merges retain the newest admission time. +- Focused verification passed: + - `go test ./internal/task/statussummary -run 'LastActivity'` (3 tests) + - `go test ./internal/task/service -run 'StatusSummary.*LastActivity'` (no matching tests) + - `go test ./internal/backendapp -run 'StatusSummary'` (7 tests) + - `go test ./internal/task/statussummary` (64 tests) +- Review fixup verification: affected backend packages passed 1,031 tests, + including live queued-prompt projection and SQLite/Postgres reconstruction + coverage. diff --git a/docs/plans/sidebar-last-activity-sort/task-04-add-sidebar-sort.md b/docs/plans/sidebar-last-activity-sort/task-04-add-sidebar-sort.md new file mode 100644 index 0000000000..bcc6a6e669 --- /dev/null +++ b/docs/plans/sidebar-last-activity-sort/task-04-add-sidebar-sort.md @@ -0,0 +1,75 @@ +--- +id: "04-add-sidebar-sort" +title: "Add the sidebar activity sort" +status: completed +wave: 3 +depends_on: ["03-project-live-task-activity"] +plan: "plan.md" +spec: "../../specs/ui/sidebar-last-activity-sort.md" +--- + +# Task 04: Add the sidebar activity sort + +Expose `lastActivityAt` through the shared saved-view picker, comparator, row +time, portable settings, and required locales. + +## Acceptance + +- Desktop and mobile view editors offer localized **Last activity** with both + directions. The option updates the live order and row time. +- Saved views and drafts retain `lastActivityAt` through wire conversion, + migration, hydration, settings events, sync failure rollback, and reload. +- Existing **Updated** views and new-view defaults remain unchanged. Missing + activity uses task update, then creation, as fallback. + +## Verification + +```bash +cd apps && pnpm --filter @kandev/web exec vitest run lib/sidebar/apply-view.test.ts lib/state/slices/ui/ui-slice-migration.test.ts lib/state/slices/ui/sidebar-view-wire.test.ts components/task/task-session-sidebar-item.test.ts +cd apps/web && pnpm run typecheck +cd apps && pnpm --filter @kandev/web lint +cd apps/web && pnpm run i18n:zh-hant && pnpm run i18n:check && pnpm run i18n:ratchet +``` + +## Files likely touched + +- `apps/web/lib/types/task-status-summary.ts` +- `apps/web/lib/state/slices/ui/sidebar-view-types.ts` +- `apps/web/lib/state/slices/ui/ui-slice.ts` +- `apps/web/components/task/sidebar-filter/sort-picker.tsx` +- `apps/web/lib/sidebar/apply-view.ts` +- `apps/web/lib/sidebar/apply-view.test.ts` +- `apps/web/components/task/task-switcher-types.ts` +- `apps/web/components/task/task-session-sidebar-item.ts` +- `apps/web/components/task/task-session-sidebar-item.test.ts` +- `apps/web/components/task/task-item-stats-row.tsx` +- `apps/web/lib/state/slices/ui/ui-slice-migration.test.ts` +- `apps/web/lib/state/slices/ui/sidebar-view-wire.test.ts` +- `apps/web/lib/state/hydration/hydrator.test.ts` +- `apps/web/lib/ws/handlers/users.test.ts` +- `apps/web/src/locales/*/task.json` + +## Dependencies + +Task 03. + +## Risks + +- Do not translate or rename the persisted key. +- Do not call `t()` at module scope in the option registry. +- The effective draft controls both sorting and displayed row time. + +## Result + +- Added the `lastActivityAt` sort key, migration allowlist entry, shared + comparator with update/creation fallbacks, and wire round-trip coverage. +- Threaded `last_activity_at` from summary payloads into desktop and mobile + task rows. Relative time uses activity only when the effective view selects + the new sort; existing Updated behavior and defaults remain unchanged. +- Added the localized label to English, Portuguese, Simplified Chinese, + Traditional Chinese, Hong Kong Chinese, and pseudo catalogs. +- Verification passed: + - focused Vitest suite: 119 tests + - `pnpm run typecheck` + - `pnpm --filter @kandev/web lint` + - `pnpm run i18n:zh-hant`, `i18n:check`, and `i18n:ratchet` diff --git a/docs/plans/sidebar-last-activity-sort/task-05-prove-sidebar-activity-sort.md b/docs/plans/sidebar-last-activity-sort/task-05-prove-sidebar-activity-sort.md new file mode 100644 index 0000000000..47a721ebdc --- /dev/null +++ b/docs/plans/sidebar-last-activity-sort/task-05-prove-sidebar-activity-sort.md @@ -0,0 +1,67 @@ +--- +id: "05-prove-sidebar-activity-sort" +title: "Prove sidebar activity sorting" +status: completed +wave: 4 +depends_on: ["01-stabilize-github-pr-refresh", "04-add-sidebar-sort"] +plan: "plan.md" +spec: "../../specs/ui/sidebar-last-activity-sort.md" +--- + +# Task 05: Prove sidebar activity sorting + +Add production-build browser regressions for the saved option, stable activity +order, persistence, and phone geometry. Finish the change-aware verification +gates. + +## Acceptance + +- Desktop proves **Last activity** order and row time survive save and reload. + A provider-only refresh does not reorder activity. +- Mobile proves the same saved option and order from the task-switcher drawer. +- Mobile assertions cover touch interaction, viewport containment, the task-list + scroll owner, and zero document horizontal overflow. + +## Verification + +```bash +cd apps && pnpm install --frozen-lockfile +cd apps/web && pnpm e2e:run tests/task/sidebar-filter.spec.ts -- --grep "last activity" +cd apps/web && pnpm e2e:run --no-build --project mobile-chrome tests/task/mobile-sidebar-views.spec.ts -- --grep "last activity" +make -C apps/backend test lint build +cd apps/web && pnpm run typecheck && pnpm run i18n:check && pnpm run i18n:ratchet +cd apps && pnpm --filter @kandev/web lint +``` + +## Files likely touched + +- `apps/web/e2e/helpers/api-client.ts` +- `apps/web/e2e/pages/sidebar-filter-popover.ts` +- `apps/web/e2e/tests/task/sidebar-filter.spec.ts` +- `apps/web/e2e/tests/task/mobile-sidebar-views.spec.ts` +- `apps/web/components/task/task-item-stats-row.tsx` + +## Dependencies + +Tasks 01 and 04. + +## Risks + +- Seed distinct activity milestones instead of relying on fixed sleeps. +- Scope portaled selectors to the active desktop or mobile surface. +- Confirm Playwright discovers one desktop test and one mobile test before + treating the runs as evidence. + +## Result + +- Desktop discovery found one targeted test, then the managed production E2E + run passed. It verified Last activity ordering, row timestamp mapping, saved + view persistence after reload, and stable order after two provider-only PR + refreshes. +- Mobile discovery found one targeted test, then the managed mobile-chrome E2E + run passed. It verified touch selection, saved-view persistence, drawer + containment, the task-list scroll owner, and zero document horizontal + overflow. +- The desktop fixture explicitly uses the existing None group so provider + association does not intentionally change repository grouping while the test + measures activity ordering. diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index 3388d361a4..ae0a17c617 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -233,6 +233,7 @@ Subscription quota tracking and per-agent cheap-model profile routing. | [external-vcs-file-links](ui/external-vcs-file-links.md) | shipped | | [task-listing-display-preferences](ui/task-listing-display-preferences.md) | shipped | | [sidebar-archived-filter](ui/sidebar-archived-filter.md) | draft | +| [sidebar-last-activity-sort](ui/sidebar-last-activity-sort.md) | draft | | [task-workspace-content-search](ui/task-workspace-content-search.md) | shipped | | [file-tree-chat-context](ui/file-tree-chat-context.md) | shipped | | [task-review-shortcut](ui/task-review-shortcut.md) | approved | diff --git a/docs/specs/platform/bounded-task-status-delivery.md b/docs/specs/platform/bounded-task-status-delivery.md index 8b34e0802d..48f1c36a80 100644 --- a/docs/specs/platform/bounded-task-status-delivery.md +++ b/docs/specs/platform/bounded-task-status-delivery.md @@ -1,7 +1,7 @@ --- status: approved created: 2026-08-01 -updated: 2026-08-15 +updated: 2026-08-17 owner: kandev --- @@ -52,6 +52,7 @@ The initial contract is: | Field | Meaning | Bound | | ---------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------ | | `revision`, `updated_at` | Monotonic task-local version and projection time | Constant | +| `last_activity_at` | Latest durable task, user-prompt, or turn milestone | Constant | | `primary_session` | Primary session ID and lifecycle state | One session | | `foreground_activity`, `active_subagent_count` | Existing task-level busy aggregate | Constant | | `pending_action` | `permission`, `clarification`, or absent | Constant | @@ -115,6 +116,14 @@ remain during migration, but switchers use the summary when present. - 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. +- `last_activity_at` is separate from projection freshness. Task creation, + persisted task mutations, user-authored prompts, and turn start or completion + advance it by source time. Focus, subscriptions, Git or pull-request polling, + queue bookkeeping, summary repair, and streamed chunks do not advance it. +- Missing and older summaries rebuild `last_activity_at` in one batch from + task, user-message, and turn records. Live and rebuilt values use a monotonic + maximum, so replay and repair cannot move activity backward. See + [ADR-2026-08-17-separate-task-activity-from-summary-freshness](../../decisions/2026-08-17-separate-task-activity-from-summary-freshness.md). ## API and event surface @@ -254,6 +263,9 @@ intermediate replacement. changes its Git tree, or receives a PR update, **WHEN** its summary revision arrives, **THEN** desktop and mobile rows update without a session subscription. +- **GIVEN** an idle task receives Git or pull-request summary changes, **WHEN** + its replacement summary arrives, **THEN** `updated_at` can advance while + `last_activity_at` remains unchanged. - **GIVEN** a recoverable error is dismissed or followed by a newer agent response, **WHEN** the projector processes that occurrence, **THEN** the independent error indicator clears on both task switchers. diff --git a/docs/specs/ui/sidebar-last-activity-sort.md b/docs/specs/ui/sidebar-last-activity-sort.md new file mode 100644 index 0000000000..85d4ef5156 --- /dev/null +++ b/docs/specs/ui/sidebar-last-activity-sort.md @@ -0,0 +1,163 @@ +--- +status: draft +created: 2026-08-17 +updated: 2026-08-17 +owner: kandev +--- + +# Sidebar Last Activity Sort + +## Why + +Saved sidebar views can sort tasks by **Updated**. That value currently follows +task-status summary freshness. Background pull-request updates can refresh many +idle rows together, so this order does not answer which tasks a user or agent +worked on most recently. + +Users need a separate **Last activity** option that remains stable during +background status refreshes. + +## What + +- Desktop and mobile sidebar view editors offer **Last activity** as a sort + option. +- The saved-view wire key is `lastActivityAt`. The key persists in saved views + and in-flight drafts through user settings, boot hydration, and live settings + updates. +- Ascending order places the oldest activity first. Descending order places the + newest activity first. +- Equal timestamps retain the existing input order. Sorting does not introduce + row movement for a tie. +- The existing `updatedAt` key and **Updated** label remain valid. Existing + saved views keep their current behavior. +- The built-in view and new-view defaults do not change. +- When a view sorts by **Last activity**, each row displays that activity time. + Other sort modes retain the existing row time behavior. + +Decision: +[ADR-2026-08-17-separate-task-activity-from-summary-freshness](../../decisions/2026-08-17-separate-task-activity-from-summary-freshness.md). + +## Activity definition + +`last_activity_at` is the greatest known timestamp from these durable actions: + +| Source | Activity time | +|---|---| +| Task creation | `tasks.created_at` | +| Persisted task mutation | `tasks.updated_at` | +| User-authored prompt, including a queued prompt | message `created_at` | +| Agent turn starts | turn `started_at` | +| Agent turn completes | turn `completed_at` | + +The following events do not advance activity: + +- opening, focusing, or subscribing to a task or session +- Git or pull-request polling and reconciliation +- queued-prompt count changes and other queue bookkeeping +- status-summary rebuilds or transport delivery +- session metadata maintenance without a user prompt or agent turn +- streamed agent chunks inside an already-started turn + +A task-state or workflow-placement change counts because it is a persisted task +mutation. A foreground-activity publication that carries an unchanged task +timestamp does not advance activity. + +## Data model and derivation + +- `TaskStatusSummary` gains optional semantic field + `last_activity_at: RFC3339 timestamp`. +- `revision` and `updated_at` keep their existing meanings: task-local summary + version and projection freshness. +- A change to `last_activity_at` is semantic. It advances the summary revision + and uses the existing complete-replacement event. +- Live projection consumes both `task.updated` and `task.state_changed`, because + some persisted state transitions publish only the latter. It takes the + monotonic maximum of the stored value and a source event timestamp. An older + replay cannot move activity backward. +- Initial load and repair use one batched query for all requested task IDs. The + query combines task, user-message, and turn timestamps without one query per + task. +- Repair updates older summaries that lack the field. It preserves a stored + activity time that is newer than the rebuilt durable maximum. +- A missing summary or an older backend falls back to task `updated_at`, then + task `created_at`. The row remains sortable during partial recovery. + +## API and persistence + +The existing task surfaces carry the additive field: + +- boot state and task-list snapshots use `status_summary.last_activity_at` +- `task.status_summary.updated` uses the same complete summary payload +- sidebar views and drafts persist sort key `lastActivityAt` through the + existing user-settings JSON contract + +No new endpoint or WebSocket action is required. The summary remains bounded +and contains no transcript, file, or provider payload. + +## Provider refresh stability + +Equivalent GitHub status snapshots from the REST feedback path and the batched +GraphQL poller must produce one canonical task-PR state. A semantic no-op does +not emit `github.task_pr.updated`. + +GitHub sync diagnostics record the names of changed semantic fields without +recording field values or provider content. This evidence makes future +cross-path oscillation visible without exposing sensitive data. + +Provider changes can advance summary `updated_at`. They never advance +`last_activity_at`. + +## Responsive behavior + +Desktop uses the existing Tasks section view editor. Phones use the existing +task-switcher drawer and its shared view editor. Both surfaces use the same +sort key, comparator, draft, and saved setting. + +The option stays inside the existing sort selector. It adds no mobile route, +drawer, popover, or scroll owner. The current task list remains the only +vertical scroll body. + +## Failure modes + +- If activity backfill fails, Kandev keeps the last valid summary and uses the + frontend fallback. A later list load or source event retries convergence. +- If a summary event is dropped, normal task-list hydration restores the + complete value. +- If user-settings sync fails, the existing saved-view rollback and error + treatment restores the previous view and draft. +- An unknown stored sort key keeps the existing migration fallback to Status. + `lastActivityAt` is added to the known-key set before it is offered in the + editor. + +## Scenarios + +- **GIVEN** several idle tasks receive pull-request status refreshes, **WHEN** a + view sorts by **Last activity**, **THEN** their order and displayed activity + times do not change. +- **GIVEN** a task with an older summary refresh but a newer user prompt, + **WHEN** the view sorts descending by **Last activity**, **THEN** that task + appears before a task with newer provider status but older activity. +- **GIVEN** a user sends a queued prompt, **WHEN** the prompt is stored, + **THEN** the task activity advances before the agent begins that turn. +- **GIVEN** an agent begins and completes a turn, **WHEN** each milestone is + projected, **THEN** activity advances at start and again at completion but + not for every streamed chunk. +- **GIVEN** an existing saved view sorted by **Updated**, **WHEN** Kandev is + upgraded, **THEN** the view keeps that sort and its current order. +- **GIVEN** a saved view sorted by **Last activity**, **WHEN** the page reloads, + **THEN** the selection, direction, order, and row timestamps persist. +- **GIVEN** a phone viewport, **WHEN** the user selects **Last activity** in the + task-switcher drawer, **THEN** the same order is applied and the selector + remains touch-reachable and viewport-contained. + +## Out of scope + +- Replacing or renaming the existing **Updated** sort. +- Changing the built-in view or new-view defaults. +- Sorting the Kanban board, Office task list, command panel, or quick-chat tabs. +- Treating passive focus, provider polling, or raw stream chunks as activity. +- Adding a general task activity feed or audit log. + +## Implementation plan + +- [Sidebar Last Activity Sort](../../plans/sidebar-last-activity-sort/plan.md)