Skip to content

feat(task-lifecycle): introduce explicit TaskStatus state machine with "interrupted" history state #559

Description

@edelauna

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

Context

Tracked under the #355 parallelizable-tasks refactor — proposed addition to Epic 1 (Foundation) or Epic 2 (Lifecycle Fixes).

Problem

HistoryItem.status is an ad-hoc state machine: valid states ("active", "delegated", "completed") are implicit string literals with no enforced transition table. Guards are scattered across AttemptCompletionTool.ts, ClineProvider#cancelTask, ClineProvider#reopenParentFromDelegation, and ClineProvider#removeClineFromStack, leading to the class of bugs fixed in #457 and #510.

A concrete open gap: when a delegated subtask is interrupted mid-execution (cancelTask fires), the code permanently severs the parent link (awaitingChildId → undefined, parent status → "active"). When the subtask resumes and calls attempt_completion, it has no way to re-attach to its parent and falls through to a standalone "Start New Task" flow. Users experience this as "subtask didn't report back to parent" — confirmed by user reports.

Root cause: cancelTask conflates two distinct operations:

  1. Aborting the running LLM stream (transient, always correct)
  2. Severing the parent–child delegation link (should be an explicit user choice, not an automatic side effect)

There is no history pseudo-state (in the HSM sense) — no persisted record that a task was interrupted from a delegated context — so the relationship cannot be restored after reload.

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

Proposed Solution

Phase 1 — Minimal (ship independently, low risk)

Add "interrupted" as a valid HistoryItem status:

// Before
status: "active" | "delegated" | "completed"

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

Transition changes:

  • cancelTask on a delegated child → child status: "active" → "interrupted", parent retains status: "delegated" and awaitingChildId (link preserved)
  • AttemptCompletionTool already handles parentStatus === "delegated" — no change needed there
  • reopenParentFromDelegation guard already accepts "delegated" — no change needed
  • UI: parent in "delegated" state with child in "interrupted" shows "Subtask was interrupted — resume or abandon" rather than silently detaching

Files touched: HistoryItem type, ClineProvider#cancelTask, ClineProvider#removeClineFromStack, UI task list rendering for the interrupted state.

Phase 2 — Full state machine (part of #355 Epic 1)

Replace scattered status guards with an explicit transition table (XState or lightweight equivalent):

IDLE → ACTIVE → DELEGATED → ACTIVE (reopen) → COMPLETED
                          ↘ INTERRUPTED → ACTIVE (resume) / COMPLETED (finish after interrupt)
ACTIVE → INTERRUPTED → ACTIVE (resume) → COMPLETED
  • Transitions enforced as discriminated unions at compile time
  • Single TaskStateMachine owns all valid transitions — no more scattered if (status === "delegated") guards
  • Slots into the TaskRegistry (Story 3.2a) as the authoritative state authority per task

Acceptance Criteria

  • A delegated subtask that is interrupted mid-execution can resume and successfully report back to its parent
  • Parent task is not silently detached when child is interrupted — link survives extension reload
  • User can explicitly break the parent–child link ("Abandon subtask") which is the only action that currently auto-fires on cancel
  • All existing subtask e2e scenarios continue to pass (apps/vscode-e2e subtasks suite)
  • No regression in the cancelTask → standalone-child path (non-delegated tasks unaffected)

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