8.7 KiB
8.7 KiB
spec, created, status
| spec | created | status |
|---|---|---|
| docs/specs/tasks/requirements/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 inapps/backend/internal/task/models/models.go(theMetaKey*const block). - In
apps/backend/internal/orchestrator/service.goreconcileOneSessionOnStartup, in the active-states branch (STARTING/RUNNING/WAITING_FOR_INPUT), whenpreviousStateisTaskSessionStateStartingorTaskSessionStateRunning, write the marker with the archive-atomic conditionalSetTaskMetadataKeyIfNotArchived(ctx, running.TaskID, models.MetaKeyInterruptedAt, time.Now().UTC().Format(time.RFC3339))— the SQL guardsarchived_at IS NULLin 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
sessionExecutorStoreinterface in the same file must gainSetTaskMetadataKey(ctx, taskID, key string, value interface{}) error,SetTaskMetadataKeyIfNotArchived(ctx, taskID, key string, value interface{}) (bool, error), andRemoveTaskMetadataKey(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
Metadatais copied:FromTaskWithSessionInfoinapps/backend/internal/task/dto/dto.go(~line 714) and(*Task).ToAPIinapps/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: theupdateTaskSessionStateWithHookhelper (andsetSessionStarting) must cover every path that moves a session intoSTARTING/RUNNING— launch, resume, prompt dispatch, agent-ready wake. On a transition toSTARTINGorRUNNING, callRemoveTaskMetadataKey(taskID, MetaKeyInterruptedAt); when it reports the key was removed, load the task andpublishTaskUpdated(ctx, task)so open clients drop the icon live. - Do not clear in
reconcileSessionsOnStartupitself (it writesWAITING_FOR_INPUTdirectly, not through the funnel).
Frontend
Data plumbing
apps/web/lib/types/http.ts—Taskgainsinterrupted?: boolean.apps/web/lib/kanban/map-task.ts—TaskLikegainsinterrupted?: boolean;toKanbanTaskmapsinterrupted: source.interrupted ?? undefined.apps/web/lib/state/slices/kanban/types.ts— the kanban task row gainsinterrupted?: boolean.apps/web/lib/ws/handlers/tasks.ts—mergeTaskUpdatepreserves an existinginterruptedvalue when the payload omits the field (mirror theforeground_activityguard) so a lightweighttask.updatedcannot wipe a set marker.apps/web/components/task/task-switcher.tsx—TaskSwitcherItemgainsinterrupted?: boolean;TaskRowpasses it toTaskItem.apps/web/components/task/task-session-sidebar-item.ts—buildSidebarItemmapsinterrupted: task.interrupted.apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts— map the same field into mobile switcher items (the mobile drawer sharesTaskItem, so the icon renders there automatically once the field flows).
Icon rendering
apps/web/components/task/task-item.tsx—TaskItemgains aninterrupted?: booleanprop;TaskStateIcongains 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 aCOMPLETED/FAILED/CANCELLEDaffordance.apps/web/lib/ui/state-icons.tsx—getTaskStateIconConfig/getTaskStateIcongain aninterruptedparameter; the interrupted config renders a redIconAlertCircleand 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 throught(); the string is new code and must passpnpm run i18n:ratchet.
Tests
- Reconciliation marks interrupted tasks — extend
apps/backend/internal/orchestrator/task_operations_test.go/reconcile_restart_test.go: aRUNNINGand aSTARTINGsession's task getsinterrupted_atmetadata; aWAITING_FOR_INPUTsession's task does not; an archived task is not marked; aCREATED/terminal session is not marked. - DTO derivation — a task DTO built from a model with
metadata["interrupted_at"]reportsinterrupted: true; without it,false/omitted. - Clearing — after a session transitions to
STARTING(via the funnel), the marker is removed andtask.updatedis 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 showstask-state-interruptedand the plain task does not. - Scenario 2: a task with
interrupted_atandstate: "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
updateTaskSessionStateWithHookfor bothSTARTINGandRUNNINGnext-states and asserting the funnel in tests. - Client merge wipe: a
task.updatedwithoutinterruptedmust preserve the marker; theforeground_activityguard 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, orpnpm run i18n:ratchetfails.