feat: show red interrupted icon for tasks cut short by a restart (#2358)

This commit is contained in:
Clément Fiere
2026-08-07 18:17:57 +02:00
committed by GitHub
parent 55c5c349ec
commit cd5199cd14
45 changed files with 1657 additions and 141 deletions
@@ -572,6 +572,7 @@ func mapKanbanTaskState(task taskdto.TaskDTO) map[string]any {
"primarySessionState": task.PrimarySessionState,
"primarySessionPendingAction": task.PrimarySessionPendingAction,
"taskPendingAction": task.TaskPendingAction,
"interrupted": task.Interrupted,
"statusSummary": task.StatusSummary,
"sessionCount": task.SessionCount,
"reviewStatus": nullString(string(task.ReviewStatus)),
@@ -836,6 +836,12 @@ func (s *Service) updateTaskSessionStateWithHook(
if onChanged != nil {
onChanged()
}
// Work has resumed: a session entering STARTING/RUNNING clears the
// startup interruption marker and republishes the task so open clients
// drop the red interruption icon. No-op when the marker is absent.
if nextState == models.TaskSessionStateStarting || nextState == models.TaskSessionStateRunning {
s.clearTaskInterruptedMarker(ctx, taskID)
}
if authoritativeUpdatedAt == nil {
s.logger.Warn("skipping session state_changed publish; could not read authoritative updated_at",
zap.String("task_id", taskID),
@@ -1412,12 +1418,46 @@ func (s *Service) setSessionStarting(
s.writeTaskInProgressForRuntime(ctx, taskID, session.ID)
}
// The launch path moves a session to STARTING without going through
// updateTaskSessionStateWithHook, so clear the interruption marker here
// too (no-op when absent).
s.clearTaskInterruptedMarker(ctx, taskID)
if publishSession != nil {
s.publishTaskSessionStateChanged(ctx, taskID, session.ID, oldState, session.State, session.ErrorMessage, stateUpdatedAt, publishSession)
}
return nil
}
// clearTaskInterruptedMarker removes the startup interruption marker from a
// task and republishes task.updated when it was actually present, so open
// clients drop the red interruption icon. Called from the session-start
// funnel when a session enters STARTING/RUNNING — work has resumed, so the
// task is no longer interrupted. No-op when the marker is absent.
func (s *Service) clearTaskInterruptedMarker(ctx context.Context, taskID string) {
if taskID == "" {
return
}
removed, err := s.repo.RemoveTaskMetadataKey(ctx, taskID, models.MetaKeyInterruptedAt)
if err != nil {
s.logger.Warn("failed to clear interrupted marker",
zap.String("task_id", taskID),
zap.Error(err))
return
}
if !removed {
return
}
task, err := s.repo.GetTask(ctx, taskID)
if err != nil || task == nil {
s.logger.Warn("failed to load task for interrupted-clear publish",
zap.String("task_id", taskID),
zap.Error(err))
return
}
s.publishTaskUpdated(ctx, task)
}
func (s *Service) persistFullTaskSessionIfCurrent(
ctx context.Context,
session *models.TaskSession,
@@ -2100,6 +2100,97 @@ func TestSetSessionStartingCanDeferTaskInProgress(t *testing.T) {
require.Empty(t, taskRepo.stateWrites)
}
// recordingTaskUpdatedPublisher captures task.updated publishes so tests can
// assert the interrupted-marker clear republishes the task.
type recordingTaskUpdatedPublisher struct {
updatedTaskIDs []string
}
func (r *recordingTaskUpdatedPublisher) PublishTaskUpdated(_ context.Context, task *models.Task, _ ...string) {
if task != nil {
r.updatedTaskIDs = append(r.updatedTaskIDs, task.ID)
}
}
func (r *recordingTaskUpdatedPublisher) PublishTaskStateChanged(context.Context, *models.Task, v1.TaskState) {
}
func (r *recordingTaskUpdatedPublisher) PublishTaskActivityIfChanged(context.Context, string) {}
func seedInterruptedMarker(t *testing.T, repo *sqliterepo.Repository, taskID string) {
t.Helper()
if err := repo.SetTaskMetadataKey(context.Background(), taskID, models.MetaKeyInterruptedAt, time.Now().UTC().Format(time.RFC3339)); err != nil {
t.Fatalf("seed interrupted marker: %v", err)
}
}
func TestSessionStartClearsInterruptedMarker(t *testing.T) {
ctx := context.Background()
t.Run("state hook transition to STARTING clears and republishes", func(t *testing.T) {
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "step1")
seedInterruptedMarker(t, repo, "t1")
publisher := &recordingTaskUpdatedPublisher{}
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
svc.SetTaskEventPublisher(publisher)
updated, changed := svc.updateTaskSessionStateWithHook(
ctx, "t1", "s1", models.TaskSessionStateStarting, "", false, nil,
)
require.NotNil(t, updated)
require.True(t, changed)
task, err := repo.GetTask(ctx, "t1")
require.NoError(t, err)
_, marked := task.Metadata[models.MetaKeyInterruptedAt]
require.False(t, marked, "marker must be cleared when the session enters STARTING")
require.Equal(t, []string{"t1"}, publisher.updatedTaskIDs,
"task.updated must be republished after clearing the marker")
})
t.Run("launch path via setSessionStarting clears and republishes", func(t *testing.T) {
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "step1")
seedInterruptedMarker(t, repo, "t1")
session, err := repo.GetTaskSession(ctx, "s1")
require.NoError(t, err)
session.State = models.TaskSessionStateStarting
session.ErrorMessage = ""
session.UpdatedAt = time.Now().UTC()
publisher := &recordingTaskUpdatedPublisher{}
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
svc.SetTaskEventPublisher(publisher)
require.NoError(t, svc.setSessionStarting(ctx, "t1", session, models.TaskSessionStateRunning, true))
task, err := repo.GetTask(ctx, "t1")
require.NoError(t, err)
_, marked := task.Metadata[models.MetaKeyInterruptedAt]
require.False(t, marked, "marker must be cleared on the launch path")
require.Equal(t, []string{"t1"}, publisher.updatedTaskIDs,
"task.updated must be republished after clearing the marker")
})
t.Run("no marker means no republish", func(t *testing.T) {
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "step1")
publisher := &recordingTaskUpdatedPublisher{}
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
svc.SetTaskEventPublisher(publisher)
updated, changed := svc.updateTaskSessionStateWithHook(
ctx, "t1", "s1", models.TaskSessionStateStarting, "", false, nil,
)
require.NotNil(t, updated)
require.True(t, changed)
require.Empty(t, publisher.updatedTaskIDs,
"no task.updated may be published when no marker was removed")
})
}
func TestSetSessionStartingRejectsTerminalSession(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
@@ -246,6 +246,16 @@ type sessionExecutorStore interface {
// than an idempotent same-owner observation.
ClaimTaskTitleSession(ctx context.Context, taskID, sessionID string) (owned bool, newlyClaimed bool, err error)
UpdateTask(ctx context.Context, task *models.Task) error
// SetTaskMetadataKey / RemoveTaskMetadataKey are concurrent-key-safe JSON
// patch helpers on tasks.metadata (implemented by the sqlite/Postgres
// repository). Used by startup reconciliation to mark interrupted tasks
// and by the session-start funnel to clear the marker.
SetTaskMetadataKey(ctx context.Context, taskID, key string, value interface{}) error
// SetTaskMetadataKeyIfNotArchived writes one metadata key only when the
// task row still has archived_at IS NULL (single statement — the archive
// check and the write cannot be separated by a concurrent archive).
SetTaskMetadataKeyIfNotArchived(ctx context.Context, taskID, key string, value interface{}) (bool, error)
RemoveTaskMetadataKey(ctx context.Context, taskID, key string) (bool, error)
ListChildCompletionRows(ctx context.Context, parentID string) ([]models.ChildCompletionRow, error)
// Git snapshots and commits
GetLatestGitSnapshot(ctx context.Context, sessionID string) (*models.GitSnapshot, error)
@@ -1811,6 +1821,21 @@ func (s *Service) reconcileOneSessionOnStartup(ctx context.Context, running *mod
}
}
// Mark the task interrupted when its session was mid-turn when the backend
// died (STARTING/RUNNING) so task-list surfaces can show the red
// interruption icon. WAITING_FOR_INPUT sessions were idle, not interrupted.
// The write is archive-atomic (SetTaskMetadataKeyIfNotArchived), so an
// archive that commits after this check cannot leave a stale marker on an
// archived task. The marker is cleared when a session of the task next
// enters STARTING/RUNNING (see updateTaskSessionStateWithHook).
if running.TaskID != "" && (previousState == models.TaskSessionStateStarting || previousState == models.TaskSessionStateRunning) {
if _, setErr := s.repo.SetTaskMetadataKeyIfNotArchived(ctx, running.TaskID, models.MetaKeyInterruptedAt, time.Now().UTC().Format(time.RFC3339)); setErr != nil {
s.logger.Warn("failed to mark task interrupted on startup",
zap.String("task_id", running.TaskID),
zap.Error(setErr))
}
}
// PRESERVE the ExecutorRunning record — it holds the resume token and worktree info
// needed for lazy recovery when the user opens the session. But make the row
// TRUE: after a restart the spawned process is normally gone, so if the local
@@ -6382,6 +6382,84 @@ func TestReconcileSessionsOnStartup(t *testing.T) {
t.Fatalf("expected REVIEW write to use UpdateTaskStateIfCurrentIn, got %d unconditional UpdateTaskState call(s)", n)
}
})
// Interrupted-task marker: sessions that were mid-turn (STARTING/RUNNING)
// when the backend died must mark their task with the interrupted_at
// metadata key so the task DTO reports interrupted and the task list can
// show the red icon. Idle and archived tasks must never be marked.
for _, tc := range []struct {
name string
sessionState models.TaskSessionState
wantInterrupt bool
}{
{"running_session_marks_task_interrupted", models.TaskSessionStateRunning, true},
{"starting_session_marks_task_interrupted", models.TaskSessionStateStarting, true},
{"waiting_for_input_session_not_marked", models.TaskSessionStateWaitingForInput, false},
} {
t.Run(tc.name, func(t *testing.T) {
repo := setupTestRepo(t)
ctx := context.Background()
now := time.Now().UTC()
seedTaskAndSession(t, repo, "task1", "session1", tc.sessionState)
err := repo.UpsertExecutorRunning(ctx, &models.ExecutorRunning{
ID: "er1",
SessionID: "session1",
TaskID: "task1",
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
t.Fatalf("failed to upsert executor running: %v", err)
}
svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), &mockAgentManager{})
svc.reconcileSessionsOnStartup(ctx)
task, err := repo.GetTask(ctx, "task1")
if err != nil {
t.Fatalf("failed to load task: %v", err)
}
_, marked := task.Metadata[models.MetaKeyInterruptedAt]
if marked != tc.wantInterrupt {
t.Fatalf("interrupted_at marker present=%v, want %v", marked, tc.wantInterrupt)
}
})
}
t.Run("archived_running_session_not_marked", func(t *testing.T) {
repo := setupTestRepo(t)
ctx := context.Background()
now := time.Now().UTC()
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateRunning)
if err := repo.ArchiveTask(ctx, "task1"); err != nil {
t.Fatalf("failed to archive task: %v", err)
}
err := repo.UpsertExecutorRunning(ctx, &models.ExecutorRunning{
ID: "er1",
SessionID: "session1",
TaskID: "task1",
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
t.Fatalf("failed to upsert executor running: %v", err)
}
svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), &mockAgentManager{})
svc.reconcileSessionsOnStartup(ctx)
task, err := repo.GetTask(ctx, "task1")
if err != nil {
t.Fatalf("failed to load task: %v", err)
}
if _, marked := task.Metadata[models.MetaKeyInterruptedAt]; marked {
t.Fatal("archived task must not be marked interrupted")
}
})
}
// --- ensureSessionRunning: prepared workspace ---
@@ -133,3 +133,49 @@ func TestFromTaskSession_IncludesAllWorktrees(t *testing.T) {
t.Fatalf("summary WorkspacePath = %q, want /task-root", summary.WorkspacePath)
}
}
func TestFromTask_DerivesInterruptedFromMetadata(t *testing.T) {
now := time.Now().UTC()
t.Run("marked", func(t *testing.T) {
task := &models.Task{
ID: "t1",
CreatedAt: now,
UpdatedAt: now,
Metadata: map[string]interface{}{
models.MetaKeyInterruptedAt: "2026-08-02T10:00:00Z",
},
}
got := FromTask(task)
if !got.Interrupted {
t.Fatal("Interrupted = false, want true when interrupted_at metadata is present")
}
})
t.Run("unmarked", func(t *testing.T) {
task := &models.Task{ID: "t1", CreatedAt: now, UpdatedAt: now}
got := FromTask(task)
if got.Interrupted {
t.Fatal("Interrupted = true, want false when interrupted_at metadata is absent")
}
})
}
func TestTaskToAPI_DerivesInterruptedFromMetadata(t *testing.T) {
now := time.Now().UTC()
marked := (&models.Task{
ID: "t1",
CreatedAt: now,
UpdatedAt: now,
Metadata: map[string]interface{}{models.MetaKeyInterruptedAt: "2026-08-02T10:00:00Z"},
}).ToAPI()
if !marked.Interrupted {
t.Fatal("ToAPI Interrupted = false, want true when interrupted_at metadata is present")
}
unmarked := (&models.Task{ID: "t1", CreatedAt: now, UpdatedAt: now}).ToAPI()
if unmarked.Interrupted {
t.Fatal("ToAPI Interrupted = true, want false when interrupted_at metadata is absent")
}
}
+5
View File
@@ -182,6 +182,10 @@ type TaskDTO struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
// Interrupted reports that the task's session was mid-turn when the backend
// died and has not been resumed since. Derived from the interrupted_at
// metadata key at DTO conversion time (see FromTaskWithSessionInfo).
Interrupted bool `json:"interrupted,omitempty"`
// Office extensions
AssigneeAgentProfileID string `json:"assignee_agent_profile_id,omitempty"`
@@ -757,6 +761,7 @@ func FromTaskWithSessionInfo(
CreatedAt: task.CreatedAt,
UpdatedAt: task.UpdatedAt,
Metadata: task.Metadata,
Interrupted: task.Metadata[models.MetaKeyInterruptedAt] != nil,
// Office extensions. AssigneeAgentProfileID is a read-time
// projection from workflow_step_participants (ADR 0005 Wave F);
// the repo's task SELECTs hydrate it via a correlated subquery.
@@ -110,6 +110,12 @@ const (
// because the winner will (or already did) handle it.
// Absent on ordinary (non-watcher) auto-start tasks, which launch normally.
MetaKeyAutoStartClaimed = "auto_start_claimed"
// MetaKeyInterruptedAt is set by startup reconciliation when a task's
// session was mid-turn (STARTING/RUNNING) when the backend died. Its
// presence makes the task DTO report `interrupted: true` so task-list
// surfaces show the red interruption icon; the orchestrator removes the
// key when a session of the task next enters STARTING/RUNNING.
MetaKeyInterruptedAt = "interrupted_at"
// MetaKeyAgentTitlePending marks tasks created in prompt-first mode whose
// provisional title still needs the first eligible agent session to replace it.
MetaKeyAgentTitlePending = "agent_title_pending"
@@ -1798,6 +1804,7 @@ func (t *Task) ToAPI() *v1.Task {
CreatedAt: t.CreatedAt,
UpdatedAt: t.UpdatedAt,
Metadata: t.Metadata,
Interrupted: t.Metadata[MetaKeyInterruptedAt] != nil,
IsEphemeral: t.IsEphemeral,
ParentID: t.ParentID,
}
@@ -653,6 +653,35 @@ func (r *Repository) SetTaskMetadataKey(ctx context.Context, taskID, key string,
return err
}
// SetTaskMetadataKeyIfNotArchived writes one metadata key atomically with the
// archived_at guard: the write only lands when the task row still has
// archived_at IS NULL, and reports whether it landed. Startup reconciliation
// uses it for the interrupted_at marker so an archive that commits between the
// guard read and the metadata write can never leave a marker on an archived
// task (the check and the write are one statement).
func (r *Repository) SetTaskMetadataKeyIfNotArchived(ctx context.Context, taskID, key string, value interface{}) (bool, error) {
payload, err := json.Marshal(value)
if err != nil {
return false, err
}
var query string
if dialect.IsPostgres(r.db.DriverName()) {
query = `UPDATE tasks SET metadata = jsonb_set(CASE WHEN metadata IS NULL OR metadata = 'null' OR metadata = '' THEN '{}'::jsonb ELSE metadata::jsonb END, ARRAY[?]::text[], ?::jsonb, true)::text, updated_at = ? WHERE id = ? AND archived_at IS NULL`
} else {
query = `UPDATE tasks SET metadata = json_set(CASE WHEN metadata IS NULL OR metadata = 'null' OR metadata = '' THEN '{}' ELSE metadata END, ?, json(?)), updated_at = ? WHERE id = ? AND archived_at IS NULL`
}
path := key
if !dialect.IsPostgres(r.db.DriverName()) {
path = jsonPath(key)
}
result, err := r.db.ExecContext(ctx, r.db.Rebind(query), path, string(payload), time.Now().UTC(), taskID)
if err != nil {
return false, err
}
rows, err := result.RowsAffected()
return rows > 0, err
}
func jsonPath(key string) string { return "$." + key }
func agentTitlePendingPredicate(driver string) string {
@@ -287,3 +287,50 @@ func TestTaskMetadataKeyHelpersRoundTripNestedValue(t *testing.T) {
t.Fatalf("deferred launch metadata still present: %#v", got.Metadata)
}
}
// TestSetTaskMetadataKeyIfNotArchivedSkipsArchivedTasks pins the archive-atomic
// contract of the interrupted-marker write: an archive that commits between a
// guard read and the metadata write must not leave a marker on an archived
// task, so the conditional write itself must be the guard (the check and the
// write are one statement).
func TestSetTaskMetadataKeyIfNotArchivedSkipsArchivedTasks(t *testing.T) {
repo, cleanup := createTestSQLiteRepo(t)
defer cleanup()
ctx := context.Background()
live := &models.Task{ID: "live-task", WorkspaceID: "metadata-workspace", Title: "Live"}
if err := repo.CreateTask(ctx, live); err != nil {
t.Fatalf("create live task: %v", err)
}
archived := &models.Task{ID: "archived-task", WorkspaceID: "metadata-workspace", Title: "Archived"}
if err := repo.CreateTask(ctx, archived); err != nil {
t.Fatalf("create archived task: %v", err)
}
if err := repo.ArchiveTask(ctx, archived.ID); err != nil {
t.Fatalf("archive task: %v", err)
}
changed, err := repo.SetTaskMetadataKeyIfNotArchived(ctx, live.ID, models.MetaKeyInterruptedAt, "2026-08-07T00:00:00Z")
if err != nil || !changed {
t.Fatalf("live task write changed=%t err=%v, want true/nil", changed, err)
}
changed, err = repo.SetTaskMetadataKeyIfNotArchived(ctx, archived.ID, models.MetaKeyInterruptedAt, "2026-08-07T00:00:00Z")
if err != nil || changed {
t.Fatalf("archived task write changed=%t err=%v, want false/nil", changed, err)
}
got, err := repo.GetTask(ctx, archived.ID)
if err != nil {
t.Fatalf("reload archived task: %v", err)
}
if _, marked := got.Metadata[models.MetaKeyInterruptedAt]; marked {
t.Fatalf("archived task must not carry the interrupted marker: %#v", got.Metadata)
}
got, err = repo.GetTask(ctx, live.ID)
if err != nil {
t.Fatalf("reload live task: %v", err)
}
if _, marked := got.Metadata[models.MetaKeyInterruptedAt]; !marked {
t.Fatalf("live task must carry the interrupted marker: %#v", got.Metadata)
}
}
+8 -3
View File
@@ -148,9 +148,14 @@ type Task struct {
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
IsEphemeral bool `json:"is_ephemeral"` // Ephemeral tasks are not shown in kanban, used for quick chat
ParentID string `json:"parent_id,omitempty"` // FK to parent task for subtasks
Identifier string `json:"identifier,omitempty"`
// Interrupted reports that the task's session was mid-turn (STARTING/RUNNING)
// when the backend died and has not been resumed since. Derived from the
// interrupted_at metadata key at DTO conversion time; the orchestrator
// clears it when a session of the task next enters STARTING/RUNNING.
Interrupted bool `json:"interrupted,omitempty"`
IsEphemeral bool `json:"is_ephemeral"` // Ephemeral tasks are not shown in kanban, used for quick chat
ParentID string `json:"parent_id,omitempty"` // FK to parent task for subtasks
Identifier string `json:"identifier,omitempty"`
}
// TaskRepositoryInput for creating/updating task repositories
@@ -116,6 +116,7 @@ function simpleTaskHeaderData(task: Task | null) {
title: task?.title ?? t("tasks:loading"),
state: task?.state ?? null,
foregroundActivity: task?.foreground_activity,
interrupted: task?.interrupted ?? false,
};
}
@@ -130,6 +131,7 @@ function SimpleTaskHeaderRow({ task }: { task: Task | null }) {
foregroundActivity={data.foregroundActivity}
hasPendingClarification={pendingInput.clarification}
hasPendingPermission={pendingInput.permission}
interrupted={data.interrupted}
/>
<TaskHeader
identifier={data.identifier}
+6 -7
View File
@@ -35,13 +35,12 @@ function PrimaryTaskLine({
}) {
return (
<>
{getTaskStateIcon(
task.state,
"h-4 w-4 shrink-0",
pendingInput.clarification,
task.foreground_activity,
pendingInput.permission,
)}
{getTaskStateIcon(task.state, "h-4 w-4 shrink-0", {
hasPendingClarification: pendingInput.clarification,
foregroundActivity: task.foreground_activity,
hasPendingPermission: pendingInput.permission,
interrupted: task.interrupted,
})}
<span className="min-w-0 truncate font-medium" data-testid="tasks-list-row-title">
{task.title}
</span>
+6 -6
View File
@@ -284,9 +284,10 @@ export function renderTaskStatusIcon(
const showQuestionIcon = shouldUseQuestionTaskIcon(task.state, hasPendingClarification);
const showPermissionIcon = shouldUsePermissionTaskIcon(hasPendingPermission);
const needsMe = showQuestionIcon || showPermissionIcon;
const showInterrupted = !!task.interrupted;
const hasActivity =
task.foregroundActivity === "generating" || task.foregroundActivity === "background";
if (!showRunningSpinner && !needsMe && !hasActivity) {
if (!showRunningSpinner && !needsMe && !hasActivity && !showInterrupted) {
return null;
}
// A "needs me" prompt (pending clarification / permission) must not be masked
@@ -296,13 +297,12 @@ export function renderTaskStatusIcon(
if (showRunningSpinner && !needsMe && task.foregroundActivity !== "background") {
return <IconLoader2 className="h-4 w-4 text-blue-500 animate-spin" />;
}
return getTaskStateIcon(
task.state,
"h-4 w-4",
return getTaskStateIcon(task.state, "h-4 w-4", {
hasPendingClarification,
task.foregroundActivity,
foregroundActivity: task.foregroundActivity,
hasPendingPermission,
);
interrupted: showInterrupted,
});
}
// The board's only window into a fan-out. `activeSubagentCount` is derived from
+2
View File
@@ -63,6 +63,8 @@ export interface Task {
* affordance on the card status icon.
*/
foregroundActivity?: ForegroundActivity | null;
/** True when the task's session was mid-turn when the backend died. */
interrupted?: boolean;
/** Live subagents summed across this task's sessions; drives the count chip. */
activeSubagentCount?: number;
reviewStatus?: "pending" | "approved" | "changes_requested" | "rejected" | null;
@@ -146,13 +146,12 @@ export function Graph2StepNode({
>
<div className="flex items-center gap-1.5 w-full">
<div className="shrink-0">
{getTaskStateIcon(
task.state,
"h-3 w-3",
pendingInput.clarification,
task.foregroundActivity,
pendingInput.permission,
)}
{getTaskStateIcon(task.state, "h-3 w-3", {
hasPendingClarification: pendingInput.clarification,
foregroundActivity: task.foregroundActivity,
hasPendingPermission: pendingInput.permission,
interrupted: task.interrupted,
})}
</div>
<span className="text-[11px] font-medium text-foreground truncate">{step.title}</span>
</div>
@@ -81,13 +81,12 @@ function DraggableTaskChip({
primarySessionState: task.primarySessionState,
primarySessionPendingAction: task.primarySessionPendingAction,
});
const statusIcon = getTaskStateIcon(
task.state,
"h-3 w-3",
pendingInput.clarification,
task.foregroundActivity,
pendingInput.permission,
);
const statusIcon = getTaskStateIcon(task.state, "h-3 w-3", {
hasPendingClarification: pendingInput.clarification,
foregroundActivity: task.foregroundActivity,
hasPendingPermission: pendingInput.permission,
interrupted: task.interrupted,
});
return (
<button
@@ -119,13 +118,12 @@ function TaskChipPreview({ task }: { task: Task }) {
primarySessionState: task.primarySessionState,
primarySessionPendingAction: task.primarySessionPendingAction,
});
const statusIcon = getTaskStateIcon(
task.state,
"h-3 w-3",
pendingInput.clarification,
task.foregroundActivity,
pendingInput.permission,
);
const statusIcon = getTaskStateIcon(task.state, "h-3 w-3", {
hasPendingClarification: pendingInput.clarification,
foregroundActivity: task.foregroundActivity,
hasPendingPermission: pendingInput.permission,
interrupted: task.interrupted,
});
return (
<div
className={cn(
@@ -113,10 +113,9 @@ export function toSheetItem(
workspaceMode: task.workspaceMode,
state: task.state as TaskState | undefined,
...status,
// Task-level most-active-wins busy aggregate from the task record — the same
// authoritative value the desktop sidebar (toSidebarItem) and board read, so the
// mobile task-switcher row shows background-running and agrees with the board for
// multi-session tasks instead of missing it.
// Same interruption marker the desktop sidebar reads — the mobile
// task-switcher row shares TaskItem rendering.
interrupted: task.interrupted,
description: task.description,
workflowId: task._workflowId,
workflowName: ctx.workflowNameById.get(task._workflowId),
@@ -11,6 +11,7 @@ const RUNNING_ICON_TEST_ID = "task-state-running";
const BACKGROUND_ICON_TEST_ID = "task-state-background-running";
const WAITING_FOR_INPUT_ICON_TEST_ID = "task-state-waiting-for-input";
const PENDING_PERMISSION_ICON_TEST_ID = "task-state-pending-permission";
const INTERRUPTED_ICON_TEST_ID = "task-state-interrupted";
const AGENT_ERROR_ICON_TEST_ID = "task-agent-error-icon";
const PREPARING_PHASE = "preparing";
const DATA_LOADING_PHASE = "data-loading-phase";
@@ -166,6 +167,54 @@ describe("TaskItem status icon", () => {
});
});
describe("TaskItem interrupted icon", () => {
it("shows the red interrupted icon for a task marked interrupted after a restart", () => {
renderTaskItem({
state: "REVIEW",
sessionState: "WAITING_FOR_INPUT",
interrupted: true,
});
expect(screen.queryByTestId(INTERRUPTED_ICON_TEST_ID)).not.toBeNull();
expect(screen.queryByTestId(TURN_FINISHED_ICON_TEST_ID)).toBeNull();
expect(screen.queryByTestId(WORKFLOW_COMPLETE_ICON_TEST_ID)).toBeNull();
});
it("does not show the interrupted icon while the task is running again", () => {
renderTaskItem({
state: "REVIEW",
sessionState: "RUNNING",
interrupted: true,
});
expect(screen.queryByTestId(INTERRUPTED_ICON_TEST_ID)).toBeNull();
expect(screen.queryByTestId(RUNNING_ICON_TEST_ID)).not.toBeNull();
});
it("does not show the interrupted icon for a terminal task", () => {
renderTaskItem({
state: "COMPLETED",
sessionState: "COMPLETED",
interrupted: true,
});
expect(screen.queryByTestId(INTERRUPTED_ICON_TEST_ID)).toBeNull();
expect(screen.queryByTestId(TURN_FINISHED_ICON_TEST_ID)).not.toBeNull();
});
it("keeps pending-input affordances over the interrupted icon", () => {
renderTaskItem({
state: "REVIEW",
sessionState: "WAITING_FOR_INPUT",
hasPendingClarification: true,
interrupted: true,
});
expect(screen.queryByTestId(INTERRUPTED_ICON_TEST_ID)).toBeNull();
expect(screen.queryByTestId(WAITING_FOR_INPUT_ICON_TEST_ID)).not.toBeNull();
});
});
describe("TaskItem actions", () => {
it("keeps row-focus actions visible when no diff stats are available", () => {
renderTaskItem({ isSelected: true });
+21 -3
View File
@@ -22,7 +22,12 @@ import { TaskItemStatsRow } from "./task-item-stats-row";
import { useTaskColor } from "@/hooks/use-task-color";
import { TASK_COLOR_BAR_CLASS, type TaskColor } from "@/lib/task-colors";
import type { ForegroundActivity, TaskState, TaskSessionState } from "@/lib/types/http";
import { shouldUseQuestionTaskIcon, shouldUsePermissionTaskIcon } from "@/lib/ui/state-icons";
import {
InterruptedTaskIcon,
isTerminalInterruptedState,
shouldUseQuestionTaskIcon,
shouldUsePermissionTaskIcon,
} from "@/lib/ui/state-icons";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { RemoteCloudTooltip } from "./remote-cloud-tooltip";
import { classifyTask } from "./task-classify";
@@ -67,6 +72,8 @@ type TaskItemProps = {
primarySessionId?: string | null;
hasPendingClarification?: boolean;
hasPendingPermission?: boolean;
/** True when the task's session was mid-turn when the backend died. */
interrupted?: boolean;
parentTaskTitle?: string;
isSubTask?: boolean;
/** Whether the task is currently on the final ordered step of its workflow. */
@@ -185,6 +192,7 @@ function TaskStateIcon({
hasPendingClarification,
hasPendingPermission,
isOnLastWorkflowStep,
interrupted,
}: {
sessionState?: TaskSessionState;
state?: TaskState;
@@ -193,6 +201,8 @@ function TaskStateIcon({
hasPendingClarification?: boolean;
hasPendingPermission?: boolean;
isOnLastWorkflowStep?: boolean;
/** True when the task's session was mid-turn when the backend died. */
interrupted?: boolean;
}) {
if (shouldUsePermissionTaskIcon(hasPendingPermission)) {
return (
@@ -250,6 +260,13 @@ function TaskStateIcon({
/>
);
}
// The task's session was mid-turn when the backend died (startup
// reconciliation marker). Show the red interruption icon instead of the
// idle/done affordances; every active/pending state above already won, and
// terminal states keep their own icons.
if (interrupted && !isTerminalInterruptedState(state, sessionState)) {
return <InterruptedTaskIcon className="mt-[1px] h-3.5 w-3.5 shrink-0" />;
}
if (classifyTask(sessionState, state) === "review") {
if (isOnLastWorkflowStep) {
return (
@@ -430,6 +447,7 @@ export const TaskItem = memo(function TaskItem({
primarySessionId,
hasPendingClarification,
hasPendingPermission,
interrupted,
isSubTask,
depth,
subtaskCount,
@@ -445,7 +463,6 @@ export const TaskItem = memo(function TaskItem({
}: TaskItemProps) {
const effectiveMenuOpen = menuOpen || isDeleting === true;
const hasDiffStats = !!diffStats && (diffStats.additions > 0 || diffStats.deletions > 0);
const showSubtaskToggle = !!subtaskCount && subtaskCount > 0 && !!onToggleSubtasks;
const taskColor = useTaskColor(taskId);
const indent = computeRowIndent(resolveRowDepth(depth, isSubTask));
@@ -470,6 +487,7 @@ export const TaskItem = memo(function TaskItem({
isInProgress={computeIsInProgress(state, sessionState)}
hasPendingClarification={hasPendingClarification}
hasPendingPermission={hasPendingPermission}
interrupted={interrupted}
isOnLastWorkflowStep={isOnLastWorkflowStep}
/>
<TaskItemContent
@@ -498,7 +516,7 @@ export const TaskItem = memo(function TaskItem({
) : (
<TaskMenuButton visible={effectiveMenuOpen} expanded={menuOpen} rowFocus />
)}
{showSubtaskToggle && (
{!!subtaskCount && subtaskCount > 0 && !!onToggleSubtasks && (
<SubtaskToggle
taskId={taskId}
count={subtaskCount!}
@@ -76,6 +76,10 @@ function sidebarSessionStatus(
sessionState: (hasSummary ? primarySession?.state : task.primarySessionState) as
| TaskSessionState
| undefined,
// The task-level MOST-ACTIVE-WINS activity aggregate (ADR-0049) is
// authoritative for the sidebar row: when no status summary is available,
// fall back to the task record's own aggregate so multi-session and
// off-screen rows still agree with the board card.
foregroundActivity: hasSummary ? summary?.foreground_activity : task.foregroundActivity,
primarySessionId: hasSummary ? (primarySession?.id ?? null) : (task.primarySessionId ?? null),
updatedAt: hasSummary ? summary?.updated_at : (task.updatedAt ?? task.createdAt),
@@ -119,6 +123,7 @@ export function buildSidebarItem(
id: task.id,
title: task.title,
state: task.state as TaskState | undefined,
interrupted: task.interrupted,
...status,
description: task.description,
workflowId: task._workflowId,
@@ -16,6 +16,8 @@ type TaskStateActionsProps = {
hasPendingClarification?: boolean;
/** Message-derived pending-permission flag. */
hasPendingPermission?: boolean;
/** True when the task's session was mid-turn when the backend died. */
interrupted?: boolean;
};
export function TaskStateActions({
@@ -24,16 +26,16 @@ export function TaskStateActions({
foregroundActivity,
hasPendingClarification = false,
hasPendingPermission = false,
interrupted = false,
}: TaskStateActionsProps) {
return (
<div className="flex items-center justify-end">
{getTaskStateIcon(
state,
className,
{getTaskStateIcon(state, className, {
hasPendingClarification,
foregroundActivity,
hasPendingPermission,
)}
interrupted,
})}
</div>
);
}
@@ -89,6 +89,22 @@ describe("dispatchSidebarRowClick", () => {
expect(h.onSelectTask).toHaveBeenCalledWith("t1");
});
it("navigates on cmd-click when the toggle handler is absent (mobile switcher)", () => {
const h = { onSelectTask: vi.fn() };
const e = fakeEvent({ metaKey: true });
dispatchSidebarRowClick(e, "t1", false, h);
expect(e.preventDefault).not.toHaveBeenCalled();
expect(h.onSelectTask).toHaveBeenCalledWith("t1");
});
it("navigates on shift-click when the range handler is absent", () => {
const h = { onSelectTask: vi.fn() };
const e = fakeEvent({ shiftKey: true });
dispatchSidebarRowClick(e, "t1", false, h);
expect(e.preventDefault).not.toHaveBeenCalled();
expect(h.onSelectTask).toHaveBeenCalledWith("t1");
});
it("always navigates archived rows without entering selection mode", () => {
const h = handlers();
const e = fakeEvent({ metaKey: true, shiftKey: true });
@@ -141,6 +141,7 @@ function TaskRowItem({
state={task.state}
sessionState={task.sessionState}
foregroundActivity={task.foregroundActivity}
interrupted={task.interrupted}
isArchived={task.isArchived}
isSelected={isSelected}
diffStats={task.diffStats}
@@ -18,6 +18,8 @@ export type TaskSwitcherItem = {
sessionState?: TaskSessionState;
/** Task-level most-active-wins busy aggregate (ADR-0049) from the task record. */
foregroundActivity?: ForegroundActivity | null;
/** True when the task's session was mid-turn when the backend died. */
interrupted?: boolean;
description?: string;
workflowId?: string;
workflowName?: string;
@@ -0,0 +1,97 @@
import { test, expect } from "../../fixtures/test-base";
import { KanbanPage } from "../../pages/kanban-page";
import { SessionPage } from "../../pages/session-page";
// Interrupted-task indicator: when Kandev dies while a task's session is
// mid-turn, startup reconciliation marks the task with the `interrupted_at`
// metadata key and the task list shows a red alert icon until the task is
// resumed. Seeding `interrupted_at` through the public task-metadata surface
// is the deterministic stand-in for a real crash; the icon itself must render
// from that marker alone.
test.describe("Task list — interrupted-task icon", () => {
test("shows the red interrupted icon only for marked tasks", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(90_000);
// Anchor task with an agent so we can open a session page and view the
// sidebar in its normal SSR-hydrated context.
await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Anchor Session",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
// The post-restart shape: a marked task reconciled to REVIEW with a
// WAITING_FOR_INPUT session.
const interrupted = await apiClient.createTask(seedData.workspaceId, "Interrupted Fixture", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
metadata: { interrupted_at: "2026-08-02T10:00:00Z" },
});
await apiClient.updateTaskState(interrupted.id, "REVIEW");
const plain = await apiClient.createTask(seedData.workspaceId, "Plain Fixture", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
await apiClient.updateTaskState(plain.id, "REVIEW");
// A terminal marked task must keep its done affordance, never the red icon.
const terminal = await apiClient.createTask(seedData.workspaceId, "Terminal Fixture", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
metadata: { interrupted_at: "2026-08-02T10:00:00Z" },
});
await apiClient.updateTaskState(terminal.id, "COMPLETED");
const kanban = new KanbanPage(testPage);
await kanban.goto();
const anchorCard = kanban.taskCardByTitle("Anchor Session");
await expect(anchorCard).toBeVisible({ timeout: 20_000 });
await anchorCard.click();
await expect(testPage).toHaveURL(/\/t\//, { timeout: 20_000 });
const session = new SessionPage(testPage);
await session.waitForLoad();
const interruptedRow = session.sidebarTaskItem("Interrupted Fixture");
await expect(interruptedRow).toBeVisible({ timeout: 20_000 });
await expect(interruptedRow.getByTestId("task-state-interrupted")).toBeVisible({
timeout: 20_000,
});
const plainRow = session.sidebarTaskItem("Plain Fixture");
await expect(plainRow).toBeVisible({ timeout: 20_000 });
await expect(plainRow.getByTestId("task-state-interrupted")).toHaveCount(0);
const terminalRow = session.sidebarTaskItem("Terminal Fixture");
await expect(terminalRow).toBeVisible({ timeout: 20_000 });
await expect(terminalRow.getByTestId("task-state-interrupted")).toHaveCount(0);
// Reload: the marker must survive SSR hydration from the boot payload.
await testPage.reload();
await session.waitForLoad();
await expect(session.sidebarTaskItem("Interrupted Fixture")).toBeVisible({
timeout: 20_000,
});
await expect(
session.sidebarTaskItem("Interrupted Fixture").getByTestId("task-state-interrupted"),
).toBeVisible({ timeout: 20_000 });
await expect(
session.sidebarTaskItem("Plain Fixture").getByTestId("task-state-interrupted"),
).toHaveCount(0);
});
});
+12
View File
@@ -143,6 +143,18 @@ describe("toKanbanTask — HTTP DTO / WS payload parity", () => {
expect(toKanbanTask(wsPayload(invalid)).taskPendingAction).toBeUndefined();
});
it("maps the interrupted marker from HTTP and WS shapes", () => {
const marked = { interrupted: true } as Partial<TaskLike>;
const http = toKanbanTask(httpDTO(marked));
const ws = toKanbanTask(wsPayload(marked));
expect(http.interrupted).toBe(true);
expect(ws.interrupted).toBe(true);
// Absent field maps to undefined so a partial update can never synthesize
// an interrupted reading.
expect(toKanbanTask(httpDTO()).interrupted).toBeUndefined();
expect(toKanbanTask(wsPayload()).interrupted).toBeUndefined();
});
it("maps the bounded status summary from HTTP and WS task shapes", () => {
const statusSummary = {
revision: 7,
+3
View File
@@ -51,6 +51,8 @@ export type TaskLike = {
primary_session_state?: TaskSessionState | string | null;
primary_session_pending_action?: TaskPendingAction | null;
task_pending_action?: TaskPendingAction | null;
/** True when the task's session was mid-turn when the backend died. */
interrupted?: boolean;
foreground_activity?: ForegroundActivity | null;
active_subagent_count?: number;
session_count?: number | null;
@@ -146,6 +148,7 @@ export function toKanbanTask(source: TaskLike): KanbanTask {
primarySessionState: source.primary_session_state ?? undefined,
primarySessionPendingAction: pickPendingAction(source.primary_session_pending_action),
taskPendingAction: pickPendingAction(source.task_pending_action),
interrupted: source.interrupted,
foregroundActivity: pickForegroundActivity(source.foreground_activity),
activeSubagentCount: source.active_subagent_count ?? undefined,
sessionCount: source.session_count ?? undefined,
@@ -82,6 +82,8 @@ export type KanbanState = {
* list background-running affordance.
*/
foregroundActivity?: ForegroundActivity | null;
/** True when the task's session was mid-turn when the backend died. */
interrupted?: boolean;
/** Live subagents across this task's sessions; drives the board count chip. */
activeSubagentCount?: number;
sessionCount?: number | null;
+3
View File
@@ -341,6 +341,9 @@ export type Task = ActiveSubagentCountFields & {
primary_session_state?: TaskSessionState | null;
primary_session_pending_action?: TaskPendingAction | null;
task_pending_action?: TaskPendingAction | null;
/** True when the task's session was mid-turn when the backend died and has
* not been resumed since (startup reconciliation marker). */
interrupted?: boolean;
/**
* Task-level MOST-ACTIVE-WINS activity across sessions. "generating" wins,
* then "background"; null/absent means none is known. The count is the
+135 -35
View File
@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest";
import { isValidElement, type ReactNode } from "react";
import { render } from "@testing-library/react";
import { TooltipProvider } from "@kandev/ui/tooltip";
import {
IconCheck,
IconCircleCheck,
@@ -8,6 +10,7 @@ import {
IconLoader2,
IconMessageQuestion,
IconShieldQuestion,
IconX,
} from "@tabler/icons-react";
import {
getSessionStateIcon,
@@ -32,11 +35,13 @@ describe("getTaskStateIcon", () => {
});
it("uses the question icon when there is a pending clarification", () => {
expect(iconType(getTaskStateIcon("REVIEW", undefined, true))).toBe(IconMessageQuestion);
expect(iconType(getTaskStateIcon("REVIEW", undefined, { hasPendingClarification: true }))).toBe(
IconMessageQuestion,
);
});
it("keeps review task state as the review check without pending clarification", () => {
expect(iconType(getTaskStateIcon("REVIEW", undefined, false))).toBe(IconCheck);
expect(iconType(getTaskStateIcon("REVIEW", undefined))).toBe(IconCheck);
});
});
@@ -45,25 +50,46 @@ describe("getTaskStateIcon — waiting-for-input variants", () => {
// permission → shield-question (needs me: approve/deny)
// Both must read apart from done AND from both running affordances by SHAPE.
it("uses the shield-question icon for a pending permission prompt", () => {
expect(iconType(getTaskStateIcon("REVIEW", undefined, false, null, true))).toBe(
IconShieldQuestion,
);
expect(
iconType(
getTaskStateIcon("REVIEW", undefined, {
foregroundActivity: null,
hasPendingPermission: true,
}),
),
).toBe(IconShieldQuestion);
});
it("lets a pending permission win over a coarse WAITING_FOR_INPUT state (not masked)", () => {
// A permission prompt often coincides with the coarse WAITING_FOR_INPUT
// state; the shield must not be hidden behind the generic question icon.
expect(iconType(getTaskStateIcon("WAITING_FOR_INPUT", undefined, false, null, true))).toBe(
IconShieldQuestion,
);
expect(
iconType(
getTaskStateIcon("WAITING_FOR_INPUT", undefined, {
foregroundActivity: null,
hasPendingPermission: true,
}),
),
).toBe(IconShieldQuestion);
});
it("distinguishes both waiting variants from done and from both running affordances by SHAPE", () => {
const clarification = iconType(getTaskStateIcon("REVIEW", undefined, true));
const permission = iconType(getTaskStateIcon("REVIEW", undefined, false, null, true));
const generating = iconType(getTaskStateIcon("IN_PROGRESS", undefined, false, "generating"));
const background = iconType(getTaskStateIcon("IN_PROGRESS", undefined, false, "background"));
const done = iconType(getTaskStateIcon("COMPLETED", undefined, false, null));
const clarification = iconType(
getTaskStateIcon("REVIEW", undefined, { hasPendingClarification: true }),
);
const permission = iconType(
getTaskStateIcon("REVIEW", undefined, {
foregroundActivity: null,
hasPendingPermission: true,
}),
);
const generating = iconType(
getTaskStateIcon("IN_PROGRESS", undefined, { foregroundActivity: "generating" }),
);
const background = iconType(
getTaskStateIcon("IN_PROGRESS", undefined, { foregroundActivity: "background" }),
);
const done = iconType(getTaskStateIcon("COMPLETED", undefined, { foregroundActivity: null }));
for (const running of [generating, background]) {
expect(clarification).not.toBe(running);
expect(permission).not.toBe(running);
@@ -75,7 +101,13 @@ describe("getTaskStateIcon — waiting-for-input variants", () => {
it("lets pending permission win over clarification and foreground activity", () => {
expect(
iconType(getTaskStateIcon("WAITING_FOR_INPUT", undefined, true, "generating", true)),
iconType(
getTaskStateIcon("WAITING_FOR_INPUT", undefined, {
hasPendingClarification: true,
foregroundActivity: "generating",
hasPendingPermission: true,
}),
),
).toBe(IconShieldQuestion);
});
@@ -83,21 +115,30 @@ describe("getTaskStateIcon — waiting-for-input variants", () => {
"lets a pending clarification win over %s activity",
(activity) => {
expect(
iconType(getTaskStateIcon("WAITING_FOR_INPUT", undefined, true, activity, false)),
iconType(
getTaskStateIcon("WAITING_FOR_INPUT", undefined, {
hasPendingClarification: true,
foregroundActivity: activity,
}),
),
).toBe(IconMessageQuestion);
},
);
it("lets generating activity win over a coarse waiting state without pending input", () => {
expect(iconType(getTaskStateIcon("WAITING_FOR_INPUT", undefined, false, "generating"))).toBe(
IconLoader2,
);
expect(
iconType(
getTaskStateIcon("WAITING_FOR_INPUT", undefined, { foregroundActivity: "generating" }),
),
).toBe(IconLoader2);
});
it("lets background activity win over a coarse waiting state without pending input", () => {
expect(iconType(getTaskStateIcon("WAITING_FOR_INPUT", undefined, false, "background"))).toBe(
IconLoader,
);
expect(
iconType(
getTaskStateIcon("WAITING_FOR_INPUT", undefined, { foregroundActivity: "background" }),
),
).toBe(IconLoader);
});
});
@@ -108,20 +149,73 @@ describe("getTaskStateIcon — task-level activity tri-state", () => {
it("(a) generating shows the running spinner even when the coarse state is done", () => {
// Most-active-wins: a generating session outranks a finished primary that
// would otherwise render the done check.
expect(iconType(getTaskStateIcon("COMPLETED", undefined, false, "generating"))).toBe(
IconLoader2,
);
expect(
iconType(getTaskStateIcon("COMPLETED", undefined, { foregroundActivity: "generating" })),
).toBe(IconLoader2);
});
it("(b) background shows a working spinner — never the done check — over a done coarse state", () => {
const bg = getTaskStateIcon("COMPLETED", undefined, false, "background");
const bg = getTaskStateIcon("COMPLETED", undefined, { foregroundActivity: "background" });
expect(iconType(bg)).toBe(IconLoader);
expect(iconType(bg)).not.toBe(IconCheck);
});
it("(c) falls through to the coarse task state when no session is active", () => {
expect(iconType(getTaskStateIcon("COMPLETED", undefined, false, null))).toBe(IconCheck);
expect(iconType(getTaskStateIcon("COMPLETED", undefined, false, undefined))).toBe(IconCheck);
expect(iconType(getTaskStateIcon("COMPLETED", undefined, { foregroundActivity: null }))).toBe(
IconCheck,
);
expect(iconType(getTaskStateIcon("COMPLETED", undefined))).toBe(IconCheck);
});
it("renders the accessible interrupted icon with the tooltip label", () => {
const { container } = render(
<TooltipProvider>
{getTaskStateIcon("REVIEW", undefined, { interrupted: true })}
</TooltipProvider>,
);
const icon = container.querySelector('[data-testid="task-state-interrupted"]');
expect(icon).not.toBeNull();
expect(icon?.className).toContain("text-red-500");
expect(container.querySelector('[aria-label="Interrupted by restart"]')).not.toBeNull();
// The icon itself is decorative; the label lives on the trigger.
expect(icon?.getAttribute("aria-hidden")).toBe("true");
});
it("keeps terminal state icons over a lingering interrupted marker", () => {
expect(iconType(getTaskStateIcon("COMPLETED", undefined, { interrupted: true }))).toBe(
IconCheck,
);
expect(iconType(getTaskStateIcon("FAILED", undefined, { interrupted: true }))).toBe(IconX);
expect(iconType(getTaskStateIcon("CANCELLED", undefined, { interrupted: true }))).toBe(IconX);
});
it("keeps active and pending affordances over the interrupted marker", () => {
expect(
iconType(
getTaskStateIcon("REVIEW", undefined, {
foregroundActivity: "generating",
interrupted: true,
}),
),
).toBe(IconLoader2);
expect(
iconType(
getTaskStateIcon("REVIEW", undefined, {
foregroundActivity: "background",
interrupted: true,
}),
),
).toBe(IconLoader);
expect(
iconType(
getTaskStateIcon("REVIEW", undefined, { hasPendingClarification: true, interrupted: true }),
),
).toBe(IconMessageQuestion);
expect(
iconType(
getTaskStateIcon("REVIEW", undefined, { hasPendingPermission: true, interrupted: true }),
),
).toBe(IconShieldQuestion);
});
it("safe fallback: an in-progress task with a MISSING aggregate reads not-done, never a check", () => {
@@ -131,8 +225,8 @@ describe("getTaskStateIcon — task-level activity tri-state", () => {
// a backend restart — must fall back to the working spinner, never the done
// check. The coarse IN_PROGRESS reading is itself not-done, so a missing
// aggregate can only ever soften to working, never harden to done.
const missingUndefined = getTaskStateIcon("IN_PROGRESS", undefined, false, undefined);
const missingNull = getTaskStateIcon("IN_PROGRESS", undefined, false, null);
const missingUndefined = getTaskStateIcon("IN_PROGRESS", undefined);
const missingNull = getTaskStateIcon("IN_PROGRESS", undefined, { foregroundActivity: null });
expect(iconType(missingUndefined)).toBe(IconLoader2);
expect(iconType(missingUndefined)).not.toBe(IconCheck);
expect(iconType(missingNull)).toBe(IconLoader2);
@@ -143,9 +237,13 @@ describe("getTaskStateIcon — task-level activity tri-state", () => {
// Icon TYPE (glyph) differs for all three, so the reading survives a
// grayscale/desaturated scan for color-vision-deficient operators
// The affordance remains distinguishable without color.
const generating = iconType(getTaskStateIcon("IN_PROGRESS", undefined, false, "generating"));
const background = iconType(getTaskStateIcon("IN_PROGRESS", undefined, false, "background"));
const done = iconType(getTaskStateIcon("COMPLETED", undefined, false, null));
const generating = iconType(
getTaskStateIcon("IN_PROGRESS", undefined, { foregroundActivity: "generating" }),
);
const background = iconType(
getTaskStateIcon("IN_PROGRESS", undefined, { foregroundActivity: "background" }),
);
const done = iconType(getTaskStateIcon("COMPLETED", undefined, { foregroundActivity: null }));
expect(background).not.toBe(generating);
expect(background).not.toBe(done);
expect(generating).not.toBe(done);
@@ -156,12 +254,14 @@ describe("getTaskStateIcon — task-level activity tri-state", () => {
// shape difference so background reads apart from generating at a glance — its
// own violet, distinct from generating's blue and done's green.
const generating = iconClassName(
getTaskStateIcon("IN_PROGRESS", undefined, false, "generating"),
getTaskStateIcon("IN_PROGRESS", undefined, { foregroundActivity: "generating" }),
);
const background = iconClassName(
getTaskStateIcon("IN_PROGRESS", undefined, false, "background"),
getTaskStateIcon("IN_PROGRESS", undefined, { foregroundActivity: "background" }),
);
const done = iconClassName(
getTaskStateIcon("COMPLETED", undefined, { foregroundActivity: null }),
);
const done = iconClassName(getTaskStateIcon("COMPLETED", undefined, false, null));
expect(background).toContain("text-violet-500");
expect(background).not.toBe(generating);
expect(background).not.toBe(done);
+94 -15
View File
@@ -1,4 +1,6 @@
import type { ComponentType } from "react";
import { useTranslation } from "react-i18next";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import {
IconAlertCircle,
IconAlertTriangle,
@@ -106,6 +108,15 @@ const PENDING_PERMISSION_ICON: IconConfig = {
className: STYLE_PERMISSION,
};
// The task-level interrupted affordance: the session was mid-turn when the
// backend died and the task has not been resumed. A red alert circle — red is
// otherwise the error/cancelled hue, but the alert shape plus the REVIEW/idle
// coarse state it replaces keeps it distinct from the terminal X affordances.
const TASK_INTERRUPTED_ICON: IconConfig = {
Icon: IconAlertCircle,
className: STYLE_ERROR,
};
const DEFAULT_TASK_ICON: IconConfig = {
Icon: IconAlertCircle,
className: STYLE_MUTED,
@@ -136,6 +147,61 @@ const ACTIVE_SESSION_STATES: ReadonlySet<string> = new Set<TaskSessionState>([
"RUNNING",
]);
// Terminal task states whose own icon (done check, failure X, cancel pause)
// always wins over the interrupted marker.
const TERMINAL_TASK_STATES: ReadonlySet<TaskState | undefined> = new Set([
"COMPLETED",
"FAILED",
"CANCELLED",
]);
/**
* True when the task or its primary session is in a terminal state whose own
* icon (done check, failure X, cancel pause) must win over the interrupted
* marker.
*/
export function isTerminalInterruptedState(
state?: TaskState,
sessionState?: TaskSessionState,
): boolean {
return (
state === "COMPLETED" ||
state === "FAILED" ||
state === "CANCELLED" ||
sessionState === "COMPLETED" ||
sessionState === "FAILED" ||
sessionState === "CANCELLED"
);
}
/**
* Shared red alert affordance for a task whose session was mid-turn when the
* backend died. Carries the accessible "Interrupted by restart" label and
* tooltip, so every surface that renders the interrupted state (sidebar rows,
* board cards, graph nodes, open-task header) presents it consistently.
*/
export function InterruptedTaskIcon({ className }: { className?: string }) {
const { t } = useTranslation();
return (
<Tooltip>
<TooltipTrigger asChild>
<span
aria-label={t("common:interruptedByRestart")}
tabIndex={0}
className="flex shrink-0 rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1"
>
<IconAlertCircle
aria-hidden="true"
data-testid="task-state-interrupted"
className={cn("text-red-500", className)}
/>
</span>
</TooltipTrigger>
<TooltipContent side="right">{t("common:interruptedByRestart")}</TooltipContent>
</Tooltip>
);
}
/**
* Returns true when the kanban card should show the spinning loader. The task
* workflow state and the primary session's runtime state are decoupled — the
@@ -177,12 +243,21 @@ export function isTaskInFlight(foregroundActivity?: ForegroundActivity | null):
return foregroundActivity === "generating" || foregroundActivity === "background";
}
function getTaskStateIconConfig(
state?: TaskState,
hasPendingClarification = false,
foregroundActivity?: ForegroundActivity | null,
hasPendingPermission = false,
): IconConfig {
type TaskStateIconOptions = {
hasPendingClarification?: boolean;
foregroundActivity?: ForegroundActivity | null;
hasPendingPermission?: boolean;
/** True when the task's session was mid-turn when the backend died. */
interrupted?: boolean;
};
function getTaskStateIconConfig(state?: TaskState, options: TaskStateIconOptions = {}): IconConfig {
const {
hasPendingClarification = false,
foregroundActivity,
hasPendingPermission = false,
interrupted = false,
} = options;
if (shouldUsePermissionTaskIcon(hasPendingPermission)) {
return PENDING_PERMISSION_ICON;
}
@@ -195,6 +270,12 @@ function getTaskStateIconConfig(
if (foregroundActivity === "generating") return TASK_GENERATING_ICON;
if (foregroundActivity === "background") return TASK_BACKGROUND_ICON;
if (isWaitingForInputState(state)) return TASK_STATE_ICONS.WAITING_FOR_INPUT;
// Interrupted (startup reconciliation marker): replaces the idle/done
// affordances but never overrides terminal states, which keep their own
// icons (done check, failure X, cancel pause).
if (interrupted && !TERMINAL_TASK_STATES.has(state)) {
return TASK_INTERRUPTED_ICON;
}
if (!state) return DEFAULT_TASK_ICON;
return TASK_STATE_ICONS[state] ?? DEFAULT_TASK_ICON;
}
@@ -202,16 +283,14 @@ function getTaskStateIconConfig(
export function getTaskStateIcon(
state?: TaskState,
className?: string,
hasPendingClarification = false,
foregroundActivity?: ForegroundActivity | null,
hasPendingPermission = false,
options: TaskStateIconOptions = {},
) {
const config = getTaskStateIconConfig(
state,
hasPendingClarification,
foregroundActivity,
hasPendingPermission,
);
const config = getTaskStateIconConfig(state, options);
// The interrupted affordance carries its own tooltip and accessible label,
// so it must render through the shared component rather than a bare icon.
if (config === TASK_INTERRUPTED_ICON) {
return <InterruptedTaskIcon className={cn("h-4 w-4", className)} />;
}
return <config.Icon className={cn("h-4 w-4", config.className, className)} />;
}
+2
View File
@@ -79,6 +79,7 @@ export function registerKanbanHandlers(store: StoreApi<AppState>): WsHandlers {
primarySessionState: existing?.primarySessionState,
primarySessionPendingAction: existing?.primarySessionPendingAction,
taskPendingAction: existing?.taskPendingAction,
interrupted: existing?.interrupted,
foregroundActivity: existing?.foregroundActivity,
...queueFields(task, existing),
};
@@ -121,6 +122,7 @@ export function registerKanbanHandlers(store: StoreApi<AppState>): WsHandlers {
t.foregroundActivity === undefined
? fallback?.foregroundActivity
: t.foregroundActivity,
interrupted: t.interrupted === undefined ? fallback?.interrupted : t.interrupted,
};
});
return {
+62 -23
View File
@@ -72,30 +72,28 @@ function makeMessage(payload: Record<string, unknown>) {
} as Parameters<NonNullable<ReturnType<typeof registerTasksHandlers>["task.updated"]>>[0];
}
type StoreTaskSeed = { id: string } & Record<string, unknown>;
// Shared by the activity-aggregate and interrupted-marker describes below;
// kept at module scope so the two identical builders cannot drift apart.
function storeWithTask(existing: StoreTaskSeed) {
const task = { workflowStepId: "step1", title: "T", position: 0, ...existing };
return makeStore({
kanban: {
workflowId: "wf1",
steps: [],
tasks: [task],
} as unknown as AppState["kanban"],
kanbanMulti: {
isLoading: false,
snapshots: {
wf1: { workflowId: "wf1", workflowName: "WF1", steps: [], tasks: [task] },
},
} as unknown as AppState["kanbanMulti"],
});
}
describe("task.updated task-level activity aggregate (live propagation + safe fallback)", () => {
type ExistingTask = {
id: string;
foregroundActivity?: "generating" | "background";
activeSubagentCount?: number;
};
function storeWithTask(existing: ExistingTask) {
const task = { workflowStepId: "step1", title: "T", position: 0, ...existing };
return makeStore({
kanban: {
workflowId: "wf1",
steps: [],
tasks: [task],
} as unknown as AppState["kanban"],
kanbanMulti: {
isLoading: false,
snapshots: {
wf1: { workflowId: "wf1", workflowName: "WF1", steps: [], tasks: [task] },
},
} as unknown as AppState["kanbanMulti"],
});
}
function activityFor(store: ReturnType<typeof makeStore>, id: string) {
const kanban = store.getState().kanban.tasks.find((t) => t.id === id)?.foregroundActivity;
const multi = store
@@ -187,3 +185,44 @@ describe("task.updated task-level activity aggregate (live propagation + safe fa
).toBe(3);
});
});
describe("task.updated interrupted marker (live propagation + safe fallback)", () => {
function interruptedFor(store: ReturnType<typeof makeStore>, id: string) {
const kanban = store.getState().kanban.tasks.find((t) => t.id === id)?.interrupted;
const multi = store
.getState()
.kanbanMulti.snapshots.wf1.tasks.find((t) => t.id === id)?.interrupted;
return { kanban, multi };
}
it("applies the marker from task.updated", () => {
const store = storeWithTask({ id: "t1" });
const handlers = registerTasksHandlers(store);
handlers["task.updated"]!(
makeMessage({ ...makeTask("t1"), state: "REVIEW", interrupted: true }),
);
expect(interruptedFor(store, "t1")).toEqual({ kanban: true, multi: true });
});
it("clears the marker on an explicit false", () => {
const store = storeWithTask({ id: "t1", interrupted: true });
const handlers = registerTasksHandlers(store);
handlers["task.updated"]!(makeMessage({ ...makeTask("t1"), interrupted: false }));
expect(interruptedFor(store, "t1")).toEqual({ kanban: false, multi: false });
});
it("preserves the marker when a partial update omits interrupted", () => {
// Clobber guard: a lightweight task.updated (e.g. a rename) must not wipe
// the interruption reading between real marker events.
const store = storeWithTask({ id: "t1", interrupted: true });
const handlers = registerTasksHandlers(store);
handlers["task.updated"]!(makeMessage({ ...makeTask("t1"), title: "Renamed" }));
expect(interruptedFor(store, "t1")).toEqual({ kanban: true, multi: true });
});
});
+25 -20
View File
@@ -52,27 +52,22 @@ function preservePrimaryExecutorFields(
}
}
function preserveOmittedStatusSummary(
existing: KanbanTask,
// A lightweight task.updated may omit an unchanged field; only an explicit
// value (true/false, null, an object) may change the cached reading. This is
// the shared preserve guard for every field that follows that contract —
// interrupted, status_summary, and parent_id (an explicit `parent_id: null`
// means detach; see parentIDEventField in service_tasks.go).
function preserveOmittedField<K extends keyof KanbanTask>(
existing: KanbanTask | undefined,
merged: KanbanTask,
payload: TaskEventPayload,
nextTask: KanbanTask,
payload: TaskEventPayload,
field: { payloadKey: keyof TaskEventPayload; taskField: K },
): void {
if (!hasPayloadField(payload, "status_summary") && nextTask.statusSummary === undefined) {
merged.statusSummary = existing.statusSummary;
}
}
// Task lifecycle events may omit parent_id when the parent is unchanged. Only
// an explicit `parent_id: null` means detach (see parentIDEventField in
// service_tasks.go), so an omitted link must not un-nest a cached child.
function preserveOmittedParent(
existing: KanbanTask,
merged: KanbanTask,
payload: TaskEventPayload,
): void {
if (!hasPayloadField(payload, "parent_id")) {
merged.parentTaskId = existing.parentTaskId;
if (!hasPayloadField(payload, field.payloadKey) && nextTask[field.taskField] === undefined) {
// The three call sites pass optional fields (interrupted, statusSummary,
// parentTaskId), so the undefined value is the safe "not present" reading.
merged[field.taskField] = existing?.[field.taskField] as KanbanTask[K];
}
}
@@ -86,7 +81,10 @@ function mergeTaskUpdate(
...nextTask,
...mergeTaskRepositoryFields(existing, nextTask),
};
preserveOmittedParent(existing, merged, payload);
preserveOmittedField(existing, merged, payload, nextTask, {
payloadKey: "parent_id",
taskField: "parentTaskId",
});
if (!hasPayloadField(payload, "primary_session_id") && nextTask.primarySessionId === undefined) {
merged.primarySessionId = existing.primarySessionId;
}
@@ -118,13 +116,20 @@ function mergeTaskUpdate(
) {
merged.foregroundActivity = existing.foregroundActivity;
}
preserveOmittedField(existing, merged, payload, nextTask, {
payloadKey: "interrupted",
taskField: "interrupted",
});
if (
!hasPayloadField(payload, "active_subagent_count") &&
nextTask.activeSubagentCount === undefined
) {
merged.activeSubagentCount = existing.activeSubagentCount;
}
preserveOmittedStatusSummary(existing, merged, nextTask, payload);
preserveOmittedField(existing, merged, payload, nextTask, {
payloadKey: "status_summary",
taskField: "statusSummary",
});
return merged;
}
+1
View File
@@ -141,6 +141,7 @@
"includeRecentBackendAndBrowserLogs": "Include recent backend & browser logs as context for the agent",
"installed": "Installed",
"integrations": "Integrations",
"interruptedByRestart": "Interrupted by restart",
"justNow": "just now",
"kandevAllowsEveryAgentPermissionRequest": "Kandev allows every agent permission request without prompting you.",
"kandevCouldNotLoad": "Kandev couldn’t load.",
+1
View File
@@ -141,6 +141,7 @@
"includeRecentBackendAndBrowserLogs": "Ĩńćĺũďē ŕēćēńţ ƀàćķēńď & ƀŕōŵśēŕ ĺōĝś àś ćōńţēxţ ƒōŕ ţĥē àĝēńţ",
"installed": "Ĩńśţàĺĺēď",
"integrations": "Ĩńţēĝŕàţĩōńś",
"interruptedByRestart": "Ĩńţēŕŕũƥţēď ƀŷ ŕēśţàŕţ",
"justNow": "ĵũśţ ńōŵ",
"kandevAllowsEveryAgentPermissionRequest": "Ķàńďēv àĺĺōŵś ēvēŕŷ àĝēńţ ƥēŕḿĩśśĩōń ŕēqũēśţ ŵĩţĥōũţ ƥŕōḿƥţĩńĝ ŷōũ.",
"kandevCouldNotLoad": "Ķàńďēv ćōũĺďń’ţ ĺōàď.",
@@ -0,0 +1,182 @@
---
spec: docs/specs/tasks/interrupted-task-indicator.md
created: 2026-08-02
status: complete
---
# Implementation Plan: Interrupted Task Indicator
## Overview
Detect tasks whose sessions were mid-turn (`STARTING`/`RUNNING`) when the
backend died, persist a durable `interrupted_at` marker in task metadata at
startup reconciliation, expose `interrupted` on the task DTO, and render a red
alert icon on every task-list surface until the task's session starts again.
No schema migration; no new task/session state; no new events or routes.
## Backend
### Marker key and detection
- Add `MetaKeyInterruptedAt = "interrupted_at"` beside the other task metadata
keys in `apps/backend/internal/task/models/models.go` (the `MetaKey*` const
block).
- In `apps/backend/internal/orchestrator/service.go` `reconcileOneSessionOnStartup`,
in the active-states branch (`STARTING`/`RUNNING`/`WAITING_FOR_INPUT`), when
`previousState` is `TaskSessionStateStarting` or `TaskSessionStateRunning`,
write the marker with the archive-atomic conditional
`SetTaskMetadataKeyIfNotArchived(ctx, running.TaskID, models.MetaKeyInterruptedAt, time.Now().UTC().Format(time.RFC3339))`
— the SQL guards `archived_at IS NULL` in the same statement, so an archive
that commits concurrently can never leave a marker on an archived task.
Log a warning on failure without aborting reconciliation.
- The `sessionExecutorStore` interface in the same file must gain
`SetTaskMetadataKey(ctx, taskID, key string, value interface{}) error`,
`SetTaskMetadataKeyIfNotArchived(ctx, taskID, key string, value interface{}) (bool, error)`,
and `RemoveTaskMetadataKey(ctx, taskID, key string) (bool, error)`. The
concrete sqlite/Postgres repository implements all three
(`apps/backend/internal/task/repository/sqlite/task.go`); test wrappers that
embed the interface inherit them.
### DTO exposure
- Add `Interrupted bool \`json:"interrupted,omitempty"\`` to `v1.Task` in
`apps/backend/pkg/api/v1/task.go` AND to `TaskDTO` in
`apps/backend/internal/task/dto/dto.go` (TaskDTO is the rich wire payload
for boot/task.updated; `v1.Task` is the list-endpoint payload).
- Derive it at both task serializers where `Metadata` is copied:
`FromTaskWithSessionInfo` in `apps/backend/internal/task/dto/dto.go`
(~line 714) and `(*Task).ToAPI` in `apps/backend/internal/task/models/models.go`
(~line 1675): `Interrupted: task.Metadata[models.MetaKeyInterruptedAt] != nil`.
### Clearing
- In `apps/backend/internal/orchestrator/event_handlers_streaming.go`, clear
the marker inside the session-start funnel. Verify the funnel: the
`updateTaskSessionStateWithHook` helper (and `setSessionStarting`) must cover
every path that moves a session into `STARTING`/`RUNNING` — launch, resume,
prompt dispatch, agent-ready wake. On a transition to `STARTING` or
`RUNNING`, call `RemoveTaskMetadataKey(taskID, MetaKeyInterruptedAt)`; when
it reports the key was removed, load the task and
`publishTaskUpdated(ctx, task)` so open clients drop the icon live.
- Do not clear in `reconcileSessionsOnStartup` itself (it writes
`WAITING_FOR_INPUT` directly, not through the funnel).
## Frontend
### Data plumbing
- `apps/web/lib/types/http.ts` — `Task` gains `interrupted?: boolean`.
- `apps/web/lib/kanban/map-task.ts` — `TaskLike` gains `interrupted?: boolean`;
`toKanbanTask` maps `interrupted: source.interrupted ?? undefined`.
- `apps/web/lib/state/slices/kanban/types.ts` — the kanban task row gains
`interrupted?: boolean`.
- `apps/web/lib/ws/handlers/tasks.ts` — `mergeTaskUpdate` preserves an existing
`interrupted` value when the payload omits the field (mirror the
`foreground_activity` guard) so a lightweight `task.updated` cannot wipe a
set marker.
- `apps/web/components/task/task-switcher.tsx` — `TaskSwitcherItem` gains
`interrupted?: boolean`; `TaskRow` passes it to `TaskItem`.
- `apps/web/components/task/task-session-sidebar-item.ts` — `buildSidebarItem`
maps `interrupted: task.interrupted`.
- `apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts` —
map the same field into mobile switcher items (the mobile drawer shares
`TaskItem`, so the icon renders there automatically once the field flows).
### Icon rendering
- `apps/web/components/task/task-item.tsx` — `TaskItem` gains an
`interrupted?: boolean` prop; `TaskStateIcon` gains a branch that renders a
red alert icon (`IconAlertCircle`, `text-red-500`, `data-testid="task-state-interrupted"`)
with a focusable tooltip/aria-label ("Interrupted by restart", externalized).
Precedence: after pending permission/clarification, generating/background
activity, preparing, and the running spinner; before the review/done branch.
It must never override a `COMPLETED`/`FAILED`/`CANCELLED` affordance.
- `apps/web/lib/ui/state-icons.tsx` — `getTaskStateIconConfig` /
`getTaskStateIcon` gain an `interrupted` parameter; the interrupted config
renders a red `IconAlertCircle` and wins over the coarse-state fallback for
non-terminal states. Update every caller:
`apps/web/components/kanban-card-content.tsx`,
`apps/web/components/kanban/graph2-step-node.tsx`,
`apps/web/components/kanban/swimlane-graph-content.tsx`,
`apps/web/app/tasks/rich-task-list-row.tsx` (path TBD),
`apps/web/components/task/task-state-actions.tsx`.
- i18n: add the tooltip/aria copy to the appropriate locale namespace
(`apps/web/src/locales/en/*.json`) and reference it through `t()`; the string
is new code and must pass `pnpm run i18n:ratchet`.
## Tests
- **Reconciliation marks interrupted tasks** — extend
`apps/backend/internal/orchestrator/task_operations_test.go` /
`reconcile_restart_test.go`: a `RUNNING` and a `STARTING` session's task gets
`interrupted_at` metadata; a `WAITING_FOR_INPUT` session's task does not; an
archived task is not marked; a `CREATED`/terminal session is not marked.
- **DTO derivation** — a task DTO built from a model with
`metadata["interrupted_at"]` reports `interrupted: true`; without it,
`false`/omitted.
- **Clearing** — after a session transitions to `STARTING` (via the funnel),
the marker is removed and `task.updated` is published; a transition that does
not remove the key publishes nothing extra.
- **Frontend unit tests** —
`apps/web/components/task/task-item.test.tsx` (icon precedence cases),
`apps/web/lib/ui/state-icons.test.tsx` (interrupted param cases),
`apps/web/lib/kanban/map-task.test.ts` (mapping),
`apps/web/lib/ws/handlers/tasks.test.ts` (merge preservation).
## E2E Tests
- **File:** `apps/web/e2e/tests/tasks/task-interrupted-icon.spec.ts` (create the
directory if the convention differs — follow sibling spec layout).
- **Scenario 1:** seed two tasks via the API client
(`createTask(workspaceId, title, { metadata: { interrupted_at: "<rfc3339>" } })`
and one without), open the task list, assert the marked task shows
`task-state-interrupted` and the plain task does not.
- **Scenario 2:** a task with `interrupted_at` and `state: "COMPLETED"` shows
the done icon, not the red icon (precedence).
- The e2e harness runs with `KANDEV_MOCK_AGENT`/e2e profile; no real executor
is needed.
## Implementation Waves And Parallel Candidates
Execution remains sequential in the primary conversation.
Wave 1:
- [x] [task-01-backend-startup-marker](task-01-backend-startup-marker.md)
Wave 2:
- [x] [task-02-backend-dto-and-clearing](task-02-backend-dto-and-clearing.md)
Wave 3:
- [x] [task-03-frontend-data-plumbing](task-03-frontend-data-plumbing.md)
Wave 4:
- [x] [task-04-frontend-icon-rendering](task-04-frontend-icon-rendering.md)
Wave 5:
- [x] [task-05-e2e-verification](task-05-e2e-verification.md)
Task 02 depends on the marker key from Task 01; Task 03 depends on the DTO
field from Task 02; Task 04 depends on the plumbed field from Task 03; Task 05
depends on the rendered icon from Task 04. No tasks are parallel-safe.
## Risks
- **Funnel coverage:** if the clear hook misses a start path (e.g. a direct
repo write on resume), the icon persists after resume. Mitigate by clearing
inside `updateTaskSessionStateWithHook` for both `STARTING` and `RUNNING`
next-states and asserting the funnel in tests.
- **Client merge wipe:** a `task.updated` without `interrupted` must preserve
the marker; the `foreground_activity` guard is the model.
- **Remote rows:** a live remote agent may be marked interrupted until its
status poll re-reports it; acceptable per spec, do not special-case without
approval.
- **Precedence regressions:** the red icon must never cover permission,
clarification, activity, or running affordances; the task-item and
state-icons test suites pin this.
- **i18n ratchet:** the new tooltip copy must go through `t()` with a real
locale entry, or `pnpm run i18n:ratchet` fails.
@@ -0,0 +1,68 @@
---
id: "01-backend-startup-marker"
title: "Backend startup interruption marker"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/tasks/interrupted-task-indicator.md"
---
# Task 01: Backend startup interruption marker
## Acceptance
- `models.MetaKeyInterruptedAt = "interrupted_at"` exists in the task metadata
key block.
- `reconcileOneSessionOnStartup` writes `interrupted_at` (RFC 3339 UTC now)
onto the owning task when `previousState` is `STARTING` or `RUNNING` and the
task exists and is not archived.
- Sessions that were `WAITING_FOR_INPUT`, `IDLE`, `CREATED`, `COMPLETED`,
`CANCELLED`, or `FAILED` at startup do not receive the marker.
- A failed marker write logs a warning and does not abort reconciliation.
- The `sessionExecutorStore` interface exposes `SetTaskMetadataKey` and
`RemoveTaskMetadataKey` (both already implemented by the concrete
repository).
## Verification
```bash
cd apps/backend && go test ./internal/orchestrator/... -run 'ReconcileSessionsOnStartup'
cd apps/backend && go build ./...
```
## Files likely touched
- `apps/backend/internal/task/models/models.go` — `MetaKeyInterruptedAt`.
- `apps/backend/internal/orchestrator/service.go` — `sessionExecutorStore`
interface + `reconcileOneSessionOnStartup` active-branch write.
- `apps/backend/internal/orchestrator/task_operations_test.go`,
`apps/backend/internal/orchestrator/reconcile_restart_test.go` — marker
assertions for RUNNING/STARTING vs WAITING_FOR_INPUT vs archived.
## Dependencies
None.
## Inputs
- Spec: `What`, `Data model`, `State machine` (unmarked → marked), `Failure
modes`.
- Plan: `Backend > Marker key and detection`.
- Existing pattern: the task `IN_PROGRESS → REVIEW` guard write in the same
function (`taskArchived` + `UpdateTaskStateIfCurrentIn`).
## Risks
- Marking a task whose taskID is empty, or an archive committing between a
guard read and the metadata write — the marker write must be archive-atomic
(`SetTaskMetadataKeyIfNotArchived`, `archived_at IS NULL` in the same
statement), so a concurrent archive can never leave a stale marker.
- Test wrappers embedding `sessionExecutorStore` compile unchanged because they
embed the interface.
## Output contract
Report the marker key, where the write sits in the reconciliation flow, the
exact tests added (including the negative cases), commands and results, then
mark this task `done` and update its checkbox in `plan.md`.
@@ -0,0 +1,72 @@
---
id: "02-backend-dto-and-clearing"
title: "Backend DTO exposure and marker clearing"
status: done
wave: 2
depends_on: ["01-backend-startup-marker"]
plan: "plan.md"
spec: "../../specs/tasks/interrupted-task-indicator.md"
---
# Task 02: Backend DTO exposure and marker clearing
## Acceptance
- `v1.Task` exposes `interrupted` (boolean, `omitempty`), derived at both task
serializers from `metadata["interrupted_at"]` presence.
- The marker clears when a session of the task transitions into `STARTING` or
`RUNNING` through the orchestrator's session-state funnel
(`updateTaskSessionStateWithHook` / `setSessionStarting` — verify the funnel
covers launch, resume, prompt dispatch, and agent-ready wake paths).
- When the key was actually removed, the orchestrator publishes `task.updated`
for that task; transitions that removed nothing publish nothing extra.
- Startup reconciliation itself never clears the marker.
## Verification
```bash
cd apps/backend && go test ./internal/orchestrator/... -run 'SessionStarting|ReconcileSessionsOnStartup|Interrupted'
cd apps/backend && go test ./internal/task/dto/... ./internal/task/models/...
cd apps/backend && go build ./...
```
## Files likely touched
- `apps/backend/pkg/api/v1/task.go` — `Interrupted` field on `v1.Task`.
- `apps/backend/internal/task/dto/dto.go` — `Interrupted` field on `TaskDTO`
(the rich wire payload) plus the derivation in `FromTaskWithSessionInfo`.
- `apps/backend/internal/task/models/models.go` — derived `Interrupted` in
`(*Task).ToAPI`.
- `apps/backend/internal/orchestrator/event_handlers_streaming.go` — clear +
publish in the session-start funnel.
- Orchestrator tests (`event_handlers_streaming_test.go` and related) — clear
behavior, publish-on-remove only.
## Dependencies
Task 01 (the key must exist before the DTO derivation and clearing compile).
## Inputs
- Spec: `API surface`, `State machine` (marked → cleared), `Failure modes`,
`Persistence guarantees`, scenarios 4–5.
- Plan: `Backend > DTO exposure` and `Backend > Clearing`.
- Existing pattern: `updateTaskSessionStateWithHook`'s publish discipline and
the `task.updated` rich payload through `taskEvents.PublishTaskUpdated`.
## Risks
- A start path that bypasses the funnel leaves the icon after resume — assert
the funnel covers every `STARTING`/`RUNNING` transition in the tests, and
clear on BOTH next-states if any path lands directly in `RUNNING`.
- `RemoveTaskMetadataKey` returns `(removed bool, err)`; only publish on
`removed == true`, and swallow/log a removal error without failing the
session transition.
- Postgres JSON patch behavior of `RemoveTaskMetadataKey` must be exercised by
the existing repository tests; do not add new dialect-specific SQL.
## Output contract
Report the DTO field, both serializer updates, the exact funnel site chosen for
clearing (with evidence it covers launch/resume/prompt/agent-ready), tests and
commands, then mark this task `done` and update `plan.md`.
@@ -0,0 +1,71 @@
---
id: "03-frontend-data-plumbing"
title: "Frontend interrupted-field plumbing"
status: done
wave: 3
depends_on: ["02-backend-dto-and-clearing"]
plan: "plan.md"
spec: "../../specs/tasks/interrupted-task-indicator.md"
---
# Task 03: Frontend interrupted-field plumbing
## Acceptance
- `Task.interrupted?: boolean` exists in `apps/web/lib/types/http.ts`.
- `toKanbanTask` maps `interrupted` from API payloads; the kanban task row type
carries it.
- `mergeTaskUpdate` in the WS `task.updated` handler preserves an existing
`interrupted` value when the payload omits the field (a lightweight update
must not wipe a set marker).
- `TaskSwitcherItem` carries `interrupted`; `buildSidebarItem` and the mobile
task-switcher sheet hooks map it from the kanban task.
- `TaskRow` passes `interrupted` into `TaskItem` (the icon itself is Task 04).
## Verification
```bash
cd apps/web && pnpm run typecheck
cd apps && pnpm --filter @kandev/web test -- lib/kanban/map-task.test.ts lib/ws/handlers/tasks.test.ts components/task/task-session-sidebar-item.test.ts 2>/dev/null || pnpm --filter @kandev/web test -- lib/kanban lib/ws/handlers
```
(Adjust the file list to the actual test locations; run the focused tests for
the files you touched.)
## Files likely touched
- `apps/web/lib/types/http.ts`
- `apps/web/lib/kanban/map-task.ts`
- `apps/web/lib/state/slices/kanban/types.ts`
- `apps/web/lib/ws/handlers/tasks.ts`
- `apps/web/components/task/task-switcher.tsx`
- `apps/web/components/task/task-session-sidebar-item.ts`
- `apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts`
- Matching `*.test.ts`/`*.test.tsx` for each mapping and merge change.
## Dependencies
Task 02 (the DTO field must exist so payloads carry it).
## Inputs
- Spec: `API surface`, `Failure modes` (client merge wipe), scenario 7.
- Plan: `Frontend > Data plumbing`.
- Existing pattern: the `foreground_activity` preserve guard in
`mergeTaskUpdate` and the `foregroundActivity` mapping in `toKanbanTask`.
## Risks
- Forgetting one of the two sidebar item builders (desktop
`buildSidebarItem` vs the mobile sheet hook) leaves a surface without the
field — both must map it.
- A partial `task.updated` merge that overwrites `interrupted` with
`undefined` wipes the marker client-side — the preserve guard must mirror the
`foreground_activity` semantics exactly (payload-omits-field → keep existing;
payload `null`/`false` → clear).
- Do not translate or rename the field; it is a typed boolean used in logic.
## Output contract
Report every file changed, the merge-preserve test case, typecheck and focused
test results, then mark this task `done` and update `plan.md`.
@@ -0,0 +1,75 @@
---
id: "04-frontend-icon-rendering"
title: "Frontend interrupted icon rendering"
status: done
wave: 4
depends_on: ["03-frontend-data-plumbing"]
plan: "plan.md"
spec: "../../specs/tasks/interrupted-task-indicator.md"
---
# Task 04: Frontend interrupted icon rendering
## Acceptance
- `TaskItem` renders a red alert icon (`IconAlertCircle`, `text-red-500`,
`data-testid="task-state-interrupted"`) when `interrupted` is true and no
higher-priority affordance applies: pending permission, pending
clarification, generating/background activity, preparing, and the running
spinner all win; the review/done affordances lose to the red icon.
- `getTaskStateIcon` / `getTaskStateIconConfig` accept an `interrupted`
parameter and return the red icon for non-terminal states; every caller
(kanban card, graph node, swimlane node, rich task-list row, task state
actions) passes the task's field.
- The icon has a focusable tooltip and accessible label with externalized copy
("Interrupted by restart") through `t()`; `pnpm run i18n:ratchet` passes for
the changed files.
- `COMPLETED`, `FAILED`, and `CANCELLED` tasks never render the red icon.
## Verification
```bash
cd apps && pnpm --filter @kandev/web test -- components/task/task-item.test.tsx lib/ui/state-icons.test.tsx
cd apps/web && pnpm run typecheck
cd apps && pnpm run i18n:ratchet
```
## Files likely touched
- `apps/web/components/task/task-item.tsx` (+ `task-item.test.tsx`)
- `apps/web/lib/ui/state-icons.tsx` (+ `state-icons.test.tsx`)
- `apps/web/components/kanban-card-content.tsx`
- `apps/web/components/kanban/graph2-step-node.tsx`
- `apps/web/components/kanban/swimlane-graph-content.tsx`
- `apps/web/app/tasks/rich-task-list-row.tsx`
- `apps/web/components/task/task-state-actions.tsx`
- `apps/web/src/locales/en/*.json` (new copy key)
## Dependencies
Task 03 (the field must reach `TaskItem` and the kanban task rows).
## Inputs
- Spec: `What` (icon precedence), `Scenarios` 1 and 6, `Out of scope`.
- Plan: `Frontend > Icon rendering`.
- Existing pattern: `BackgroundWorkTaskIcon` in `task-item.tsx` (focusable
tooltip wrapper, `aria-label`, `data-testid`) and the icon config map in
`state-icons.tsx` (`STYLE_ERROR = "text-red-500"`).
## Risks
- Positional-argument growth in `getTaskStateIcon` — keep the existing
argument order and update every caller and test; the signature is public to
the surfaces listed above (grep for `getTaskStateIcon(` to catch stragglers).
- The interrupted branch must sit after the activity/preparing/running checks
in `TaskStateIcon` or a resumed task briefly shows red over its spinner.
- The i18n ratchet judges changed lines: the tooltip/aria copy must use `t()`
with a real locale key, never a literal.
- Tooltip text must be stable and never compared with `===` elsewhere.
## Output contract
Report the precedence order implemented, all `getTaskStateIcon` callers
updated, the i18n key added, focused test and ratchet results, then mark this
task `done` and update `plan.md`.
@@ -0,0 +1,68 @@
---
id: "05-e2e-verification"
title: "E2E verification and final checks"
status: done
wave: 5
depends_on: ["04-frontend-icon-rendering"]
plan: "plan.md"
spec: "../../specs/tasks/interrupted-task-indicator.md"
---
# Task 05: E2E verification and final checks
## Acceptance
- A Playwright spec seeds one task with `metadata.interrupted_at` and one
without, and asserts the sidebar task list shows `task-state-interrupted`
only for the marked task.
- A marked task in `COMPLETED` state shows the done icon, not the red icon.
- The full local verification suite passes: `make fmt`, `make typecheck`,
`make test`, `make lint`.
## Verification
```bash
cd apps && pnpm --filter @kandev/web e2e -- task-interrupted-icon # or the repo's e2e invocation for one spec
cd apps && make -C .. fmt 2>/dev/null || make fmt
make typecheck
make test
make lint
```
(Follow the exact e2e invocation documented in `apps/web/e2e/README.md` and the
root `Makefile` targets; run the new spec plus the touched suites.)
## Files likely touched
- `apps/web/e2e/tests/tasks/task-interrupted-icon.spec.ts` (follow sibling
spec layout under `apps/web/e2e/tests/`)
- No public docs change: the feature is an icon on existing surfaces. Only
touch docs if a task-status reference page explicitly enumerates status
icons (check `docs/public/`; if one exists, add a one-line mention).
## Dependencies
Tasks 01–04 (the marker, DTO field, plumbing, and icon must all be live).
## Inputs
- Spec: `Scenarios` 1, 6.
- Plan: `E2E Tests`.
- Existing pattern: `apps/web/e2e/helpers/api-client.ts` `createTask` with
`metadata`, and sibling specs under `apps/web/e2e/tests/`.
## Risks
- The e2e backend must serve the derived `interrupted` field — run against the
e2e profile build so the backend code from Tasks 01–02 is present.
- Seeding `interrupted_at` through task metadata is the deterministic
stand-in for a real crash; do not attempt a real backend kill in the spec.
- A strict locator may match the interrupted icon in hidden/stale sidebar
instances — scope to the active task list container per the web AGENTS.md
guidance.
## Output contract
Report the spec file, the e2e command and result, the full
`make fmt/typecheck/test/lint` results, then mark this task `done` and update
`plan.md` to complete.
+1
View File
@@ -59,6 +59,7 @@ Kandev's task model: documents, execution stages, labels, blocker escalation, su
|---|---|
| [documents](tasks/documents.md) | shipped |
| [execution-stages](tasks/execution-stages.md) | shipped |
| [interrupted-task-indicator](tasks/interrupted-task-indicator.md) | complete |
| [labels](tasks/labels.md) | shipped |
| [title-length-limit](tasks/title-length-limit.md) | complete |
| [model-unification](tasks/model-unification.md) | draft |
@@ -0,0 +1,169 @@
---
status: complete
created: 2026-08-02
owner: kandev
---
# Interrupted Task Indicator
## Why
When Kandev crashes, is killed, or restarts while tasks are mid-turn, those
sessions are interrupted: the agent process is gone and the turn was cut short.
Startup reconciliation already flips such sessions to `WAITING_FOR_INPUT` and
the task to `REVIEW`, so after a restart an interrupted task is visually
indistinguishable from a task that finished its turn and is waiting normally.
Operators cannot tell which tasks lost work to the crash. This spec adds a
durable "interrupted" marker set at startup for tasks whose sessions were
mid-turn, surfaced as a red icon in the task list.
## What
- At startup, when reconciliation finds a session that was `STARTING` or
`RUNNING` (mid-turn) when the backend died, the owning task is marked
interrupted with a durable task metadata key `interrupted_at` holding the
reconciliation timestamp (RFC 3339 UTC).
- Sessions that were `WAITING_FOR_INPUT`, `IDLE`, `CREATED`, or terminal at
startup are not marked: they were not interrupted mid-work.
- Archived or deleted tasks are not marked (they do not appear in the active
task list).
- The task API exposes `interrupted: true` on the task DTO while the marker is
present; it is derived from the metadata key, so no schema migration is
required.
- Task list surfaces render a red alert icon beside the task while
`interrupted` is true: sidebar task rows, the mobile task-switcher drawer
(shared row rendering), kanban board cards, graph/swimlane nodes, the rich
task-list row, and the open-task state icon. The icon carries an accessible
label and tooltip ("Interrupted by restart").
- The marker clears when a session of the task next enters `STARTING` or
`RUNNING` — work has resumed, so the task is no longer interrupted — and the
cleared state is published over the existing `task.updated` event so open
clients drop the icon live.
- The red icon renders instead of the idle/done affordances only. Pending
permission, pending clarification, generating/background activity, and
running spinners keep their existing precedence; terminal `COMPLETED`,
`FAILED`, and `CANCELLED` states keep their existing icons.
## Data model
No schema change. The marker lives in the existing `tasks.metadata` JSON
column under a new reserved key:
```
MetaKeyInterruptedAt = "interrupted_at" // value: RFC 3339 UTC timestamp string
```
Task metadata is already serialized into the API task DTO and already
survives restarts. The repository's existing `SetTaskMetadataKey` /
`RemoveTaskMetadataKey` helpers (concurrent-key-safe JSON patch, both SQLite
and Postgres) are the only writers; nothing else may mutate the key.
## API surface
- `v1.Task` gains `interrupted bool json:"interrupted,omitempty"`.
- The field is computed at DTO conversion time in both task serializers
(`internal/task/dto` and `internal/task/models` ToAPI) as
`metadata["interrupted_at"] != nil`. It is not a stored column.
- The field flows through every existing task payload automatically: boot
payload, `kanban.update`, `task.created`, `task.updated`, `task.state_changed`,
and `GET /api/v1/tasks/:id`.
- No new HTTP route, WebSocket action, or event type.
## State machine
The marker follows this lifecycle:
- `unmarked`: no `interrupted_at` key.
- `marked`: `interrupted_at` present; task DTO reports `interrupted: true`.
- `cleared`: key removed; DTO reports `interrupted: false` (omitted).
Transitions:
- `unmarked -> marked` at startup reconciliation when a preserved
`executors_running` row's session was `STARTING` or `RUNNING` and the task
exists and is not archived. Overwriting an existing key is safe: a marked
task cannot have a mid-turn session at the next startup unless it was
resumed and crashed again.
- `marked -> cleared` when any session of the task transitions into
`STARTING` or `RUNNING` (launch, resume, prompt dispatch, agent-ready
wake). The transition chokepoint in the orchestrator
(`updateTaskSessionStateWithHook` / `setSessionStarting`; the implementer
verifies the funnel covers every start path) removes the key and publishes
`task.updated` only when the key was actually removed.
- Terminal task/session transitions do not clear the marker; terminal state
icons take precedence over the red icon, and archiving/deleting the task
removes the row with its metadata.
## Permissions
None. The marker is display metadata on a task the viewer can already see. It
is user-writable only through the generic task metadata update surface and has
no effect beyond the icon.
## Failure modes
- A metadata write fails during reconciliation: the marker is skipped with a
warning and startup continues; the task simply shows no icon until the next
restart.
- A metadata removal fails during a session start: the marker survives and the
next successful start transition clears it; the icon may briefly persist
behind the running spinner, which takes precedence.
- Remote executors: the backend connection is severed even if the remote agent
is still alive. The task is marked. If the remote status poll later reports
the session running again and the session transitions, the marker clears; if
not, the marker stays until the next resume. This is acceptable — from
Kandev's perspective the turn was cut off.
- An already-open client during startup reconnects to the boot payload, which
carries the marker; no startup-time event is required.
- A `task.updated` event that omits `interrupted` must not wipe a set marker in
client state (frontend merge preserves the field when the payload omits it,
mirroring `foreground_activity`).
## Persistence guarantees
- The marker is durable in `tasks.metadata` and survives any number of
restarts until a session start clears it.
- Clearing is a normal key removal; a crash between removal and the
`task.updated` publish leaves the client icon stale until the next payload,
never a corrupt row.
## Scenarios
- **GIVEN** a task whose primary session was `RUNNING` when the backend died,
**WHEN** the backend starts and reconciliation runs, **THEN** the task's
metadata contains `interrupted_at`, the task DTO reports `interrupted: true`,
and the sidebar row, board card, graph node, rich list row, and mobile
switcher row show the red alert icon.
- **GIVEN** a task whose session was `WAITING_FOR_INPUT` when the backend
died, **WHEN** reconciliation runs, **THEN** the task is not marked and shows
no red icon.
- **GIVEN** an archived task with a mid-turn session row, **WHEN**
reconciliation runs, **THEN** the task is not marked.
- **GIVEN** a marked task, **WHEN** the user resumes the session and it enters
`STARTING`/`RUNNING`, **THEN** the marker is removed, `task.updated` is
published, and every surface drops the red icon (running spinners take over).
- **GIVEN** a marked task that is never resumed, **WHEN** the backend restarts
again, **THEN** the marker survives and the icon still renders.
- **GIVEN** a marked task whose state is `COMPLETED`, **WHEN** any surface
renders it, **THEN** the green done icon wins and no red icon is shown.
- **GIVEN** a `task.updated` event without an `interrupted` field for a marked
task, **WHEN** a client merges it, **THEN** the client keeps the marker.
## Out of scope
- Changing the task or session state machine (no new task state; the task
remains `REVIEW` with a `WAITING_FOR_INPUT` session after reconciliation).
- Marking sessions or tasks `FAILED` on interruption; resumability behavior is
unchanged.
- Persisting the marker as a typed column; metadata is the single source.
- Office agent cards and the Office dashboard surface, which use their own
status presentation.
- Reconstructing detached remote-work liveness after restart.
- A user-facing action to clear or dismiss the icon manually.
## Related
- [Task Runtime Cleanup](runtime-cleanup.md) — startup reconciliation semantics
the marker hooks into.
- [Sidebar Task Completion Icons](../ui/sidebar-task-completion-icons.md) —
the shared task-row icon precedence this feature extends.