fix(task): cascade-archive child tasks on workflow delete (#1332)

* fix(task): cascade-archive child tasks on workflow delete

Tasks pointing at a deleted workflow_id silently became unreachable
because the FK on tasks.workflow_id was dropped to support ephemeral
quick-chat tasks, so SQLite cannot CASCADE. Service.DeleteWorkflow
now archives every active child task before removing the workflow,
re-using the existing ArchiveTask path (events, worktree cleanup,
agent stop). Frontend confirm copy and button label updated to reflect
the new behavior.

Closes #1279

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(task): make workflow-delete cascade race-safe

Address review feedback (Greptile, Claude, cubic): ArchiveTask now returns
a typed ErrTaskAlreadyArchived sentinel so DeleteWorkflow can swallow the
TOCTOU race where a task is archived between ListTasks and ArchiveTask.
Without the sentinel, that race would propagate as an opaque error and
leave the workflow row intact even though every task in the snapshot was
already in the desired state.

Tests:
- TestService_DeleteWorkflow_SkipsConcurrentlyArchivedTask covers the
  sentinel-skip path through the cascade.
- TestService_DeleteWorkflow_PartialArchiveErrorPreservesWorkflow covers
  the fail-fast contract on a real (non-sentinel) error: workflow row
  survives, previously-archived tasks stay archived, retry is safe.
- TestService_ArchiveTask_ReturnsAlreadyArchivedSentinel locks in the
  sentinel-error contract DeleteWorkflow relies on.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
João Salavisa
2026-06-11 15:41:09 +01:00
committed by GitHub
parent dfca24ee31
commit a4a6bce577
4 changed files with 223 additions and 5 deletions
@@ -2,6 +2,7 @@ package service
import (
"context"
"errors"
"fmt"
"net/url"
"os"
@@ -29,6 +30,12 @@ const defaultPriority = "medium"
// intentionally exempt.
var ErrSubtaskDepthExceeded = fmt.Errorf("cannot create a subtask of a subtask — maximum nesting depth is 1 for kanban tasks. Create a sibling task under the same parent or a top-level task instead")
// ErrTaskAlreadyArchived is returned by ArchiveTask when the target task
// already has archived_at set. Sentinel so cascade callers (e.g.
// DeleteWorkflow) can treat a concurrent archive as a no-op instead of
// aborting the whole operation.
var ErrTaskAlreadyArchived = errors.New("task is already archived")
type taskStopTarget struct {
sessionID string
executionID string
@@ -729,7 +736,7 @@ func (s *Service) ArchiveTask(ctx context.Context, id string) error {
}
if task.ArchivedAt != nil {
return fmt.Errorf("task is already archived: %s", id)
return fmt.Errorf("%w: %s", ErrTaskAlreadyArchived, id)
}
// 2. Gather data needed for cleanup BEFORE archive