Files
kandev/docs/plans/interrupted-task-indicator/plan.md
T

8.7 KiB

spec, created, status
spec created status
docs/specs/tasks/interrupted-task-indicator.md 2026-08-02 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"`tov1.Taskinapps/backend/pkg/api/v1/task.goAND toTaskDTOinapps/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:

Wave 2:

Wave 3:

Wave 4:

Wave 5:

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.