@@ -824,6 +824,68 @@ describe("ClineProvider flicker-free cancel", () => {
824824 )
825825 } )
826826
827+ // Regression test for the race where a user clicks Stop on a freshly-delegated
828+ // child and immediately navigates back to the parent (showTaskWithId), before
829+ // cancelTask()'s own persistence of childHistory.status = "interrupted" has
830+ // landed. Both cancelTask() and removeClineFromStack() serialize their parent
831+ // writes through runDelegationTransition(parentTaskId, ...), but removeClineFromStack
832+ // only skips its repair when taskHistoryStore.get(childTaskId)?.status === "interrupted".
833+ // If removeClineFromStack's transition wins the race and runs while the store still
834+ // reports "active" (the write from cancelTask() hasn't landed yet), it incorrectly
835+ // repairs the parent to "active" and clears awaitingChildId, permanently severing
836+ // the delegation link before the child ever gets a chance to report back.
837+ it ( "removeClineFromStack does not repair parent when a cancellation for the child is in flight" , async ( ) => {
838+ const parentHistory : HistoryItem = {
839+ id : "parent-1" ,
840+ number : 1 ,
841+ task : "parent task" ,
842+ ts : Date . now ( ) ,
843+ tokensIn : 10 ,
844+ tokensOut : 20 ,
845+ totalCost : 0.001 ,
846+ workspace : "/test/workspace" ,
847+ status : "delegated" ,
848+ awaitingChildId : "child-1" ,
849+ delegatedToId : "child-1" ,
850+ }
851+
852+ const childTask = {
853+ taskId : "child-1" ,
854+ instanceId : "inst-child" ,
855+ parentTaskId : "parent-1" ,
856+ emit : vi . fn ( ) ,
857+ abortTask : vi . fn ( ) . mockResolvedValue ( undefined ) ,
858+ }
859+ ; ( provider as any ) . clineStack = [ childTask ]
860+ ; ( provider as any ) . taskEventListeners = new Map ( )
861+
862+ // The store still reports "active" — cancelTask()'s write to "interrupted"
863+ // has not landed yet. This is the pre-write window of the race.
864+ vi . spyOn ( ( provider as any ) . taskHistoryStore , "get" ) . mockImplementation ( ( id : unknown ) =>
865+ id === "child-1" ? { status : "active" } : undefined ,
866+ )
867+
868+ provider . getTaskWithId = vi . fn ( ) . mockImplementation ( ( id ) => {
869+ if ( id === "parent-1" ) return Promise . resolve ( { historyItem : parentHistory } )
870+ throw new Error ( `unexpected task lookup: ${ id } ` )
871+ } ) as any
872+
873+ const updateTaskHistorySpy = vi . spyOn ( provider , "updateTaskHistory" ) . mockResolvedValue ( [ ] )
874+
875+ // Simulate cancelTask() having already synchronously marked this child as
876+ // "being cancelled" before its own await chain reaches the history write.
877+ ; ( provider as any ) . cancellingDelegationChildIds . add ( "child-1" )
878+
879+ await ( provider as any ) . removeClineFromStack ( )
880+
881+ // Parent must NOT be transitioned to active while the child's cancellation
882+ // is still in flight — repairing here would clear awaitingChildId and
883+ // permanently sever the delegation link before "interrupted" is persisted.
884+ expect ( updateTaskHistorySpy ) . not . toHaveBeenCalledWith (
885+ expect . objectContaining ( { id : "parent-1" , status : "active" } ) ,
886+ )
887+ } )
888+
827889 afterAll ( ( ) => {
828890 vi . restoreAllMocks ( )
829891 } )
0 commit comments