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
This commit is contained in:
José Almeida
2026-07-08 12:11:28 +01:00
committed by GitHub
parent 8b90ec2593
commit 1ed2c59997
27 changed files with 1492 additions and 17 deletions
@@ -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,
@@ -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,
+1
View File
@@ -700,6 +700,7 @@ func startGatewayAndServe(
}
services.Task.StartAutoArchiveLoop(ctx)
services.Task.StartQuickChatExpirationLoop(ctx)
// ============================================
// SYSTEM PAGES
@@ -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))
+11
View File
@@ -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')
@@ -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
}
@@ -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
@@ -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")
@@ -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.
@@ -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 '$.<key>' path literal), so it must be
@@ -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()
@@ -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
@@ -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
@@ -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))
}
}
@@ -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,
@@ -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()
+22 -9
View File
@@ -60,6 +60,27 @@ function readAgentProfileId(
return typeof value === "string" ? value : undefined;
}
type QuickChatTask = Awaited<ReturnType<typeof listQuickChatSessions>>["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,
@@ -121,6 +121,8 @@ export async function listQuickChatSessions(workspaceId: string, options?: ApiRe
workspace_id: string;
primary_session_id?: string | null;
metadata?: Record<string, unknown> | null;
origin?: string | null;
updated_at?: string;
}>;
}>(`/api/v1/workspaces/${workspaceId}/tasks?only_ephemeral=true&exclude_config=true`, options);
}
+16 -1
View File
@@ -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>): 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>): DefaultState {
if (!initialState) return defaultState;
@@ -220,7 +235,7 @@ export function mergeInitialState(initialState?: Partial<DefaultState>): 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 },
+51 -1
View File
@@ -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<AppState>) => {
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", () => {
+195
View File
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
+1
View File
@@ -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
+168
View File
@@ -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).