Files
kandev/docs/plans/interrupted-task-indicator/plan.md
T
2026-08-23 17:34:52 +00:00

183 lines
8.7 KiB
Markdown

---
spec: docs/specs/tasks/requirements/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.