Skip to content

[Story 2.3] Task status transition guard and startup reconciliation #366

Description

@edelauna

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

Depends on: #365 (atomicUpdatePair — the two-file atomic write that this story's guard wraps).

Relates to #257 (chat stuck on "API Request..." after task switch — the transition guard and reconciliation prevent the delegation inconsistencies that contribute to locked UI state).

Context

HistoryItem.status is a raw string ("active" | "delegated" | "completed") with transitions scattered across delegateParentAndOpenChild and reopenParentFromDelegation. Nothing prevents writing status: "completed" from any state, and no startup logic detects inconsistent parent-child relationships left by a crash mid-transition. Two pre-existing bugs:

  1. If the extension crashes after marking a parent "delegated" but before the child completes, the parent is permanently stuck — no recovery logic fires on startup.
  2. If the extension crashes after marking a child "completed" but before marking the parent "active", the parent remains "delegated" with a completed child — permanently inconsistent.

These become more likely at maxConcurrency > 1 where more delegation transitions occur. The fix follows two patterns: (a) enforce valid transitions at write time, and (b) reconcile inconsistent state on startup using the Kubernetes controller reconciliation loop pattern.

Implementation (as shipped in PR #692)

Transition guard in TaskHistoryStore.ts:

const VALID_TRANSITIONS: Record<HistoryItemStatus, HistoryItemStatus[]> = {
  active: ["delegated", "completed"],
  delegated: ["active"],
  completed: [],  // terminal state
}

export function assertValidTransition(from: HistoryItemStatus | undefined, to: HistoryItemStatus): void {
  const fromStatus: HistoryItemStatus = from ?? "active"
  const validTargets = VALID_TRANSITIONS[fromStatus]
  if (!validTargets.includes(to)) {
    throw new Error(`Invalid task status transition: ${fromStatus}${to}`)
  }
}

The guard is enforced at two levels:

  1. upsertCore write boundary — every single-item write through upsert or atomicReadAndUpdate validates the transition. Legacy items with status: undefined are normalised to "active" before comparison so idempotent re-writes don't throw.
  2. atomicUpdatePair write boundary — the two-item atomic write validates both updater outputs before either file is written, so an invalid transition in either updater aborts the pair atomically.

Explicit assertValidTransition calls are also placed inside updater lambdas at each call site (delegateParentAndOpenChild, reopenParentFromDelegation, cancelTask, removeClineFromStack) for defence-in-depth.

Partial-write resilience: the child updater in reopenParentFromDelegation persists completionResultSummary onto the child record so that if the extension crashes after writing the child but before writing the parent, startup reconciliation copies the real result rather than falling back to "Task completed (recovered after interruption)".

Startup reconciliation in TaskHistoryStore.initialize():

private async reconcileDelegationState(): Promise<void> {
  return this.withLock(async () => {
    let repairsInThisPass: number
    do {
      repairsInThisPass = 0
      const byId = new Map(Array.from(this.cache.values()).map((i) => [i.id, i]))
      for (const [, item] of byId) {
        if (item.status !== "delegated") continue
        if (!item.awaitingChildId) {
          // invalid delegation — no child recorded
          await this.upsertCore({ ...item, status: "active", awaitingChildId: undefined, delegatedToId: undefined }, { skipTransitionCheck: true })
          repairsInThisPass++
        } else {
          const child = byId.get(item.awaitingChildId)
          if (!child) {
            // orphaned delegation — child record missing
            await this.upsertCore({ ...item, status: "active", awaitingChildId: undefined, delegatedToId: undefined }, { skipTransitionCheck: true })
            repairsInThisPass++
          } else if (child.status === "completed") {
            // interrupted handoff — child done but parent not yet resumed
            await this.upsertCore({ ...item, status: "active", awaitingChildId: undefined, completedByChildId: child.id,
              completionResultSummary: child.completionResultSummary ?? "Task completed (recovered after interruption)" }, { skipTransitionCheck: true })
            repairsInThisPass++
          }
          // child.status === "active" → leave as-is (child is resumable)
        }
      }
    } while (repairsInThisPass > 0)  // convergence loop handles chained delegations (A→B→C)
  })
}

Files changed: src/core/task-persistence/TaskHistoryStore.ts, src/core/webview/ClineProvider.ts

Acceptance Criteria

  • assertValidTransition() is called before every write that changes status.
  • Invalid transitions throw — no corrupt status can be written through upsert, atomicReadAndUpdate, or atomicUpdatePair.
  • On startup, orphaned delegated parents are automatically repaired.
  • On startup, interrupted completion handoffs (child completed, parent still delegated) are automatically repaired, copying the real completionResultSummary.
  • Reconciliation is idempotent and logged.
  • All existing delegation tests pass unchanged.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Fields

    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