Repository navigation
[Story 2.3] Task status transition guard and startup reconciliation #366
Description
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:
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 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 changesstatus. - Invalid transitions throw — no corrupt status can be written through
upsert,atomicReadAndUpdate, oratomicUpdatePair. - 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.
Activity