Skip to content

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

@edelauna

Description

@edelauna

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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions