Skip to content

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

Closed

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", awaitingChildId → undefined
  • 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/types — HistoryItem — 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.ts — cancelTask (~L3151), removeClineFromStack (~L523), startup reconciliation
  • src/core/tools/AttemptCompletionTool.ts — accept "interrupted" child status (~L100)
  • src/core/webview/ClineProvider.ts — reopenParentFromDelegation 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

Activity

  1. edelauna commented on Jun 14, 2026

    @edelauna
    ContributorAuthor

    Deferring in favor of #355 Epic 1+2 foundation

    A prototype implementation was built on issue/560 (PR #613) and put through a full multi-agent code review. The review surfaced 3 blocking concurrency bugs — all stemming from the same structural root cause: new delegation-state mutations (deleteTaskWithId parent repair, createTaskWithHistoryItem self-heal) bypassed the runDelegationTransition serialization lock, and the removeClineFromStack fail-closed path relied on a volatile in-memory Set that doesn't survive provider reload.

    These were fixed in the prototype, but the pattern is clear: every new state or transition added to the current ad-hoc system requires manually remembering to wrap mutations in the correct lock, persist the correct fallback state, and add idempotency guards — with no compile-time enforcement. This is exactly the class of bugs that #355 Epic 2 Story 2.3 (transition guards + startup reconciliation) and atomicReadAndUpdate() on TaskHistoryStore are designed to eliminate structurally.

    Recommendation: Land #355 Epics 1+2 first (Stories 1.1–1.3, 2.1–2.3), then implement interrupted as a single story on top of the transition guard from #366. The interrupted status becomes a one-line addition to the transition map rather than a multi-file patch with hand-coded guards.

    The prototype branch (issue/560, PR #613) is preserved for reference — the test coverage (E2E + unit) and the behavioral design (child marked interrupted, parent stays delegated, self-heal on rehydration) are directly reusable.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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