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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user