@@ -482,6 +482,312 @@ describe('groupEntriesByExecution', () => {
482482 } )
483483} )
484484
485+ describe ( 'duration computation' , ( ) => {
486+ /**
487+ * Regression guard for the 18m → 20m → 22m bug.
488+ *
489+ * When a loop iteration contains a parallel block, the iteration's displayed
490+ * duration must be wall-clock (max(endedAt) − min(startedAt)), not the sum of
491+ * child durationMs. Summing over concurrent parallel branches over-counts time
492+ * and causes the displayed iteration duration to climb rapidly as each branch
493+ * resolves.
494+ */
495+ it ( 'loop iteration with concurrent parallel branches uses wall-clock duration' , ( ) => {
496+ const branches = 5
497+ const branchDurationMs = 110_000
498+ const loopIterStartMs = Date . UTC ( 2025 , 0 , 1 , 0 , 0 , 0 )
499+ const loopIterEndMs = loopIterStartMs + branchDurationMs
500+
501+ const entries : ConsoleEntry [ ] = [ ]
502+ for ( let branch = 0 ; branch < branches ; branch ++ ) {
503+ entries . push (
504+ makeEntry ( {
505+ blockId : 'function-1' ,
506+ blockName : 'Function 1' ,
507+ executionOrder : branch + 1 ,
508+ startedAt : new Date ( loopIterStartMs ) . toISOString ( ) ,
509+ endedAt : new Date ( loopIterEndMs ) . toISOString ( ) ,
510+ durationMs : branchDurationMs ,
511+ iterationType : 'parallel' ,
512+ iterationCurrent : branch ,
513+ iterationTotal : branches ,
514+ iterationContainerId : 'parallel-1' ,
515+ parentIterations : [
516+ {
517+ iterationType : 'loop' ,
518+ iterationCurrent : 0 ,
519+ iterationTotal : 1 ,
520+ iterationContainerId : 'loop-1' ,
521+ } ,
522+ ] ,
523+ } )
524+ )
525+ }
526+
527+ const tree = buildEntryTree ( entries )
528+ const loopSubflow = tree . find ( ( n ) => n . entry . blockType === 'loop' )
529+ expect ( loopSubflow ) . toBeDefined ( )
530+
531+ const iteration = loopSubflow ! . children [ 0 ]
532+ expect ( iteration . nodeType ) . toBe ( 'iteration' )
533+ expect ( iteration . entry . durationMs ) . toBe ( branchDurationMs )
534+ expect ( iteration . entry . durationMs ) . toBeLessThan ( branches * branchDurationMs )
535+ } )
536+
537+ it ( 'subflow container with concurrent children uses wall-clock duration' , ( ) => {
538+ const branches = 4
539+ const branchDurationMs = 60_000
540+ const startMs = Date . UTC ( 2025 , 0 , 1 , 0 , 0 , 0 )
541+ const endMs = startMs + branchDurationMs
542+
543+ const entries : ConsoleEntry [ ] = [ ]
544+ for ( let branch = 0 ; branch < branches ; branch ++ ) {
545+ entries . push (
546+ makeEntry ( {
547+ blockId : 'function-1' ,
548+ executionOrder : branch + 1 ,
549+ startedAt : new Date ( startMs ) . toISOString ( ) ,
550+ endedAt : new Date ( endMs ) . toISOString ( ) ,
551+ durationMs : branchDurationMs ,
552+ iterationType : 'parallel' ,
553+ iterationCurrent : branch ,
554+ iterationTotal : branches ,
555+ iterationContainerId : 'parallel-1' ,
556+ } )
557+ )
558+ }
559+
560+ const tree = buildEntryTree ( entries )
561+ const subflow = tree . find ( ( n ) => n . entry . blockType === 'parallel' )
562+ expect ( subflow ) . toBeDefined ( )
563+ expect ( subflow ! . entry . durationMs ) . toBe ( branchDurationMs )
564+ expect ( subflow ! . entry . durationMs ) . toBeLessThan ( branches * branchDurationMs )
565+ } )
566+
567+ it ( 'sequential loop iteration uses wall-clock duration' , ( ) => {
568+ const blockStart = Date . UTC ( 2025 , 0 , 1 , 0 , 0 , 0 )
569+ const blockEnd = blockStart + 5_000
570+
571+ const entries : ConsoleEntry [ ] = [
572+ makeEntry ( {
573+ blockId : 'function-1' ,
574+ executionOrder : 1 ,
575+ startedAt : new Date ( blockStart ) . toISOString ( ) ,
576+ endedAt : new Date ( blockEnd ) . toISOString ( ) ,
577+ durationMs : 5_000 ,
578+ iterationType : 'loop' ,
579+ iterationCurrent : 0 ,
580+ iterationTotal : 1 ,
581+ iterationContainerId : 'loop-1' ,
582+ } ) ,
583+ ]
584+
585+ const tree = buildEntryTree ( entries )
586+ const loop = tree . find ( ( n ) => n . entry . blockType === 'loop' )
587+ expect ( loop ) . toBeDefined ( )
588+ expect ( loop ! . children [ 0 ] . entry . durationMs ) . toBe ( 5_000 )
589+ } )
590+
591+ it ( 'parallel iteration uses wall-clock duration' , ( ) => {
592+ const start = Date . UTC ( 2025 , 0 , 1 , 0 , 0 , 0 )
593+ const end = start + 7_500
594+
595+ const entries : ConsoleEntry [ ] = [
596+ makeEntry ( {
597+ blockId : 'function-1' ,
598+ executionOrder : 1 ,
599+ startedAt : new Date ( start ) . toISOString ( ) ,
600+ endedAt : new Date ( end ) . toISOString ( ) ,
601+ durationMs : 7_500 ,
602+ iterationType : 'parallel' ,
603+ iterationCurrent : 0 ,
604+ iterationTotal : 1 ,
605+ iterationContainerId : 'parallel-1' ,
606+ } ) ,
607+ ]
608+
609+ const tree = buildEntryTree ( entries )
610+ const parallel = tree . find ( ( n ) => n . entry . blockType === 'parallel' )
611+ expect ( parallel ) . toBeDefined ( )
612+ expect ( parallel ! . children [ 0 ] . entry . durationMs ) . toBe ( 7_500 )
613+ } )
614+
615+ it ( 'sequential loop with gaps between iterations: each iteration is wall-clock of its own children' , ( ) => {
616+ const entries : ConsoleEntry [ ] = [ ]
617+ const iterStarts = [ 0 , 10_000 , 30_000 ]
618+ const blockDuration = 1_000
619+ const base = Date . UTC ( 2025 , 0 , 1 , 0 , 0 , 0 )
620+
621+ for ( let i = 0 ; i < iterStarts . length ; i ++ ) {
622+ entries . push (
623+ makeEntry ( {
624+ blockId : 'function-1' ,
625+ executionOrder : i + 1 ,
626+ startedAt : new Date ( base + iterStarts [ i ] ) . toISOString ( ) ,
627+ endedAt : new Date ( base + iterStarts [ i ] + blockDuration ) . toISOString ( ) ,
628+ durationMs : blockDuration ,
629+ iterationType : 'loop' ,
630+ iterationCurrent : i ,
631+ iterationTotal : 3 ,
632+ iterationContainerId : 'loop-1' ,
633+ } )
634+ )
635+ }
636+
637+ const tree = buildEntryTree ( entries )
638+ const loop = tree . find ( ( n ) => n . entry . blockType === 'loop' ) !
639+ for ( let i = 0 ; i < 3 ; i ++ ) {
640+ expect ( loop . children [ i ] . entry . durationMs ) . toBe ( blockDuration )
641+ }
642+ expect ( loop . entry . durationMs ) . toBe ( iterStarts [ 2 ] + blockDuration - iterStarts [ 0 ] )
643+ } )
644+
645+ it ( 'loop-in-loop: outer iteration duration spans all inner iterations wall-clock' , ( ) => {
646+ const entries : ConsoleEntry [ ] = [ ]
647+ const base = Date . UTC ( 2025 , 0 , 1 , 0 , 0 , 0 )
648+ const innerDuration = 2_000
649+ const innerCount = 3
650+
651+ for ( let inner = 0 ; inner < innerCount ; inner ++ ) {
652+ const start = base + inner * innerDuration
653+ entries . push (
654+ makeEntry ( {
655+ blockId : 'function-1' ,
656+ executionOrder : inner + 1 ,
657+ startedAt : new Date ( start ) . toISOString ( ) ,
658+ endedAt : new Date ( start + innerDuration ) . toISOString ( ) ,
659+ durationMs : innerDuration ,
660+ iterationType : 'loop' ,
661+ iterationCurrent : inner ,
662+ iterationTotal : innerCount ,
663+ iterationContainerId : 'inner-loop' ,
664+ parentIterations : [
665+ {
666+ iterationType : 'loop' ,
667+ iterationCurrent : 0 ,
668+ iterationTotal : 1 ,
669+ iterationContainerId : 'outer-loop' ,
670+ } ,
671+ ] ,
672+ } )
673+ )
674+ }
675+
676+ const tree = buildEntryTree ( entries )
677+ const outerLoop = tree . find ( ( n ) => n . entry . blockType === 'loop' ) !
678+ const outerIter = outerLoop . children [ 0 ]
679+ expect ( outerIter . entry . durationMs ) . toBe ( innerCount * innerDuration )
680+ } )
681+
682+ it ( 'loop-in-parallel: each branch duration reflects its own loop wall-clock' , ( ) => {
683+ const entries : ConsoleEntry [ ] = [ ]
684+ const base = Date . UTC ( 2025 , 0 , 1 , 0 , 0 , 0 )
685+ const innerDuration = 1_500
686+ const innerCount = 2
687+ const branches = 3
688+
689+ for ( let branch = 0 ; branch < branches ; branch ++ ) {
690+ for ( let inner = 0 ; inner < innerCount ; inner ++ ) {
691+ const start = base + inner * innerDuration
692+ entries . push (
693+ makeEntry ( {
694+ blockId : 'function-1' ,
695+ executionOrder : branch * innerCount + inner + 1 ,
696+ startedAt : new Date ( start ) . toISOString ( ) ,
697+ endedAt : new Date ( start + innerDuration ) . toISOString ( ) ,
698+ durationMs : innerDuration ,
699+ iterationType : 'loop' ,
700+ iterationCurrent : inner ,
701+ iterationTotal : innerCount ,
702+ iterationContainerId : 'inner-loop' ,
703+ parentIterations : [
704+ {
705+ iterationType : 'parallel' ,
706+ iterationCurrent : branch ,
707+ iterationTotal : branches ,
708+ iterationContainerId : 'parallel-1' ,
709+ } ,
710+ ] ,
711+ } )
712+ )
713+ }
714+ }
715+
716+ const tree = buildEntryTree ( entries )
717+ const parallelSubflow = tree . find ( ( n ) => n . entry . blockType === 'parallel' ) !
718+ expect ( parallelSubflow . children ) . toHaveLength ( branches )
719+ for ( let branch = 0 ; branch < branches ; branch ++ ) {
720+ const branchNode = parallelSubflow . children [ branch ]
721+ expect ( branchNode . entry . durationMs ) . toBe ( innerCount * innerDuration )
722+ }
723+ expect ( parallelSubflow . entry . durationMs ) . toBe ( innerCount * innerDuration )
724+ } )
725+
726+ it ( 'single-block iteration: duration equals the block durationMs' , ( ) => {
727+ const start = Date . UTC ( 2025 , 0 , 1 , 0 , 0 , 0 )
728+ const blockDuration = 3_141
729+
730+ const entries : ConsoleEntry [ ] = [
731+ makeEntry ( {
732+ blockId : 'function-1' ,
733+ executionOrder : 1 ,
734+ startedAt : new Date ( start ) . toISOString ( ) ,
735+ endedAt : new Date ( start + blockDuration ) . toISOString ( ) ,
736+ durationMs : blockDuration ,
737+ iterationType : 'loop' ,
738+ iterationCurrent : 0 ,
739+ iterationTotal : 1 ,
740+ iterationContainerId : 'loop-1' ,
741+ } ) ,
742+ ]
743+
744+ const tree = buildEntryTree ( entries )
745+ const loop = tree . find ( ( n ) => n . entry . blockType === 'loop' ) !
746+ expect ( loop . children [ 0 ] . entry . durationMs ) . toBe ( blockDuration )
747+ expect ( loop . entry . durationMs ) . toBe ( blockDuration )
748+ } )
749+
750+ it ( 'does not sum concurrent branch durations into iteration duration' , ( ) => {
751+ const branches = 20
752+ const branchDurationMs = 100_000
753+ const start = Date . UTC ( 2025 , 0 , 1 , 0 , 0 , 0 )
754+
755+ const entries : ConsoleEntry [ ] = [ ]
756+ for ( let branch = 0 ; branch < branches ; branch ++ ) {
757+ const branchStart = start + branch * 5
758+ entries . push (
759+ makeEntry ( {
760+ blockId : 'function-1' ,
761+ executionOrder : branch + 1 ,
762+ startedAt : new Date ( branchStart ) . toISOString ( ) ,
763+ endedAt : new Date ( branchStart + branchDurationMs ) . toISOString ( ) ,
764+ durationMs : branchDurationMs ,
765+ iterationType : 'parallel' ,
766+ iterationCurrent : branch ,
767+ iterationTotal : branches ,
768+ iterationContainerId : 'parallel-1' ,
769+ parentIterations : [
770+ {
771+ iterationType : 'loop' ,
772+ iterationCurrent : 0 ,
773+ iterationTotal : 1 ,
774+ iterationContainerId : 'loop-1' ,
775+ } ,
776+ ] ,
777+ } )
778+ )
779+ }
780+
781+ const tree = buildEntryTree ( entries )
782+ const loopSubflow = tree . find ( ( n ) => n . entry . blockType === 'loop' ) !
783+ const iteration = loopSubflow . children [ 0 ]
784+
785+ const wallClock = branchDurationMs + ( branches - 1 ) * 5
786+ expect ( iteration . entry . durationMs ) . toBe ( wallClock )
787+ expect ( iteration . entry . durationMs ) . toBeLessThan ( branches * branchDurationMs )
788+ } )
789+ } )
790+
485791describe ( 'flattenVisibleExecutionRows' , ( ) => {
486792 it ( 'only includes children for expanded nodes' , ( ) => {
487793 const childBlock = makeEntry ( {
0 commit comments