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:
- 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.
- 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:
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.
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
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.statusis a raw string ("active" | "delegated" | "completed") with transitions scattered acrossdelegateParentAndOpenChildandreopenParentFromDelegation. Nothing prevents writingstatus: "completed"from any state, and no startup logic detects inconsistent parent-child relationships left by a crash mid-transition. Two pre-existing bugs:"delegated"but before the child completes, the parent is permanently stuck — no recovery logic fires on startup."completed"but before marking the parent"active", the parent remains"delegated"with a completed child — permanently inconsistent.These become more likely at
maxConcurrency > 1where 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:The guard is enforced at two levels:
upsertCorewrite boundary — every single-item write throughupsertoratomicReadAndUpdatevalidates the transition. Legacy items withstatus: undefinedare normalised to"active"before comparison so idempotent re-writes don't throw.atomicUpdatePairwrite 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
assertValidTransitioncalls 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
reopenParentFromDelegationpersistscompletionResultSummaryonto 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():Files changed:
src/core/task-persistence/TaskHistoryStore.ts,src/core/webview/ClineProvider.tsAcceptance Criteria
assertValidTransition()is called before every write that changesstatus.upsert,atomicReadAndUpdate, oratomicUpdatePair.completionResultSummary.