From 1ed2c5999793af4b21247449325f57f1310e5349 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:11:28 +0100 Subject: [PATCH] feat: persist and expire quick chats (#1612) * feat: persist and expire quick chats * fix: address quick chat review feedback * fix: resolve quick chat review findings * fix: close remaining quick chat review gaps * fix: avoid repeated quick chat expiration rechecks * fix: guard quick chat expiration deletes * fix: expire abandoned starting quick chats * fix: ignore missing quick chat expiration candidates --- .../backend/internal/backendapp/boot_state.go | 209 ++++++++++++++++++ .../internal/backendapp/helpers_test.go | 137 ++++++++++++ apps/backend/internal/backendapp/main.go | 1 + .../internal/db/dialect/dialect_test.go | 11 + apps/backend/internal/db/dialect/time.go | 11 + .../task/handlers/process_handlers_test.go | 6 + .../internal/task/repository/interface.go | 3 + .../task/repository/repoerrors/errors.go | 3 + .../internal/task/repository/sqlite/errors.go | 2 +- .../internal/task/repository/sqlite/task.go | 96 ++++++++ .../task/repository/task_repository_test.go | 169 ++++++++++++++ .../task/service/handoff_cascade_test.go | 4 + .../task/service/handoff_workspace_test.go | 8 + .../task/service/quick_chat_expiration.go | 62 ++++++ .../internal/task/service/service_tasks.go | 40 +++- .../internal/task/service/service_test.go | 108 +++++++++ apps/web/app/page.tsx | 31 ++- apps/web/lib/api/domains/workspace-api.ts | 2 + apps/web/lib/state/default-state.ts | 17 +- apps/web/lib/state/hydration/hydrator.test.ts | 52 ++++- docs/plans/quick-chat-expiration/plan.md | 195 ++++++++++++++++ .../task-01-backend-candidate-query.md | 42 ++++ .../task-02-backend-expiration-service.md | 42 ++++ .../task-03-boot-hydration.md | 46 ++++ .../task-04-e2e-and-verification.md | 43 ++++ docs/specs/INDEX.md | 1 + docs/specs/tasks/quick-chat-expiration.md | 168 ++++++++++++++ 27 files changed, 1492 insertions(+), 17 deletions(-) create mode 100644 apps/backend/internal/task/service/quick_chat_expiration.go create mode 100644 docs/plans/quick-chat-expiration/plan.md create mode 100644 docs/plans/quick-chat-expiration/task-01-backend-candidate-query.md create mode 100644 docs/plans/quick-chat-expiration/task-02-backend-expiration-service.md create mode 100644 docs/plans/quick-chat-expiration/task-03-boot-hydration.md create mode 100644 docs/plans/quick-chat-expiration/task-04-e2e-and-verification.md create mode 100644 docs/specs/tasks/quick-chat-expiration.md diff --git a/apps/backend/internal/backendapp/boot_state.go b/apps/backend/internal/backendapp/boot_state.go index 96d2e4e85e..66e4acf60b 100644 --- a/apps/backend/internal/backendapp/boot_state.go +++ b/apps/backend/internal/backendapp/boot_state.go @@ -3,6 +3,8 @@ package backendapp import ( "context" "net/http" + "sort" + "time" agentsettingsdto "github.com/kandev/kandev/internal/agent/settings/dto" taskdto "github.com/kandev/kandev/internal/task/dto" @@ -16,6 +18,8 @@ import ( const ( activeWorkspaceCookie = "kandev-active-workspace" legacyOfficeWorkspaceCookie = "office-active-workspace" + bootStateKeySessionID = "sessionId" + bootStateKeyWorkspaceID = "workspaceId" ) func bootInitialState( @@ -48,6 +52,7 @@ func bootInitialState( if route.Route == webapp.RouteOffice { builder.addOfficeRouteState(ctx, req, state) } + builder.addQuickChatState(ctx, req, state) return state } @@ -261,6 +266,210 @@ func (b bootStateBuilder) addRepositoriesState(ctx context.Context, state map[st } } +func (b bootStateBuilder) addQuickChatState(ctx context.Context, req *http.Request, state map[string]any) { + workspaceID := b.resolveQuickChatWorkspaceID(ctx, req, state) + if workspaceID == "" { + return + } + quickChat, err := b.quickChatSessions(ctx, workspaceID) + if err != nil { + b.logBootError("list quick-chat sessions", err) + return + } + state["quickChat"] = map[string]any{ + "isOpen": false, + "sessions": quickChat.sessions, + "activeSessionId": nil, + } + mergeBootTaskSessionItems(state, quickChat.taskSessions) +} + +func (b bootStateBuilder) resolveQuickChatWorkspaceID(ctx context.Context, req *http.Request, state map[string]any) string { + if active := activeWorkspaceIDFromState(state); active != "" { + return active + } + if b.p.taskSvc == nil { + return "" + } + workspaces, err := b.p.taskSvc.ListWorkspaces(ctx) + if err != nil { + b.logBootError("list quick-chat workspaces", err) + return "" + } + settingsWorkspaceID := "" + if settings, ok := b.userSettings(ctx); ok { + settingsWorkspaceID = settings.Settings.WorkspaceID + } + return firstValidID( + workspaceIDSet(workspaces), + queryValue(req, "workspaceId"), + queryValue(req, "workspace"), + readActiveWorkspaceCookie(req), + settingsWorkspaceID, + firstWorkspaceID(workspaces), + ) +} + +func activeWorkspaceIDFromState(state map[string]any) string { + workspaces, ok := state["workspaces"].(map[string]any) + if !ok { + return "" + } + active, _ := workspaces["activeId"].(string) + return active +} + +func (b bootStateBuilder) quickChatSessions(ctx context.Context, workspaceID string) (quickChatBootState, error) { + tasks, err := b.listQuickChatTasks(ctx, workspaceID) + if err != nil { + return quickChatBootState{}, err + } + if len(tasks) == 0 { + return quickChatBootState{sessions: []map[string]any{}}, nil + } + taskIDs := taskIDs(tasks) + sessionsByTask, err := b.p.taskSvc.BatchGetSessionsForTasks(ctx, taskIDs) + if err != nil { + return quickChatBootState{}, err + } + primaryByTask, err := b.p.taskSvc.GetPrimarySessionInfoForTasks(ctx, taskIDs) + if err != nil { + return quickChatBootState{}, err + } + + items := make([]quickChatBootSession, 0, len(tasks)) + taskSessions := make(map[string]taskdto.TaskSessionDTO, len(tasks)) + for _, task := range tasks { + if !isRestorableQuickChatTask(task) { + continue + } + primary := primaryByTask[task.ID] + if primary == nil || primary.ID == "" { + continue + } + items = append(items, quickChatBootSession{ + state: mapQuickChatSessionState(task, primary), + lastActivity: quickChatLastActivity(task, sessionsByTask[task.ID]), + }) + taskSessions[primary.ID] = taskdto.FromTaskSession(primary) + } + sort.SliceStable(items, func(i, j int) bool { + return items[i].lastActivity.After(items[j].lastActivity) + }) + result := make([]map[string]any, 0, len(items)) + for _, item := range items { + result = append(result, item.state) + } + return quickChatBootState{sessions: result, taskSessions: taskSessions}, nil +} + +func (b bootStateBuilder) listQuickChatTasks(ctx context.Context, workspaceID string) ([]*taskmodels.Task, error) { + const pageSize = 1000 + var all []*taskmodels.Task + for page := 1; ; page++ { + tasks, total, err := b.p.taskSvc.ListTasksByWorkspace(ctx, workspaceID, "", "", "", page, pageSize, "", false, false, true, true) + if err != nil { + return nil, err + } + all = append(all, tasks...) + if len(tasks) == 0 || len(all) >= total { + return all, nil + } + } +} + +type quickChatBootSession struct { + state map[string]any + lastActivity time.Time +} + +type quickChatBootState struct { + sessions []map[string]any + taskSessions map[string]taskdto.TaskSessionDTO +} + +func mergeBootTaskSessionItems(state map[string]any, items map[string]taskdto.TaskSessionDTO) { + if len(items) == 0 { + return + } + taskSessions, ok := state["taskSessions"].(map[string]any) + if !ok { + state["taskSessions"] = map[string]any{"items": items} + return + } + merged := make(map[string]any, len(items)) + switch existing := taskSessions["items"].(type) { + case map[string]taskdto.TaskSessionDTO: + for id, session := range existing { + merged[id] = session + } + case map[string]any: + for id, session := range existing { + merged[id] = session + } + } + for id, session := range items { + merged[id] = session + } + taskSessions["items"] = merged +} + +func taskIDs(tasks []*taskmodels.Task) []string { + ids := make([]string, 0, len(tasks)) + for _, task := range tasks { + if task != nil { + ids = append(ids, task.ID) + } + } + return ids +} + +func isRestorableQuickChatTask(task *taskmodels.Task) bool { + return task != nil && + task.IsEphemeral && + task.WorkflowID == "" && + task.Origin != taskmodels.TaskOriginAutomationRun +} + +func mapQuickChatSessionState(task *taskmodels.Task, primary *taskmodels.TaskSession) map[string]any { + state := map[string]any{ + bootStateKeySessionID: primary.ID, + bootStateKeyWorkspaceID: task.WorkspaceID, + } + if task.Title != "" && task.Title != "Quick Chat" { + state["name"] = task.Title + } + if agentProfileID := quickChatAgentProfileID(task, primary); agentProfileID != "" { + state["agentProfileId"] = agentProfileID + } + return state +} + +func quickChatAgentProfileID(task *taskmodels.Task, primary *taskmodels.TaskSession) string { + if task != nil { + if value, ok := task.Metadata[taskmodels.MetaKeyAgentProfileID].(string); ok { + return value + } + } + if primary != nil { + return primary.AgentProfileID + } + return "" +} + +func quickChatLastActivity(task *taskmodels.Task, sessions []*taskmodels.TaskSession) time.Time { + last := time.Time{} + if task != nil { + last = task.UpdatedAt + } + for _, session := range sessions { + if session != nil && session.UpdatedAt.After(last) { + last = session.UpdatedAt + } + } + return last +} + func (b bootStateBuilder) addKanbanSnapshotsState( ctx context.Context, state map[string]any, diff --git a/apps/backend/internal/backendapp/helpers_test.go b/apps/backend/internal/backendapp/helpers_test.go index 0fc44ca207..094e1a3249 100644 --- a/apps/backend/internal/backendapp/helpers_test.go +++ b/apps/backend/internal/backendapp/helpers_test.go @@ -22,6 +22,7 @@ import ( taskdto "github.com/kandev/kandev/internal/task/dto" "github.com/kandev/kandev/internal/task/models" taskrepo "github.com/kandev/kandev/internal/task/repository" + sqlitetaskrepo "github.com/kandev/kandev/internal/task/repository/sqlite" taskservice "github.com/kandev/kandev/internal/task/service" usercontroller "github.com/kandev/kandev/internal/user/controller" userservice "github.com/kandev/kandev/internal/user/service" @@ -30,6 +31,7 @@ import ( workflowrepo "github.com/kandev/kandev/internal/workflow/repository" workflowservice "github.com/kandev/kandev/internal/workflow/service" "github.com/kandev/kandev/internal/worktree" + v1 "github.com/kandev/kandev/pkg/api/v1" ws "github.com/kandev/kandev/pkg/websocket" ) @@ -612,6 +614,139 @@ func TestBootPayloadIncludesDebugRuntimeWhenDevMode(t *testing.T) { } } +func TestBootPayloadRestoresQuickChatSessions(t *testing.T) { + harness := newBootStateTestHarness(t) + ctx := context.Background() + repo := harness.taskRepo + + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-qc", Name: "Quick Chats"}); err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-qc", WorkspaceID: "ws-qc", Name: "Workflow"}); err != nil { + t.Fatalf("CreateWorkflow: %v", err) + } + base := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC) + seedBootQuickChatTask(t, repo, ctx, bootQuickChatFixture{ + id: "task-old", title: "Older Chat", updatedAt: base.Add(-2 * time.Hour), + sessionUpdatedAt: base.Add(-90 * time.Minute), agentProfileID: "agent-old", + }) + seedBootQuickChatTask(t, repo, ctx, bootQuickChatFixture{ + id: "task-new", title: "Newer Chat", updatedAt: base.Add(-4 * time.Hour), + sessionUpdatedAt: base.Add(-10 * time.Minute), agentProfileID: "agent-new", + }) + seedBootQuickChatTask(t, repo, ctx, bootQuickChatFixture{ + id: "task-config", title: "Config", updatedAt: base, sessionUpdatedAt: base, + agentProfileID: "agent-config", metadata: map[string]interface{}{"config_mode": true}, + }) + seedBootQuickChatTask(t, repo, ctx, bootQuickChatFixture{ + id: "task-automation", title: "Automation", updatedAt: base, sessionUpdatedAt: base, + agentProfileID: "agent-automation", origin: models.TaskOriginAutomationRun, + }) + seedBootQuickChatTask(t, repo, ctx, bootQuickChatFixture{ + id: "task-workflow", title: "Workflow Ephemeral", updatedAt: base, sessionUpdatedAt: base, + agentProfileID: "agent-workflow", workflowID: "wf-qc", + }) + + req := httptest.NewRequest(http.MethodGet, "/?workspaceId=ws-qc", nil) + payload := bootPayload(ctx, req, routeParams{ + taskSvc: harness.taskSvc, + services: &Services{Workflow: harness.workflowSvc}, + userCtrl: harness.userCtrl, + }, webapp.ClassifyRoute("/")) + + raw, err := json.Marshal(payload) + if err != nil { + t.Fatalf("Marshal payload: %v", err) + } + var decoded struct { + InitialState struct { + QuickChat struct { + Sessions []struct { + SessionID string `json:"sessionId"` + WorkspaceID string `json:"workspaceId"` + Name string `json:"name"` + AgentProfileID string `json:"agentProfileId"` + } `json:"sessions"` + IsOpen bool `json:"isOpen"` + } `json:"quickChat"` + TaskSessions struct { + Items map[string]struct { + TaskID string `json:"task_id"` + } `json:"items"` + } `json:"taskSessions"` + } `json:"initialState"` + } + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("Unmarshal payload: %v", err) + } + + sessions := decoded.InitialState.QuickChat.Sessions + if len(sessions) != 2 { + t.Fatalf("quickChat sessions = %#v, want 2 restored sessions", sessions) + } + if got := sessions[0].SessionID; got != "task-new-session" { + t.Fatalf("first restored session = %q, want newest task-new-session", got) + } + if sessions[0].AgentProfileID != "agent-new" || sessions[1].AgentProfileID != "agent-old" { + t.Fatalf("agent profile IDs = %#v", sessions) + } + if got := decoded.InitialState.TaskSessions.Items["task-new-session"].TaskID; got != "task-new" { + t.Fatalf("quick chat task session task_id = %q, want task-new", got) + } + if decoded.InitialState.QuickChat.IsOpen { + t.Fatal("quick chat should hydrate closed") + } +} + +type bootQuickChatFixture struct { + id string + title string + workflowID string + origin string + agentProfileID string + metadata map[string]interface{} + updatedAt time.Time + sessionUpdatedAt time.Time +} + +func seedBootQuickChatTask(t *testing.T, repo *sqlitetaskrepo.Repository, ctx context.Context, f bootQuickChatFixture) { + t.Helper() + metadata := map[string]interface{}{models.MetaKeyAgentProfileID: f.agentProfileID} + for key, value := range f.metadata { + metadata[key] = value + } + if err := repo.CreateTask(ctx, &models.Task{ + ID: f.id, + WorkspaceID: "ws-qc", + WorkflowID: f.workflowID, + Title: f.title, + State: v1.TaskStateTODO, + Priority: "medium", + IsEphemeral: true, + Origin: f.origin, + Metadata: metadata, + }); err != nil { + t.Fatalf("CreateTask(%s): %v", f.id, err) + } + if _, err := repo.DB().ExecContext(ctx, + `UPDATE tasks SET created_at = ?, updated_at = ? WHERE id = ?`, + f.updatedAt.Add(-time.Hour), f.updatedAt, f.id, + ); err != nil { + t.Fatalf("backdate task(%s): %v", f.id, err) + } + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: f.id + "-session", + TaskID: f.id, + AgentProfileID: f.agentProfileID, + State: models.TaskSessionStateCompleted, + StartedAt: f.sessionUpdatedAt.Add(-time.Hour), + UpdatedAt: f.sessionUpdatedAt, + IsPrimary: true, + }); err != nil { + t.Fatalf("CreateTaskSession(%s): %v", f.id, err) + } +} + func TestBootRouteDataIntegrationRouteIncludesOnlyLocalContext(t *testing.T) { taskSvc, workflowSvc := newBootStateTestServices(t) ctx := context.Background() @@ -827,6 +962,7 @@ func TestQueryValueReadsRouteQueryFromAppStatePath(t *testing.T) { type bootStateTestHarness struct { taskSvc *taskservice.Service + taskRepo *sqlitetaskrepo.Repository workflowSvc *workflowservice.Service userCtrl *usercontroller.Controller userSvc *userservice.Service @@ -901,6 +1037,7 @@ func newBootStateTestHarness(t *testing.T) bootStateTestHarness { workflowSvc.SetWorkflowProvider(&workflowProviderAdapter{svc: taskSvc}) return bootStateTestHarness{ taskSvc: taskSvc, + taskRepo: taskRepo, workflowSvc: workflowSvc, userCtrl: usercontroller.NewController(userSvc), userSvc: userSvc, diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go index dd8feb9300..e587458324 100644 --- a/apps/backend/internal/backendapp/main.go +++ b/apps/backend/internal/backendapp/main.go @@ -700,6 +700,7 @@ func startGatewayAndServe( } services.Task.StartAutoArchiveLoop(ctx) + services.Task.StartQuickChatExpirationLoop(ctx) // ============================================ // SYSTEM PAGES diff --git a/apps/backend/internal/db/dialect/dialect_test.go b/apps/backend/internal/db/dialect/dialect_test.go index f34e2315c5..d3b3d6e871 100644 --- a/apps/backend/internal/db/dialect/dialect_test.go +++ b/apps/backend/internal/db/dialect/dialect_test.go @@ -111,6 +111,17 @@ func TestNowMinusHours(t *testing.T) { } } +func TestGreatestTimestamp(t *testing.T) { + got := GreatestTimestamp(SQLite3, "t.updated_at", "MAX(ts.updated_at)") + if got != "max(t.updated_at, MAX(ts.updated_at))" { + t.Errorf("sqlite: got %q", got) + } + got = GreatestTimestamp(PGX, "t.updated_at", "MAX(ts.updated_at)") + if got != "GREATEST(t.updated_at, MAX(ts.updated_at))" { + t.Errorf("pgx: got %q", got) + } +} + func TestCurrentDate(t *testing.T) { if CurrentDate(SQLite3) != "date('now')" { t.Errorf("sqlite: got %q", CurrentDate(SQLite3)) diff --git a/apps/backend/internal/db/dialect/time.go b/apps/backend/internal/db/dialect/time.go index 46c8db8651..3b56db4666 100644 --- a/apps/backend/internal/db/dialect/time.go +++ b/apps/backend/internal/db/dialect/time.go @@ -47,6 +47,17 @@ func NowMinusHours(driver, hoursExpr string) string { return fmt.Sprintf("datetime('now', '-' || %s || ' hours')", hoursExpr) } +// GreatestTimestamp returns the greater of two timestamp expressions. +// +// SQLite: max(left, right) +// Postgres: GREATEST(left, right) +func GreatestTimestamp(driver, left, right string) string { + if IsPostgres(driver) { + return fmt.Sprintf("GREATEST(%s, %s)", left, right) + } + return fmt.Sprintf("max(%s, %s)", left, right) +} + // CurrentDate returns the SQL expression for the current date (no time component). // // SQLite: date('now') diff --git a/apps/backend/internal/task/handlers/process_handlers_test.go b/apps/backend/internal/task/handlers/process_handlers_test.go index f29eef0858..043e4d2404 100644 --- a/apps/backend/internal/task/handlers/process_handlers_test.go +++ b/apps/backend/internal/task/handlers/process_handlers_test.go @@ -83,6 +83,12 @@ func (m *mockRepository) ArchiveTask(ctx context.Context, id string) error { func (m *mockRepository) ListTasksForAutoArchive(ctx context.Context) ([]*models.Task, error) { return nil, nil } +func (m *mockRepository) ListExpiredQuickChatTasks(ctx context.Context, cutoff time.Time) ([]*models.Task, error) { + return nil, nil +} +func (m *mockRepository) DeleteExpiredQuickChatTask(ctx context.Context, id string, cutoff time.Time) (bool, error) { + return false, nil +} func (m *mockRepository) CountOpenWatcherCreatedTasks(_ context.Context, _, _ string) (int, error) { return 0, nil } diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index 0a3f20099f..fe47bc3a69 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -12,6 +12,7 @@ import ( var ErrWorkspaceNameMismatch = repoerrors.ErrWorkspaceNameMismatch var ErrWorkspaceNotFound = repoerrors.ErrWorkspaceNotFound +var ErrTaskNotFound = repoerrors.ErrTaskNotFound // WorkspaceRepository handles workspace CRUD. type WorkspaceRepository interface { @@ -43,6 +44,8 @@ type TaskRepository interface { // archived by the named cascade. Returns whether the row was updated. UnarchiveTaskByCascade(ctx context.Context, id, cascadeID string) (bool, error) ListTasksForAutoArchive(ctx context.Context) ([]*models.Task, error) + ListExpiredQuickChatTasks(ctx context.Context, cutoff time.Time) ([]*models.Task, error) + DeleteExpiredQuickChatTask(ctx context.Context, id string, cutoff time.Time) (bool, error) // CountOpenWatcherCreatedTasks returns the number of open watcher-created // tasks for a single watch, identified by the integration's task-metadata // key (e.g. "sentry_issue_watch_id") and the watch id. Open = non-archived diff --git a/apps/backend/internal/task/repository/repoerrors/errors.go b/apps/backend/internal/task/repository/repoerrors/errors.go index 97c30abc21..29a7144328 100644 --- a/apps/backend/internal/task/repository/repoerrors/errors.go +++ b/apps/backend/internal/task/repository/repoerrors/errors.go @@ -8,3 +8,6 @@ var ErrWorkspaceNameMismatch = errors.New("workspace name mismatch") // ErrWorkspaceNotFound reports that no workspace row matched the supplied id. var ErrWorkspaceNotFound = errors.New("workspace not found") + +// ErrTaskNotFound reports that no task row matched the supplied id. +var ErrTaskNotFound = errors.New("task not found") diff --git a/apps/backend/internal/task/repository/sqlite/errors.go b/apps/backend/internal/task/repository/sqlite/errors.go index 5dba6211c8..5db0f1a040 100644 --- a/apps/backend/internal/task/repository/sqlite/errors.go +++ b/apps/backend/internal/task/repository/sqlite/errors.go @@ -10,7 +10,7 @@ import ( // DeleteTask, UpdateTaskState, …) when no row matches the supplied id. // Callers should classify via errors.Is rather than substring-matching the // formatted message, which includes the task id and is therefore brittle. -var ErrTaskNotFound = errors.New("task not found") +var ErrTaskNotFound = repoerrors.ErrTaskNotFound // ErrWorkspaceNotFound is returned by Repository workspace methods when no row // matches the supplied id. diff --git a/apps/backend/internal/task/repository/sqlite/task.go b/apps/backend/internal/task/repository/sqlite/task.go index d1975a721c..f1b6fdffa1 100644 --- a/apps/backend/internal/task/repository/sqlite/task.go +++ b/apps/backend/internal/task/repository/sqlite/task.go @@ -865,6 +865,102 @@ func (r *Repository) ListTasksForAutoArchive(ctx context.Context) ([]*models.Tas return r.scanTasks(rows) } +// ListExpiredQuickChatTasks returns quick-chat tasks whose last task/session +// activity is older than cutoff. Active sessions are excluded so in-use chats +// are never deleted by the idle sweeper. +func (r *Repository) ListExpiredQuickChatTasks(ctx context.Context, cutoff time.Time) ([]*models.Task, error) { + drv := r.ro.DriverName() + sessionActivity := "COALESCE(MAX(ts.updated_at), t.updated_at)" + lastActivity := dialect.GreatestTimestamp(drv, "t.updated_at", sessionActivity) + query := fmt.Sprintf(` + WITH candidates AS ( + SELECT t.id, %s AS last_activity + FROM tasks t + LEFT JOIN task_sessions ts ON ts.task_id = t.id + WHERE t.is_ephemeral = 1 + AND COALESCE(t.workflow_id, '') = '' + AND COALESCE(t.origin, '') != ? + AND %s + AND t.archived_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM task_sessions active + WHERE active.task_id = t.id + AND active.state IN (?, ?) + ) + GROUP BY t.id, t.updated_at + HAVING %s < ? + ) + SELECT %s + FROM tasks t + JOIN candidates c ON c.id = t.id + ORDER BY c.last_activity ASC + `, + lastActivity, + excludeConfigModePredicate(drv, "t.metadata"), + lastActivity, + taskSelectColumns("t"), + ) + rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), + models.TaskOriginAutomationRun, + models.TaskSessionStateRunning, + models.TaskSessionStateIdle, + cutoff, + ) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + return r.scanTasks(rows) +} + +// DeleteExpiredQuickChatTask deletes id only when it still matches the expired +// quick-chat predicate at delete time. +func (r *Repository) DeleteExpiredQuickChatTask(ctx context.Context, id string, cutoff time.Time) (bool, error) { + drv := r.db.DriverName() + sessionActivity := "COALESCE(MAX(ts.updated_at), t.updated_at)" + lastActivity := dialect.GreatestTimestamp(drv, "t.updated_at", sessionActivity) + query := fmt.Sprintf(` + WITH candidate AS ( + SELECT t.id, %s AS last_activity + FROM tasks t + LEFT JOIN task_sessions ts ON ts.task_id = t.id + WHERE t.id = ? + AND t.is_ephemeral = 1 + AND COALESCE(t.workflow_id, '') = '' + AND COALESCE(t.origin, '') != ? + AND %s + AND t.archived_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM task_sessions active + WHERE active.task_id = t.id + AND active.state IN (?, ?) + ) + GROUP BY t.id, t.updated_at + HAVING %s < ? + ) + DELETE FROM tasks + WHERE id = ? + AND EXISTS (SELECT 1 FROM candidate) + `, + lastActivity, + excludeConfigModePredicate(drv, "t.metadata"), + lastActivity, + ) + result, err := r.db.ExecContext(ctx, r.db.Rebind(query), + id, + models.TaskOriginAutomationRun, + models.TaskSessionStateRunning, + models.TaskSessionStateIdle, + cutoff, + id, + ) + if err != nil { + return false, err + } + rows, _ := result.RowsAffected() + return rows > 0, nil +} + // isSafeMetadataKey reports whether s is a safe JSON metadata key to splice // into a json_extract path. The key is concatenated into the SQL text (it // cannot be a bind parameter inside the '$.' path literal), so it must be diff --git a/apps/backend/internal/task/repository/task_repository_test.go b/apps/backend/internal/task/repository/task_repository_test.go index 8bd2da9142..4ebbdf2b4e 100644 --- a/apps/backend/internal/task/repository/task_repository_test.go +++ b/apps/backend/internal/task/repository/task_repository_test.go @@ -182,6 +182,175 @@ func TestSQLiteRepository_UpdateTaskStateIfCurrentIn(t *testing.T) { } } +func TestSQLiteRepository_ListExpiredQuickChatTasks(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-quick", Name: "Quick"}); err != nil { + t.Fatalf("create workspace: %v", err) + } + if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-quick", WorkspaceID: "ws-quick", Name: "Workflow"}); err != nil { + t.Fatalf("create workflow: %v", err) + } + + now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC) + cutoff := now.Add(-7 * 24 * time.Hour) + old := cutoff.Add(-time.Hour) + older := cutoff.Add(-3 * time.Hour) + abandonedCreated := cutoff.Add(-2 * time.Hour) + abandonedStarting := cutoff.Add(-90 * time.Minute) + recent := cutoff.Add(time.Hour) + + cases := []struct { + id string + taskUpdated time.Time + sessionUpdated time.Time + sessionState models.TaskSessionState + ephemeral bool + workflowID string + archived bool + origin string + metadata map[string]interface{} + wantExpired bool + }{ + {id: "expired-older", taskUpdated: older, sessionUpdated: older, sessionState: models.TaskSessionStateCompleted, ephemeral: true, wantExpired: true}, + {id: "abandoned-created", taskUpdated: abandonedCreated, sessionUpdated: abandonedCreated, sessionState: models.TaskSessionStateCreated, ephemeral: true, wantExpired: true}, + {id: "abandoned-starting", taskUpdated: abandonedStarting, sessionUpdated: abandonedStarting, sessionState: models.TaskSessionStateStarting, ephemeral: true, wantExpired: true}, + {id: "expired-old", taskUpdated: old, sessionUpdated: old, sessionState: models.TaskSessionStateCompleted, ephemeral: true, wantExpired: true}, + {id: "expired-waiting-input", taskUpdated: old, sessionUpdated: old, sessionState: models.TaskSessionStateWaitingForInput, ephemeral: true, wantExpired: true}, + {id: "recent-task", taskUpdated: recent, sessionUpdated: old, sessionState: models.TaskSessionStateCompleted, ephemeral: true}, + {id: "recent-session", taskUpdated: old, sessionUpdated: recent, sessionState: models.TaskSessionStateCompleted, ephemeral: true}, + {id: "running-session", taskUpdated: older, sessionUpdated: older, sessionState: models.TaskSessionStateRunning, ephemeral: true}, + {id: "idle-session", taskUpdated: older, sessionUpdated: older, sessionState: models.TaskSessionStateIdle, ephemeral: true}, + {id: "config-mode", taskUpdated: older, sessionUpdated: older, sessionState: models.TaskSessionStateCompleted, ephemeral: true, metadata: map[string]interface{}{"config_mode": true}}, + {id: "automation-run", taskUpdated: older, sessionUpdated: older, sessionState: models.TaskSessionStateCompleted, ephemeral: true, origin: models.TaskOriginAutomationRun}, + {id: "kanban-task", taskUpdated: older, sessionUpdated: older, sessionState: models.TaskSessionStateCompleted, ephemeral: false, workflowID: "wf-quick"}, + {id: "ephemeral-workflow", taskUpdated: older, sessionUpdated: older, sessionState: models.TaskSessionStateCompleted, ephemeral: true, workflowID: "wf-quick"}, + {id: "archived", taskUpdated: older, sessionUpdated: older, sessionState: models.TaskSessionStateCompleted, ephemeral: true, archived: true}, + } + + for _, tc := range cases { + createQuickChatExpiryFixture(t, repo, ctx, tc.id, tc.workflowID, tc.origin, tc.metadata, tc.ephemeral, tc.archived, tc.taskUpdated, tc.sessionUpdated, tc.sessionState) + } + + tasks, err := repo.ListExpiredQuickChatTasks(ctx, cutoff) + if err != nil { + t.Fatalf("ListExpiredQuickChatTasks: %v", err) + } + + var got []string + for _, task := range tasks { + got = append(got, task.ID) + } + var want []string + for _, tc := range cases { + if tc.wantExpired { + want = append(want, tc.id) + } + } + if !slices.Equal(got, want) { + t.Fatalf("expired task IDs = %v, want %v", got, want) + } +} + +func TestSQLiteRepository_DeleteExpiredQuickChatTask(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-quick", Name: "Quick"}); err != nil { + t.Fatalf("create workspace: %v", err) + } + + now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC) + cutoff := now.Add(-7 * 24 * time.Hour) + old := cutoff.Add(-time.Hour) + recent := cutoff.Add(time.Hour) + + createQuickChatExpiryFixture(t, repo, ctx, "expired", "", "", nil, true, false, old, old, models.TaskSessionStateCompleted) + createQuickChatExpiryFixture(t, repo, ctx, "active", "", "", nil, true, false, old, old, models.TaskSessionStateRunning) + createQuickChatExpiryFixture(t, repo, ctx, "recent", "", "", nil, true, false, old, recent, models.TaskSessionStateCompleted) + + deleted, err := repo.DeleteExpiredQuickChatTask(ctx, "expired", cutoff) + if err != nil { + t.Fatalf("DeleteExpiredQuickChatTask expired: %v", err) + } + if !deleted { + t.Fatal("expected expired quick chat to be deleted") + } + if _, err := repo.GetTask(ctx, "expired"); err == nil { + t.Fatal("expired quick chat still exists after guarded delete") + } + + for _, id := range []string{"active", "recent"} { + deleted, err := repo.DeleteExpiredQuickChatTask(ctx, id, cutoff) + if err != nil { + t.Fatalf("DeleteExpiredQuickChatTask %s: %v", id, err) + } + if deleted { + t.Fatalf("%s should not be deleted while ineligible", id) + } + if _, err := repo.GetTask(ctx, id); err != nil { + t.Fatalf("%s should still exist: %v", id, err) + } + } +} + +func createQuickChatExpiryFixture( + t *testing.T, + repo *sqlite.Repository, + ctx context.Context, + id string, + workflowID string, + origin string, + metadata map[string]interface{}, + ephemeral bool, + archived bool, + taskUpdated time.Time, + sessionUpdated time.Time, + sessionState models.TaskSessionState, +) { + t.Helper() + task := &models.Task{ + ID: id, + WorkspaceID: "ws-quick", + WorkflowID: workflowID, + Title: id, + State: v1.TaskStateTODO, + Priority: "medium", + IsEphemeral: ephemeral, + Origin: origin, + Metadata: metadata, + CreatedAt: taskUpdated.Add(-time.Hour), + UpdatedAt: taskUpdated, + } + if archived { + archivedAt := taskUpdated.Add(time.Minute) + task.ArchivedAt = &archivedAt + } + if err := repo.CreateTask(ctx, task); err != nil { + t.Fatalf("CreateTask(%s): %v", id, err) + } + if _, err := repo.DB().ExecContext(ctx, + `UPDATE tasks SET created_at = ?, updated_at = ?, archived_at = ? WHERE id = ?`, + task.CreatedAt, taskUpdated, task.ArchivedAt, id, + ); err != nil { + t.Fatalf("backdate task(%s): %v", id, err) + } + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: id + "-session", + TaskID: id, + AgentProfileID: "agent-1", + State: sessionState, + StartedAt: sessionUpdated.Add(-time.Hour), + UpdatedAt: sessionUpdated, + IsPrimary: true, + }); err != nil { + t.Fatalf("CreateTaskSession(%s): %v", id, err) + } +} + func TestSQLiteRepository_ListChildCompletionRows_ActiveChildren(t *testing.T) { repo, cleanup := createTestSQLiteRepo(t) defer cleanup() diff --git a/apps/backend/internal/task/service/handoff_cascade_test.go b/apps/backend/internal/task/service/handoff_cascade_test.go index 0fc7f3272c..4d198147be 100644 --- a/apps/backend/internal/task/service/handoff_cascade_test.go +++ b/apps/backend/internal/task/service/handoff_cascade_test.go @@ -327,6 +327,10 @@ func (r *fakeDeleteRepo) DeleteTask(_ context.Context, id string) error { return nil } +func (r *fakeDeleteRepo) DeleteExpiredQuickChatTask(context.Context, string, time.Time) (bool, error) { + panic("DeleteExpiredQuickChatTask should not be used by delete cascade tests") +} + // REGRESSION (post-review #4): a parent with already-archived children // must still have those children deleted by the cascade. The original // collectTaskTree used ListChildren which filtered archived rows; the diff --git a/apps/backend/internal/task/service/handoff_workspace_test.go b/apps/backend/internal/task/service/handoff_workspace_test.go index fe26d5409c..90a2fcb9fd 100644 --- a/apps/backend/internal/task/service/handoff_workspace_test.go +++ b/apps/backend/internal/task/service/handoff_workspace_test.go @@ -375,6 +375,14 @@ func (r *phase4TaskRepo) ListTasksForAutoArchive(context.Context) ([]*models.Tas r.panicNotUsed("ListTasksForAutoArchive") return nil, nil } +func (r *phase4TaskRepo) ListExpiredQuickChatTasks(context.Context, time.Time) ([]*models.Task, error) { + r.panicNotUsed("ListExpiredQuickChatTasks") + return nil, nil +} +func (r *phase4TaskRepo) DeleteExpiredQuickChatTask(context.Context, string, time.Time) (bool, error) { + r.panicNotUsed("DeleteExpiredQuickChatTask") + return false, nil +} func (r *phase4TaskRepo) CountOpenWatcherCreatedTasks(context.Context, string, string) (int, error) { r.panicNotUsed("CountOpenWatcherCreatedTasks") return 0, nil diff --git a/apps/backend/internal/task/service/quick_chat_expiration.go b/apps/backend/internal/task/service/quick_chat_expiration.go new file mode 100644 index 0000000000..fde4c137a1 --- /dev/null +++ b/apps/backend/internal/task/service/quick_chat_expiration.go @@ -0,0 +1,62 @@ +package service + +import ( + "context" + "time" + + "go.uber.org/zap" +) + +const ( + quickChatIdleRetention = 7 * 24 * time.Hour + quickChatExpirationInterval = 24 * time.Hour +) + +// StartQuickChatExpirationLoop starts a background goroutine that removes +// abandoned quick chats after the retention window. +func (s *Service) StartQuickChatExpirationLoop(ctx context.Context) { + ticker := time.NewTicker(quickChatExpirationInterval) + go func() { + defer ticker.Stop() + s.runQuickChatExpiration(ctx, time.Now()) + for { + select { + case <-ctx.Done(): + return + case now := <-ticker.C: + s.runQuickChatExpiration(ctx, now) + } + } + }() + s.logger.Info("quick-chat expiration loop started", + zap.Duration("interval", quickChatExpirationInterval), + zap.Duration("retention", quickChatIdleRetention)) +} + +func (s *Service) runQuickChatExpiration(ctx context.Context, now time.Time) { + cutoff := now.UTC().Add(-quickChatIdleRetention) + tasks, err := s.tasks.ListExpiredQuickChatTasks(ctx, cutoff) + if err != nil { + s.logger.Warn("quick-chat expiration: failed to list candidates", zap.Error(err)) + return + } + if len(tasks) == 0 { + return + } + + s.logger.Info("quick-chat expiration: found candidates", zap.Int("count", len(tasks))) + for _, task := range tasks { + deleted, err := s.deleteExpiredQuickChatTask(ctx, task.ID, cutoff) + if err != nil { + s.logger.Warn("quick-chat expiration: failed to delete task", + zap.String("task_id", task.ID), + zap.Error(err)) + continue + } + if !deleted { + continue + } + s.logger.Info("quick-chat expiration: deleted task", + zap.String("task_id", task.ID)) + } +} diff --git a/apps/backend/internal/task/service/service_tasks.go b/apps/backend/internal/task/service/service_tasks.go index 932bd69f12..7872a154bd 100644 --- a/apps/backend/internal/task/service/service_tasks.go +++ b/apps/backend/internal/task/service/service_tasks.go @@ -18,6 +18,7 @@ import ( "github.com/kandev/kandev/internal/common/gitref" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/task/models" + taskrepo "github.com/kandev/kandev/internal/task/repository" "github.com/kandev/kandev/internal/worktree" ) @@ -1006,12 +1007,37 @@ func (s *Service) DeleteTaskWithReason(ctx context.Context, id, reason string) e } func (s *Service) deleteTaskWithReason(ctx context.Context, id, reason string) error { + _, err := s.deleteTaskWithReasonAndDBDelete(ctx, id, reason, func(ctx context.Context, id string) (bool, error) { + if err := s.tasks.DeleteTask(ctx, id); err != nil { + return false, err + } + return true, nil + }) + return err +} + +func (s *Service) deleteExpiredQuickChatTask(ctx context.Context, id string, cutoff time.Time) (bool, error) { + deleted, err := s.deleteTaskWithReasonAndDBDelete(ctx, id, "", func(ctx context.Context, id string) (bool, error) { + return s.tasks.DeleteExpiredQuickChatTask(ctx, id, cutoff) + }) + if errors.Is(err, taskrepo.ErrTaskNotFound) { + return false, nil + } + return deleted, err +} + +func (s *Service) deleteTaskWithReasonAndDBDelete( + ctx context.Context, + id string, + reason string, + deleteFromDB func(context.Context, string) (bool, error), +) (bool, error) { start := time.Now() // 1. Get task (sync, fast) task, err := s.tasks.GetTask(ctx, id) if err != nil { - return err + return false, err } // 2. Gather data needed for cleanup BEFORE delete (sync, fast) @@ -1037,14 +1063,18 @@ func (s *Service) deleteTaskWithReason(ctx context.Context, id, reason string) e } stopTargets, err = s.buildStopTargets(ctx, id, activeSessions) if err != nil { - return fmt.Errorf("list runtime cleanup inventory: %w", err) + return false, fmt.Errorf("list runtime cleanup inventory: %w", err) } } // 4. Delete from DB (sync, fast) - if err := s.tasks.DeleteTask(ctx, id); err != nil { + deleted, err := deleteFromDB(ctx, id) + if err != nil { s.logger.Error("failed to delete task", zap.String("task_id", id), zap.Error(err)) - return err + return false, err + } + if !deleted { + return false, nil } // 5. Publish event (sync, fast) - frontend removes task immediately @@ -1068,7 +1098,7 @@ func (s *Service) deleteTaskWithReason(ctx context.Context, id, reason string) e "task deleted", "failed to stop session on task delete", "task cleanup completed") } - return nil + return true, nil } // CleanupTaskResources tears down a task's runtime resources (container, diff --git a/apps/backend/internal/task/service/service_test.go b/apps/backend/internal/task/service/service_test.go index b4d664d2d9..93aaf1af87 100644 --- a/apps/backend/internal/task/service/service_test.go +++ b/apps/backend/internal/task/service/service_test.go @@ -1200,6 +1200,114 @@ func TestService_DeleteTaskCleansSuccessfulSessionResourcesOnPartialStopFailure( } } +func TestService_QuickChatExpirationDeletesExpiredCandidates(t *testing.T) { + svc, eventBus, repo := createTestService(t) + ctx := context.Background() + svc.setCleanupDoneForTestHook(make(chan struct{}, 1)) + quickChatDir := t.TempDir() + svc.SetQuickChatDir(quickChatDir) + + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-expire", Name: "Expire"}); err != nil { + t.Fatalf("create workspace: %v", err) + } + expiredTaskID := "quick-expired" + recentTaskID := "quick-recent" + now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC) + + createQuickChatExpirationServiceFixture(t, repo, ctx, expiredTaskID, now.Add(-8*24*time.Hour)) + createQuickChatExpirationServiceFixture(t, repo, ctx, recentTaskID, now.Add(-time.Hour)) + expiredSessionDir := filepath.Join(quickChatDir, expiredTaskID+"-session") + if err := os.MkdirAll(expiredSessionDir, 0o755); err != nil { + t.Fatalf("mkdir expired session dir: %v", err) + } + + svc.runQuickChatExpiration(ctx, now) + waitForCleanupDone(t, svc) + + if _, err := repo.GetTask(ctx, expiredTaskID); err == nil { + t.Fatal("expired quick chat should be deleted") + } + if _, err := repo.GetTask(ctx, recentTaskID); err != nil { + t.Fatalf("recent quick chat should remain: %v", err) + } + if _, err := os.Stat(expiredSessionDir); !os.IsNotExist(err) { + t.Fatalf("expired quick-chat directory should be removed, got %v", err) + } + if !eventBusHasType(eventBus, "task.deleted") { + t.Fatalf("expected task.deleted event, got %#v", eventBus.GetPublishedEvents()) + } +} + +func TestService_QuickChatExpirationLoopRunsOnStartup(t *testing.T) { + svc, _, repo := createTestService(t) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + svc.setCleanupDoneForTestHook(make(chan struct{}, 1)) + + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-expire", Name: "Expire"}); err != nil { + t.Fatalf("create workspace: %v", err) + } + taskID := "quick-expired-startup" + createQuickChatExpirationServiceFixture(t, repo, ctx, taskID, time.Now().UTC().Add(-8*24*time.Hour)) + + svc.StartQuickChatExpirationLoop(ctx) + waitForCleanupDone(t, svc) + + if _, err := repo.GetTask(context.Background(), taskID); err == nil { + t.Fatal("expired quick chat should be deleted by startup sweep") + } +} + +func TestService_DeleteExpiredQuickChatTaskIgnoresMissingTask(t *testing.T) { + svc, _, _ := createTestService(t) + + deleted, err := svc.deleteExpiredQuickChatTask(context.Background(), "missing", time.Now().UTC()) + if err != nil { + t.Fatalf("deleteExpiredQuickChatTask missing task: %v", err) + } + if deleted { + t.Fatal("missing task should not be reported as deleted") + } +} + +func createQuickChatExpirationServiceFixture( + t *testing.T, + repo *sqliterepo.Repository, + ctx context.Context, + taskID string, + updatedAt time.Time, +) { + t.Helper() + if err := repo.CreateTask(ctx, &models.Task{ + ID: taskID, + WorkspaceID: "ws-expire", + Title: taskID, + State: v1.TaskStateTODO, + Priority: "medium", + IsEphemeral: true, + CreatedAt: updatedAt.Add(-time.Hour), + UpdatedAt: updatedAt, + }); err != nil { + t.Fatalf("CreateTask(%s): %v", taskID, err) + } + if _, err := repo.DB().ExecContext(ctx, + `UPDATE tasks SET created_at = ?, updated_at = ? WHERE id = ?`, + updatedAt.Add(-time.Hour), updatedAt, taskID, + ); err != nil { + t.Fatalf("backdate task(%s): %v", taskID, err) + } + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: taskID + "-session", + TaskID: taskID, + State: models.TaskSessionStateCompleted, + StartedAt: updatedAt.Add(-time.Hour), + UpdatedAt: updatedAt, + IsPrimary: true, + }); err != nil { + t.Fatalf("CreateTaskSession(%s): %v", taskID, err) + } +} + func TestService_DeleteTaskCleansMissingSessionExecutorRowAfterStop(t *testing.T) { svc, _, repo := createTestService(t) ctx := context.Background() diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index dc86d04571..56692f7b3c 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -60,6 +60,27 @@ function readAgentProfileId( return typeof value === "string" ? value : undefined; } +type QuickChatTask = Awaited>["tasks"][number]; + +function mapQuickChatSessions(tasks: QuickChatTask[]): AppState["quickChat"]["sessions"] { + const quickChatUpdatedAt = (task: QuickChatTask) => Date.parse(task.updated_at ?? "") || 0; + + return ( + tasks + .filter((task) => task.primary_session_id) + .filter((task) => task.origin !== "automation_run") + // Legacy Next.js path only receives task.updated_at. The Go boot payload + // sorts by max(task/session updated_at) and is the authoritative SPA path. + .sort((a, b) => quickChatUpdatedAt(b) - quickChatUpdatedAt(a)) + .map((task) => ({ + sessionId: task.primary_session_id!, + workspaceId: task.workspace_id, + name: task.title !== "Quick Chat" ? task.title : undefined, + agentProfileId: readAgentProfileId(task.metadata), + })) + ); +} + function buildBaseState( workspaces: ListWorkspacesResponse, userSettingsResponse: UserSettingsResponse | null, @@ -181,15 +202,7 @@ export default async function Page({ searchParams }: PageProps) { workspaceWorkflows: workflowList.workflows, }); - // Map quick chat tasks to sessions - const quickChatSessions = quickChatResponse.tasks - .filter((t) => t.primary_session_id) // Only include tasks with active sessions - .map((t) => ({ - sessionId: t.primary_session_id!, - workspaceId: t.workspace_id, - name: t.title !== "Quick Chat" ? t.title : undefined, - agentProfileId: readAgentProfileId(t.metadata), - })); + const quickChatSessions = mapQuickChatSessions(quickChatResponse.tasks); initialState = { ...initialState, diff --git a/apps/web/lib/api/domains/workspace-api.ts b/apps/web/lib/api/domains/workspace-api.ts index 9e22d0986d..1e99d46f91 100644 --- a/apps/web/lib/api/domains/workspace-api.ts +++ b/apps/web/lib/api/domains/workspace-api.ts @@ -121,6 +121,8 @@ export async function listQuickChatSessions(workspaceId: string, options?: ApiRe workspace_id: string; primary_session_id?: string | null; metadata?: Record | null; + origin?: string | null; + updated_at?: string; }>; }>(`/api/v1/workspaces/${workspaceId}/tasks?only_ephemeral=true&exclude_config=true`, options); } diff --git a/apps/web/lib/state/default-state.ts b/apps/web/lib/state/default-state.ts index 2bbc42df4a..802a64d967 100644 --- a/apps/web/lib/state/default-state.ts +++ b/apps/web/lib/state/default-state.ts @@ -14,6 +14,7 @@ import { defaultAutomationsState, defaultSystemState, } from "./slices"; +import { getStoredQuickChatNames } from "@/lib/local-storage"; export const defaultState = { kanban: defaultKanbanState.kanban, @@ -130,6 +131,20 @@ function mergeGitLabFields( }; } +function mergeQuickChatState(initialState: Partial): DefaultState["quickChat"] { + const quickChat = { ...defaultState.quickChat, ...initialState.quickChat }; + if (!initialState.quickChat?.sessions) return quickChat; + + const storedNames = getStoredQuickChatNames(); + return { + ...quickChat, + sessions: initialState.quickChat.sessions.map((session) => { + const localName = storedNames[session.sessionId]; + return localName ? { ...session, name: localName } : session; + }), + }; +} + export function mergeInitialState(initialState?: Partial): DefaultState { if (!initialState) return defaultState; @@ -220,7 +235,7 @@ export function mergeInitialState(initialState?: Partial): Default chatInput: { ...defaultState.chatInput, ...initialState.chatInput }, documentPanel: { ...defaultState.documentPanel, ...initialState.documentPanel }, systemHealth: { ...defaultState.systemHealth, ...initialState.systemHealth }, - quickChat: { ...defaultState.quickChat, ...initialState.quickChat }, + quickChat: mergeQuickChatState(initialState), sessionFailureNotification: initialState.sessionFailureNotification ?? defaultState.sessionFailureNotification, bottomTerminal: { ...defaultState.bottomTerminal, ...initialState.bottomTerminal }, diff --git a/apps/web/lib/state/hydration/hydrator.test.ts b/apps/web/lib/state/hydration/hydrator.test.ts index b57f868f09..8a563e2e4c 100644 --- a/apps/web/lib/state/hydration/hydrator.test.ts +++ b/apps/web/lib/state/hydration/hydrator.test.ts @@ -3,7 +3,7 @@ import { produce } from "immer"; import type { Draft } from "immer"; import { hydrateState, hydrateUI } from "./hydrator"; import { defaultUIState } from "@/lib/state/slices/ui/ui-slice"; -import { defaultState } from "@/lib/state/default-state"; +import { defaultState, mergeInitialState } from "@/lib/state/default-state"; import type { AppState } from "@/lib/state/store"; function makeDraft(): AppState { @@ -75,6 +75,56 @@ describe("hydrateUI — quick chat name overlay", () => { expect(result.quickChat.sessions.map((s) => s.name)).toEqual(["Renamed A", "Original B"]); }); + + it("clears stale quick chat sessions when the backend returns none", () => { + const result = produce(makeDraft(), (draft: Draft) => { + draft.quickChat = { + isOpen: true, + activeSessionId: "stale-session", + sessions: [{ sessionId: "stale-session", workspaceId: "ws-1", name: "Stale" }], + }; + hydrateUI(draft, { + quickChat: { + isOpen: false, + activeSessionId: null, + sessions: [], + }, + }); + }); + + expect(result.quickChat.sessions).toEqual([]); + expect(result.quickChat.isOpen).toBe(false); + expect(result.quickChat.activeSessionId).toBeNull(); + }); +}); + +describe("mergeInitialState — quick chat name overlay", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it("overlays locally-renamed names during boot payload merge", () => { + window.localStorage.setItem( + "kandev.quickChat.names", + JSON.stringify({ "sess-boot": "Local boot name" }), + ); + + const result = mergeInitialState({ + quickChat: { + isOpen: false, + activeSessionId: null, + sessions: [ + { sessionId: "sess-boot", workspaceId: "ws-1", name: "Backend task title" }, + { sessionId: "sess-other", workspaceId: "ws-1", name: "Other title" }, + ], + }, + }); + + expect(result.quickChat.sessions.map((s) => s.name)).toEqual([ + "Local boot name", + "Other title", + ]); + }); }); describe("hydrateState — sidebar views from user settings", () => { diff --git a/docs/plans/quick-chat-expiration/plan.md b/docs/plans/quick-chat-expiration/plan.md new file mode 100644 index 0000000000..da6a6dc709 --- /dev/null +++ b/docs/plans/quick-chat-expiration/plan.md @@ -0,0 +1,195 @@ +--- +spec: docs/specs/tasks/quick-chat-expiration.md +created: 2026-07-03 +status: implemented +--- + +# Implementation Plan: Quick Chat Persistence & Expiration + +## Overview + +Restore quick-chat tabs from backend state during SPA boot, then add a backend-owned idle sweeper that deletes only true quick-chat tasks after 7 days of inactivity. The repository owns candidate selection because the filter depends on task metadata, task origin, workflow absence, active session state, and last activity across task/session timestamps. The service layer owns deletion so expiration reuses `Service.DeleteTask` and preserves existing cleanup and `task.deleted` behavior. + +--- + +## Backend + +### Quick-chat candidate query + +Files: + +- `apps/backend/internal/task/repository/interface.go` +- `apps/backend/internal/task/repository/sqlite/task.go` +- `apps/backend/internal/task/repository/task_repository_test.go` +- `apps/backend/internal/db/dialect/time.go` and `dialect_test.go` if a small `GreatestTimestamp` helper is needed + +Add a task repository method: + +```go +ListExpiredQuickChatTasks(ctx context.Context, cutoff time.Time) ([]*models.Task, error) +``` + +The SQLite query must return only rows where: + +- `t.is_ephemeral = 1` +- `COALESCE(t.workflow_id, '') = ''` +- `COALESCE(t.origin, '') != models.TaskOriginAutomationRun` +- `excludeConfigModePredicate(driver, "t.metadata")` +- `t.archived_at IS NULL` +- no session for the task has state in `('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT')` +- dialect-aware `last_activity = max/greatest(t.updated_at, COALESCE(MAX(ts.updated_at), t.updated_at)) < cutoff` + +Order by computed last activity ascending for deterministic deletion and tests. + +### Expiration service + +Files: + +- New `apps/backend/internal/task/service/quick_chat_expiration.go` +- `apps/backend/internal/task/service/service_test.go` +- `apps/backend/internal/backendapp/main.go` + +Add constants: + +```go +const quickChatIdleRetention = 7 * 24 * time.Hour +const quickChatExpirationInterval = 24 * time.Hour +``` + +Add service methods: + +```go +func (s *Service) StartQuickChatExpirationLoop(ctx context.Context) +func (s *Service) runQuickChatExpiration(ctx context.Context, now time.Time) +``` + +`runQuickChatExpiration` computes `cutoff := now.Add(-quickChatIdleRetention)`, calls `ListExpiredQuickChatTasks`, logs and returns without deleting on list errors, then iterates candidates and calls `s.DeleteTask(ctx, task.ID)`. A delete failure logs the task ID/error and continues with the rest. + +Wire `services.Task.StartQuickChatExpirationLoop(ctx)` next to `StartAutoArchiveLoop(ctx)` in `apps/backend/internal/backendapp/main.go`. + +### Boot-state quick-chat hydration + +Files: + +- `apps/backend/internal/backendapp/boot_state.go` +- `apps/backend/internal/backendapp/boot_state_routes.go` +- `apps/backend/internal/backendapp/helpers_test.go` +- `apps/web/lib/api/domains/workspace-api.ts` +- `apps/web/app/page.tsx` +- `apps/web/lib/state/hydration/hydrator.test.ts` + +Add a boot-state helper that populates: + +```json +"quickChat": { + "isOpen": false, + "sessions": [ + { + "sessionId": "...", + "workspaceId": "...", + "name": "...", + "agentProfileId": "..." + } + ], + "activeSessionId": null +} +``` + +The helper should: + +- resolve the active workspace from already-built boot state when possible, otherwise from query/cookie/user settings/first workspace using existing helpers; +- fetch all pages of `ListTasksByWorkspace(ctx, workspaceID, "", "", "", page, pageSize, false, false, true, true)` so there is no count cap; +- filter in Go to `workflow_id == ""`, `origin != automation_run`, and `primary_session_id != nil`; +- use `BatchGetSessionsForTasks` and `GetPrimarySessionInfoForTasks` through `taskDTOsWithSessionInfo` or a small shared helper rather than adding a new endpoint; +- compute last activity as `max(task.UpdatedAt, max(session.UpdatedAt))` and sort restored tabs newest first; +- derive `agentProfileId` from `task.Metadata[models.MetaKeyAgentProfileID]`; +- preserve local display-name overrides through the existing frontend `hydrateUI` quick-chat name overlay. + +Call this helper from SPA boot-state assembly so quick chats restore on `/`, `/tasks`, task-detail, local context routes (`/github`, `/gitlab`, `/jira`, `/linear`, `/stats`), `/office`, and settings routes when an active workspace can be resolved. + +Update the legacy `listQuickChatSessions` response typing and `app/page.tsx` mapping to include `origin`, `updated_at`, and the same automation-run filtering so stale Next-compatible code stays type-correct. + +--- + +## Frontend + +No new visible controls are required. The frontend behavior change is boot-time state hydration: `QuickChatProvider` already mounts globally, and `hydrateUI` already overlays local quick-chat names from `localStorage`. + +State and tests: + +- Keep `QuickChatState` in `apps/web/lib/state/slices/ui/types.ts` unchanged unless the boot payload needs an explicit helper type. +- Extend `apps/web/lib/state/hydration/hydrator.test.ts` if needed to assert that hydrated sessions replace the in-memory list, preserve local names, and clear the modal when the backend returns no sessions. +- Add or update `apps/web/src/boot-payload.test.ts` only if TypeScript parsing/shape guards need to understand a narrower quick-chat payload. + +--- + +## Tests + +- **What:** repository expiration query includes quick chats idle longer than cutoff. + **File:** `apps/backend/internal/task/repository/task_repository_test.go` + **How:** SQLite integration test with backdated `tasks.updated_at` and `task_sessions.updated_at`. + +- **What:** any new timestamp SQL dialect helper emits SQLite and Postgres fragments correctly. + **File:** `apps/backend/internal/db/dialect/dialect_test.go` + **How:** unit test for the helper only if Task 01 adds one. + +- **What:** repository query excludes active sessions, config-mode chats, automation-run ephemeral tasks, non-ephemeral tasks, archived tasks, and ephemeral tasks with a workflow. + **File:** `apps/backend/internal/task/repository/task_repository_test.go` + **How:** table-driven SQLite test seeding each row variant. + +- **What:** session activity keeps an old quick-chat task alive. + **File:** `apps/backend/internal/task/repository/task_repository_test.go` + **How:** set `tasks.updated_at` older than 7 days and primary `task_sessions.updated_at` newer than cutoff; assert no candidate. + +- **What:** expiration uses the service delete path, publishes `task.deleted`, and cleans quick-chat directories through existing async cleanup. + **File:** `apps/backend/internal/task/service/service_test.go` + **How:** service test with real SQLite repo, `SetQuickChatDir`, `cleanupDoneForTest`, and event-bus assertions. + +- **What:** expiration fails closed on candidate-list error and continues when a single delete fails. + **File:** `apps/backend/internal/task/service/quick_chat_expiration_test.go` or `service_test.go` + **How:** use a small fake repository/service seam if the real repo cannot simulate the list/delete errors cleanly. + +- **What:** boot payload contains quick-chat sessions ordered by last activity, excluding automation/config tasks. + **File:** `apps/backend/internal/backendapp/helpers_test.go` + **How:** use `newBootStateTestHarness`, create multiple ephemeral tasks/sessions, backdate rows with SQL, call `bootPayload`, and unmarshal `initialState.quickChat.sessions`. + +- **What:** frontend hydration overlays local quick-chat names onto backend-restored sessions. + **File:** `apps/web/lib/state/hydration/hydrator.test.ts` + **How:** existing jsdom localStorage tests extended for multiple restored sessions. + +--- + +## E2E Tests + +- **Scenario:** GIVEN an open quick chat with a message, WHEN the user reloads the page, THEN the quick-chat tab restores and prior history is visible. + **File:** `apps/web/e2e/tests/chat/quick-chat.spec.ts` + **What to verify:** create a quick chat with `openQuickChatWithAgent`, send a mock-agent message, reload, reopen quick chat with the shortcut, assert the same response text appears without selecting a new agent. + +- **Scenario:** GIVEN multiple quick-chat tabs, WHEN the user reloads, THEN all tabs restore. + **File:** `apps/web/e2e/tests/chat/quick-chat.spec.ts` + **What to verify:** extend the existing multi-tab test or add a focused test that counts restored tab buttons after reload. + +Backend expiration is covered by Go integration tests rather than E2E because it has no new visible controls and depends on a 7-day idle clock. + +--- + +## Implementation Waves + +Wave 1: + +- [x] [task-01-backend-candidate-query](task-01-backend-candidate-query.md) +- [x] [task-03-boot-hydration](task-03-boot-hydration.md) + +Wave 2: + +- [x] [task-02-backend-expiration-service](task-02-backend-expiration-service.md) + +Wave 3: + +- [ ] [task-04-e2e-and-verification](task-04-e2e-and-verification.md) + +--- + +## Open Questions + +- Confirm during implementation whether quick-chat messages update `tasks.updated_at`, `task_sessions.updated_at`, or only message/turn rows. The planned candidate query and hydration sort use `MAX(task.updated_at, session.updated_at)`, so the feature remains correct as long as at least one of those rows moves on activity. diff --git a/docs/plans/quick-chat-expiration/task-01-backend-candidate-query.md b/docs/plans/quick-chat-expiration/task-01-backend-candidate-query.md new file mode 100644 index 0000000000..c4311dd206 --- /dev/null +++ b/docs/plans/quick-chat-expiration/task-01-backend-candidate-query.md @@ -0,0 +1,42 @@ +--- +id: "01-backend-candidate-query" +title: "Backend candidate query" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/tasks/quick-chat-expiration.md" +--- + +# Task 01: Backend Candidate Query + +## Acceptance + +- `repository.TaskRepository` exposes `ListExpiredQuickChatTasks(ctx, cutoff)` and SQLite implements it. +- The query matches only true quick chats: ephemeral, no workflow, not archived, not config-mode, not automation-run origin, no active sessions. +- Last activity uses the greater of `tasks.updated_at` and newest `task_sessions.updated_at`. + +## Verification + +- `cd apps/backend && go test ./internal/task/repository -run 'TestSQLiteRepository_ListExpiredQuickChatTasks'` + +## Files likely touched + +- `apps/backend/internal/task/repository/interface.go` +- `apps/backend/internal/task/repository/sqlite/task.go` +- `apps/backend/internal/task/repository/task_repository_test.go` +- Optional `apps/backend/internal/db/dialect/time.go` +- Optional `apps/backend/internal/db/dialect/dialect_test.go` + +## Dependencies + +None. + +## Inputs + +- Spec sections: Data model, API surface, Failure modes, Scenarios. +- Existing patterns: `ListTasksForAutoArchive` in `apps/backend/internal/task/repository/sqlite/task.go`; `excludeConfigModePredicate` in the same file; active session state filters in `apps/backend/internal/task/repository/sqlite/session.go`; SQL portability helpers in `apps/backend/internal/db/dialect/`. + +## Output contract + +Update this task status to `done`, update the Wave 1 checkbox in `plan.md`, and report the files changed, tests run, and any discovered timestamp behavior. diff --git a/docs/plans/quick-chat-expiration/task-02-backend-expiration-service.md b/docs/plans/quick-chat-expiration/task-02-backend-expiration-service.md new file mode 100644 index 0000000000..066f549954 --- /dev/null +++ b/docs/plans/quick-chat-expiration/task-02-backend-expiration-service.md @@ -0,0 +1,42 @@ +--- +id: "02-backend-expiration-service" +title: "Backend expiration service" +status: done +wave: 2 +depends_on: ["01-backend-candidate-query"] +plan: "plan.md" +spec: "../../specs/tasks/quick-chat-expiration.md" +--- + +# Task 02: Backend Expiration Service + +## Acceptance + +- The task service runs a daily quick-chat expiration loop with a hardcoded 7-day idle retention window. +- Each expired candidate is deleted through `Service.DeleteTask`, preserving existing workspace directory cleanup and `task.deleted` publishing. +- Candidate-list errors delete nothing; individual delete failures are logged and do not stop later candidates. + +## Verification + +- `cd apps/backend && go test ./internal/task/service -run 'TestService_QuickChatExpiration|TestService_DeleteTask'` +- `cd apps/backend && go test ./internal/backendapp -run 'Test.*QuickChat'` if startup wiring gets focused coverage there. + +## Files likely touched + +- New `apps/backend/internal/task/service/quick_chat_expiration.go` +- `apps/backend/internal/task/service/service_test.go` +- Optional focused test file `apps/backend/internal/task/service/quick_chat_expiration_test.go` +- `apps/backend/internal/backendapp/main.go` + +## Dependencies + +- Task 01 must land first so the service can call `ListExpiredQuickChatTasks`. + +## Inputs + +- Spec sections: What, State machine, Failure modes, Persistence guarantees. +- Existing patterns: `StartAutoArchiveLoop` / `runAutoArchive` in `apps/backend/internal/task/service/auto_archive.go`; `DeleteTask` cleanup path in `apps/backend/internal/task/service/service_tasks.go`. + +## Output contract + +Update this task status to `done`, update the Wave 2 checkbox in `plan.md`, and report the files changed, tests run, and any cleanup/event behavior verified. diff --git a/docs/plans/quick-chat-expiration/task-03-boot-hydration.md b/docs/plans/quick-chat-expiration/task-03-boot-hydration.md new file mode 100644 index 0000000000..c3c9b79265 --- /dev/null +++ b/docs/plans/quick-chat-expiration/task-03-boot-hydration.md @@ -0,0 +1,46 @@ +--- +id: "03-boot-hydration" +title: "Boot hydration" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/tasks/quick-chat-expiration.md" +--- + +# Task 03: Boot Hydration + +## Acceptance + +- SPA boot payload includes `initialState.quickChat.sessions` for the active workspace whenever an active workspace can be resolved. +- Restored sessions exclude config-mode and automation-run ephemeral tasks, require a primary session, and are ordered by newest last activity first. +- Frontend hydration keeps existing local quick-chat rename overlays and remains type-correct. + +## Verification + +- `cd apps/backend && go test ./internal/backendapp -run 'TestBoot.*QuickChat|TestBootPayload'` +- `cd apps && pnpm --filter @kandev/web test -- --run lib/state/hydration/hydrator.test.ts src/boot-payload.test.ts` +- `cd apps/web && pnpm run typecheck` + +## Files likely touched + +- `apps/backend/internal/backendapp/boot_state.go` +- `apps/backend/internal/backendapp/boot_state_routes.go` +- `apps/backend/internal/backendapp/helpers_test.go` +- `apps/web/lib/api/domains/workspace-api.ts` +- `apps/web/app/page.tsx` +- `apps/web/lib/state/hydration/hydrator.test.ts` +- Optional `apps/web/src/boot-payload.test.ts` + +## Dependencies + +None. This can proceed in parallel with Task 01 because it can use existing `ListTasksByWorkspace` plus session enrichment. + +## Inputs + +- Spec sections: API surface, Persistence guarantees, Scenarios. +- Existing patterns: home/tasks boot-state helpers in `apps/backend/internal/backendapp/boot_state.go` and `boot_state_routes.go`; quick-chat local rename overlay in `apps/web/lib/state/hydration/hydrator.ts`. + +## Output contract + +Update this task status to `done`, update the Wave 1 checkbox in `plan.md`, and report the files changed, tests run, and any routes where quick-chat hydration intentionally no-ops. diff --git a/docs/plans/quick-chat-expiration/task-04-e2e-and-verification.md b/docs/plans/quick-chat-expiration/task-04-e2e-and-verification.md new file mode 100644 index 0000000000..4c83af4879 --- /dev/null +++ b/docs/plans/quick-chat-expiration/task-04-e2e-and-verification.md @@ -0,0 +1,43 @@ +--- +id: "04-e2e-and-verification" +title: "E2E and verification" +status: pending +wave: 3 +depends_on: ["02-backend-expiration-service", "03-boot-hydration"] +plan: "plan.md" +spec: "../../specs/tasks/quick-chat-expiration.md" +--- + +# Task 04: E2E and Verification + +## Acceptance + +- Quick-chat E2E coverage proves a reload restores at least one existing quick-chat tab with prior history. +- Multi-tab reload behavior is covered without adding a count cap. +- Final verification runs targeted backend/frontend tests plus format/typecheck/lint commands appropriate for changed files. + +## Verification + +- `cd apps/web && pnpm e2e -- tests/chat/quick-chat.spec.ts` +- `make -C apps/backend test` +- `cd apps/web && pnpm run typecheck` +- `cd apps && pnpm --filter @kandev/web lint` + +## Files likely touched + +- `apps/web/e2e/tests/chat/quick-chat.spec.ts` +- Test helpers only if the reload flow needs a reusable selector/helper. + +## Dependencies + +- Task 02 for backend expiration coverage. +- Task 03 for reload hydration behavior. + +## Inputs + +- Spec scenarios for reload restoration and 12-tab/no-cap behavior. +- Existing quick-chat E2E helpers in `apps/web/e2e/tests/chat/quick-chat.spec.ts`. + +## Output contract + +Update this task status to `done`, update the Wave 3 checkbox in `plan.md`, and report tests run, any skipped checks, and residual risks. diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index 5bd7e90755..8410e9467c 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -49,6 +49,7 @@ Kandev's task model: documents, execution stages, labels, blocker escalation, su | [runtime-cleanup](tasks/runtime-cleanup.md) | draft | | [link-existing-task-github-issue](tasks/link-existing-task-github-issue.md) | building | | [multi-branch](tasks/multi-branch/spec.md) | shipped | +| [quick-chat-expiration](tasks/quick-chat-expiration.md) | draft | ## agents/ — agent governance diff --git a/docs/specs/tasks/quick-chat-expiration.md b/docs/specs/tasks/quick-chat-expiration.md new file mode 100644 index 0000000000..d620eac80d --- /dev/null +++ b/docs/specs/tasks/quick-chat-expiration.md @@ -0,0 +1,168 @@ +--- +status: draft +created: 2026-07-02 +owner: José Almeida +--- + +# Quick Chat Persistence & Expiration + +## Why +A user reported her quick chats "disappearing much faster." Quick chats are +ephemeral tasks whose open-tab list lives only in the in-memory frontend store, +so a page reload drops every tab even though the underlying task, session, and +workspace directory still exist on the backend. Those now-unreachable rows and +directories never get cleaned up, so they leak indefinitely. Users want their +recent quick chats to survive a reload, and the system needs a bound so +abandoned quick chats don't accumulate forever. + +## What +- Open quick-chat tabs SHALL be restored after a full page reload, reconstructed + from backend state rather than client-only memory. +- A quick chat that has been idle longer than the retention window SHALL be + deleted automatically (task row, its sessions, and its workspace directory), + the same as an explicit close. +- The default retention window is **7 days** of inactivity, applied per quick + chat based on its last activity. The window is a hardcoded constant; it is not + user-configurable in this iteration. +- Expiration SHALL reuse the existing quick-chat delete path so that workspace + directory cleanup and task-lifecycle events fire exactly as they do for a + user-initiated close. +- Only quick chats expire. Config-mode chats, automation-run ephemeral tasks, + and non-ephemeral kanban/office tasks are untouched. +- A quick chat that is actively in use (recent message or running session) + SHALL NOT expire, regardless of how old it is. + +## Data model +No new tables. This feature operates on existing `tasks` and `task_sessions` +rows. A **quick chat** is precisely the set of tasks matching: + +``` +tasks + is_ephemeral = 1 + workflow_id = '' -- ephemeral tasks carry no workflow + origin != 'automation_run' -- excludes run-mode ephemeral tasks + metadata.config_mode IS NOT 1 -- excludes config-mode settings chats + archived_at IS NULL +``` + +This mirrors the existing list filters: `onlyEphemeral = true` plus the +`excludeConfigModePredicate` (`json_extract(metadata,'$.config_mode') IS NOT 1`, +`internal/task/repository/sqlite/task.go:62`) plus an `origin != 'automation_run'` +guard. The `origin` guard distinguishes user-started quick chats (`origin = +'manual'`) from automation run-mode ephemeral tasks. + +**Last-activity timestamp** for a quick chat is defined as: + +``` +last_activity = MAX(task.updated_at, MAX(session.updated_at) over its task_sessions) +``` + +Using the greater of the task row and its newest session row makes the metric +robust to whichever row a message/turn bumps. A quick chat expires when +`now - last_activity > 7 days`. + +## API surface +- **Hydration (read):** reuse the existing workspace task listing rather than a + new endpoint. The frontend fetches quick chats via + `ListTasksByWorkspace(workspaceId, workflowID="", …, onlyEphemeral=true, + excludeConfig=true)` and filters out `origin == "automation_run"`, then + rebuilds the `quickChat.sessions` store slice (one tab per returned task, in + most-recently-active-first order). Each restored tab needs + `{ sessionId, workspaceId, agentProfileId }`, all resolvable from the task and + its primary session. The persisted display name is already available via + `getStoredQuickChatName` / `setStoredQuickChatName`. +- **Deletion (expiration):** no new external contract. Expiration runs as a + backend background sweep that calls the existing `Service.DeleteTask` path + (which invokes `cleanupQuickChatDirs`, `internal/task/service/service_tasks.go:1402`, + and publishes `task.deleted`). Deleted quick chats propagate to any connected + client through the existing `task.deleted` WS handler. + +## State machine +A quick chat task is created `active`, and reaches a terminal `deleted` state via +one of these triggers (this feature adds only the last one): + +| Trigger | Actor | Existing? | +|---|---|---| +| User closes a tab (confirm) | user | yes (`use-quick-chat-modal.ts` `handleConfirmClose`) | +| Rapid re-pick supersedes an in-flight start | frontend | yes (`useAgentSelection` orphan cleanup) | +| Agent profile deleted | user | yes (`DeleteEphemeralTasksByAgentProfile`) | +| **Idle longer than retention window** | **background sweep** | **new** | + +All four converge on `Service.DeleteTask`, so directory cleanup and event +publishing are identical. + +## Failure modes +- **Last-activity query fails / returns error:** the sweep fails closed — it + deletes nothing that pass and logs a warning. A transient DB error must never + cause an over-broad delete. +- **A single quick chat delete fails** (dir busy, session stop error): the sweep + logs the failure, leaves that task in place, and continues with the rest. One + bad row does not abort the batch. The next sweep retries it. +- **Hydration list query fails on boot:** quick chat tabs render empty (current + behavior); the app is otherwise unaffected. No error toast is required — this + degrades to today's behavior. +- **A hydrated tab's backend session is already stopped/gone** (e.g. backend was + restarted between reloads): the tab still restores and shows its history; + sending a new message follows the normal resume/relaunch path. A tab whose + task no longer exists is simply not returned by the list and thus not shown. +- **Clock skew / non-monotonic time:** expiration compares stored timestamps to + `now`; it does not assume monotonic wall clock beyond the 7-day granularity. + +## Persistence guarantees +- **Survives reload:** open quick-chat tabs (restored from backend), quick-chat + task rows, sessions, and workspace directories — until they expire or are + closed. +- **Survives backend restart:** quick-chat task rows and directories (subject to + the same 7-day idle expiration). Tab restoration on the client depends only on + the backend list, so it works across a backend restart as long as the task row + still exists. +- **Does NOT survive:** a quick chat idle > 7 days (deleted on the next sweep). + The in-memory `quickChat.sessions` slice remains client-only and is rebuilt + from the backend on each load rather than persisted to `localStorage`. +- **Retention window:** 7 days of inactivity, hardcoded. Sweep cadence is an + implementation detail (piggy-backing on an existing periodic job is + acceptable) but SHALL run at least daily so expiry is observed within roughly + a day of the window elapsing. + +## Scenarios +- **GIVEN** an open quick chat with one or more tabs, **WHEN** the user reloads + the page, **THEN** the same tabs reappear, ordered most-recently-active first, + each showing its prior chat history and persisted name. +- **GIVEN** a quick-chat task whose `last_activity` is 8 days ago, **WHEN** the + expiration sweep runs, **THEN** the task, its sessions, and its workspace + directory are deleted, a `task.deleted` event is published, and the tab + disappears from any connected client. +- **GIVEN** a quick-chat task with a message sent 10 minutes ago (older than 7 + days since creation but recently active), **WHEN** the sweep runs, **THEN** the + task is retained. +- **GIVEN** a config-mode chat (`metadata.config_mode = 1`) idle for 30 days, + **WHEN** the sweep runs, **THEN** it is NOT deleted. +- **GIVEN** an automation-run ephemeral task (`origin = 'automation_run'`) idle + for 30 days, **WHEN** the sweep runs, **THEN** it is NOT deleted. +- **GIVEN** a non-ephemeral kanban task idle for a year, **WHEN** the sweep runs, + **THEN** it is NOT deleted. +- **GIVEN** the last-activity query errors, **WHEN** the sweep runs, **THEN** no + quick chats are deleted and a warning is logged. +- **GIVEN** two idle quick chats where deleting the first fails, **WHEN** the + sweep runs, **THEN** the second is still evaluated and deleted, and the failure + on the first is logged. +- **GIVEN** the workspace has 12 quick chats within the retention window, **WHEN** + the user reloads, **THEN** all 12 tabs restore (no count cap in this + iteration). + +## Out of scope +- A user-configurable retention window (per-workspace or global setting). Ships + as a hardcoded 7-day constant; revisit only if requested. +- A hard count cap on number of quick chats (idle TTL only). If clutter becomes + a problem, a cap is a follow-up. +- Persisting quick-chat tabs to `localStorage`. Backend is the source of truth; + no client-side durable store. +- Archiving/soft-deleting expired quick chats — they are hard-deleted, matching + the existing close behavior. +- Changing config-mode chat, automation-run, or kanban/office task lifecycles. + +## Open questions +- Does a message/turn in a quick chat currently bump `tasks.updated_at`, or only + `task_sessions.updated_at` / turn rows? The `MAX(task, session)` definition is + chosen to be correct either way, but the implementation should confirm which + row actually moves so the sweep query reads the right column(s).