feat: show red interrupted icon for tasks cut short by a restart (#2358)
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user