Skip to content

fix(task-lifecycle): preserve parent-child link when delegated subtask is interrupted #560

Description

@edelauna

Part of #357 (Epic 2: Task Lifecycle Fixes).

Problem

When a delegated subtask is interrupted mid-execution (user hits stop), cancelTask immediately severs the parent–child link:

  • Parent: status: "delegated""active", awaitingChildIdundefined
  • Child: retains parentTaskId but is now effectively standalone

When the user resumes the subtask and it calls attempt_completion, AttemptCompletionTool checks parentHistory.awaitingChildId === task.taskId — which is now false — and falls through to the standalone "Start New Task" flow. The subtask never reports back.

Users experience this as: "subtask ended with Start New Task instead of Complete Subtask and Keep Going" — particularly when the subtask was interrupted midway.

Confirmed reproducible after #510 (which fixed a different variant: profile-switch causing status: "active" on parent while awaitingChildId was still set).

Root cause: cancelTask conflates stream abortion (always correct) with parent link severance (should be explicit). There is no persisted record that the child was interrupted from a delegated context, so the relationship cannot survive a reload.

The in-memory cancelledDelegationChildIds Set is a partial guard but resets on extension reload.

Proposed Fix

ClineProvider#cancelTask — when cancelling a delegated child, do not clear the parent link. Instead mark the child as interrupted:

// Before (ClineProvider.ts ~L3151)
if (parentHistory?.status === "delegated" && parentHistory?.awaitingChildId === task.taskId) {
  await this.updateTaskHistory({
    ...parentHistory,
    status: "active",
    awaitingChildId: undefined,
  })
}

// After
if (parentHistory?.status === "delegated" && parentHistory?.awaitingChildId === task.taskId) {
  await this.updateTaskHistory({
    ...historyItem,
    status: "interrupted",  // new status — child is paused, not detached
  })
  // parent stays delegated with awaitingChildId intact
}

packages/typesHistoryItem — add "interrupted" to the status union:

status: "active" | "delegated" | "interrupted" | "completed"

AttemptCompletionTool — extend the child status check to accept "interrupted" alongside "active" (L100):

} else if (status === "active" || status === "interrupted") {

UI (task list) — interrupted subtask state needs a label and an "Abandon" action so users can explicitly break the link if they do not want to resume.

Files to Change

  • packages/types/src/HistoryItem.ts (or wherever the type lives) — add "interrupted" to status union
  • src/core/webview/ClineProvider.tscancelTask (~L3151), removeClineFromStack (~L523), startup reconciliation
  • src/core/tools/AttemptCompletionTool.ts — accept "interrupted" child status (~L100)
  • src/core/webview/ClineProvider.tsreopenParentFromDelegation guard may need "interrupted" added
  • webview-ui/ — task list UI for interrupted subtask state

Acceptance Criteria

  • Interrupt a delegated subtask mid-execution → resume it → it reports back to parent (no "Start New Task")
  • Parent–child link survives extension reload after interruption
  • User can explicitly abandon the interrupted subtask, which detaches the parent (current auto-detach behavior, now user-initiated)
  • Non-delegated task cancellation is unaffected
  • Existing subtask e2e suite passes (apps/vscode-e2e subtasks)

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions